7 Commits

Author SHA1 Message Date
X-iZhang 6f10406d5b chore: update version to v0.2.2
Docker / build (push) Has been cancelled
2026-07-11 01:16:03 +01:00
X-iZhang 9042068094 feat(models): add support for GPT-5.6 variants and update context windows for Grok models 2026-07-11 00:51:06 +01:00
dependabot[bot] 81ff0519dc chore(deps): bump soupsieve in the uv group across 1 directory (#347)
Bumps the uv group with 1 update in the / directory: [soupsieve](https://github.com/facelessuser/soupsieve).


Updates `soupsieve` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/facelessuser/soupsieve/releases)
- [Commits](https://github.com/facelessuser/soupsieve/compare/2.8.3...2.8.4)

---
updated-dependencies:
- dependency-name: soupsieve
  dependency-version: 2.8.4
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 22:47:18 +01:00
dinos 690b903f85 test: standardize async tests on pytest-asyncio auto mode (#338)
* chore: add pytest-asyncio in auto mode

* test: migrate channel and stream tests to native async

Convert run_async() wrapper tests to plain 'async def test_*' under
pytest-asyncio auto mode. collect_events() in stream_v3_fakes becomes a
coroutine awaited at every call site.

* test: migrate command and model/middleware tests to native async

Convert run_async() wrappers (import, alias, and fixture forms) to plain
'async def test_*'. Multi-call tests merge onto one loop as sequential
awaits; none asserted on loop identity.

* test: migrate TUI, notifier, gateway, and session tests to native async

TUI/notifier/gateway files convert run_async wrappers to plain async
tests. test_sessions.py's unittest.TestCase classes move to
unittest.IsolatedAsyncioTestCase (pytest-asyncio does not await async
methods on plain TestCase; converting blindly would have made ~70 tests
silently vacuous). Its setUpClass keeps a one-shot asyncio.run() since
IsolatedAsyncioTestCase has no async class-level hook. TestLoadingWidget
in test_tui_widgets.py drops its TestCase base for the same reason.

* test: replace direct asyncio.run() calls with native async tests

Convert tests that called asyncio.run() (directly or via a local _run
helper) to plain 'async def test_*'; delete the local helpers.

* test: drop undeclared anyio markers and delete run_async helper

The @pytest.mark.anyio tests relied on anyio being a transitive dep of
httpx; auto-mode pytest-asyncio collects them natively. run_async() and
its fixture are unreferenced after the migration, so remove them —
pytest-asyncio's per-test loop teardown covers the pending-task
cancellation the helper existed for (verified: full suite runs with no
'Event loop is closed' errors or destroyed-task warnings).
2026-07-08 18:37:48 +00:00
dinos d2452c54d5 Refactor onboarding OAuth flow for auxiliary models (#337)
* refactor(onboard): shared flow for ccproxy providers

* feat(onboard): support oauth configuration for auxiliary models

* fix(onboard): reuse main model auth for same-provider auxiliary

* fix(onboard): reconcile oauth providers
2026-07-08 18:28:44 +00:00
dinos a7b9e175c1 fix(config): set config.yaml permissions to 0x600 (#336) 2026-07-08 19:25:01 +01:00
dinos be3dd272c3 test: deflake timing-dependent tests (#335)
* test: deflake timing-dependent tests

Inject a clock into channel dedup tests, replace fixed async sleeps with
events/explicit flushes, and avoid wall-clock waits in background tests.

* coderabbit nit
2026-07-07 08:25:35 +01:00
76 changed files with 3638 additions and 3764 deletions
+1 -1
View File
@@ -5,5 +5,5 @@
<rect x="54" y="5" width="62" height="24" rx="6" fill="#1565c0"/> <rect x="54" y="5" width="62" height="24" rx="6" fill="#1565c0"/>
<text x="85" y="22" text-anchor="middle" <text x="85" y="22" text-anchor="middle"
font-family="Inter, -apple-system, system-ui, sans-serif" 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> </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"/> <rect x="54" y="5" width="62" height="24" rx="6" fill="#2563eb"/>
<text x="85" y="22" text-anchor="middle" <text x="85" y="22" text-anchor="middle"
font-family="Inter, -apple-system, system-ui, sans-serif" 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> </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, max_size: int = _DEDUP_MAX,
trim_to: int = _DEDUP_TRIM, trim_to: int = _DEDUP_TRIM,
ttl_seconds: float = _DEDUP_TTL, ttl_seconds: float = _DEDUP_TTL,
clock: Callable[[], float] | None = None,
) -> None: ) -> None:
self._seen: OrderedDict[str, float] = OrderedDict() self._seen: OrderedDict[str, float] = OrderedDict()
self._max = max_size self._max = max_size
self._trim = trim_to self._trim = trim_to
self._ttl = ttl_seconds self._ttl = ttl_seconds
self._clock = clock or time.monotonic
# ── public API ────────────────────────────────────────────────── # ── public API ──────────────────────────────────────────────────
@@ -93,15 +95,16 @@ class DedupCache:
if not msg_id: if not msg_id:
return False return False
self._prune() now = self._clock()
self._prune(now)
if msg_id in self._seen: if msg_id in self._seen:
# LRU: refresh position and timestamp # LRU: refresh position and timestamp
self._seen.move_to_end(msg_id) self._seen.move_to_end(msg_id)
self._seen[msg_id] = time.monotonic() self._seen[msg_id] = now
return True return True
self._seen[msg_id] = time.monotonic() self._seen[msg_id] = now
if len(self._seen) > self._max: if len(self._seen) > self._max:
while len(self._seen) > self._trim: while len(self._seen) > self._trim:
self._seen.popitem(last=False) self._seen.popitem(last=False)
@@ -118,9 +121,9 @@ class DedupCache:
# ── internal ──────────────────────────────────────────────────── # ── internal ────────────────────────────────────────────────────
def _prune(self) -> None: def _prune(self, now: float | None = None) -> None:
"""Remove entries older than *ttl_seconds*.""" """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. # OrderedDict is insertion-ordered; oldest entries are first.
while self._seen: while self._seen:
_key, ts = next(iter(self._seen.items())) _key, ts = next(iter(self._seen.items()))
@@ -428,11 +431,13 @@ class DedupMiddleware(InboundMiddleware):
max_size: int = 1000, max_size: int = 1000,
trim_to: int = 500, trim_to: int = 500,
ttl_seconds: float = 3600.0, ttl_seconds: float = 3600.0,
clock: Callable[[], float] | None = None,
) -> None: ) -> None:
self._cache = DedupCache( self._cache = DedupCache(
max_size=max_size, max_size=max_size,
trim_to=trim_to, trim_to=trim_to,
ttl_seconds=ttl_seconds, ttl_seconds=ttl_seconds,
clock=clock,
) )
async def process_inbound( async def process_inbound(
+66 -109
View File
@@ -371,14 +371,31 @@ def _step_minimax_region(config: EvoScientistConfig) -> str:
return _MINIMAX_REGIONS[region] return _MINIMAX_REGIONS[region]
def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: def _step_oauth_auth_mode(
"""Step 2a: Select Anthropic authentication mode (API key vs OAuth). 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: Args:
config: Current configuration. 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: 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 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 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 = [ choices = [
Choice(title="API Key (direct Anthropic access)", value="api_key"), Choice(title=f"API Key (direct {provider_label} access)", value="api_key"),
Choice( Choice(
title="Claude Code OAuth (via ccproxy — no API key needed)" title=f"{oauth_label} (via ccproxy — no API key needed)"
+ ( + (
"" ""
if ccproxy_available if ccproxy_available
@@ -401,12 +422,12 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
Choice(title="← Back (re-pick provider)", value=BACK_SENTINEL), 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"): if current not in ("api_key", "oauth"):
current = "api_key" current = "api_key"
question = questionary.select( question = questionary.select(
"Authentication mode [Esc/← to go back]:", f"{auth_question_label} [Esc/← to go back]:",
choices=choices, choices=choices,
default=current, default=current,
style=WIZARD_STYLE, style=WIZARD_STYLE,
@@ -448,11 +469,9 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
if auth_mode == "oauth": if auth_mode == "oauth":
_prompt_ccproxy_port(config) _prompt_ccproxy_port(config)
# If OAuth selected, check auth status and offer login authed, msg = check_ccproxy_auth(ccproxy_provider)
if auth_mode in ("oauth", "auto"):
authed, msg = check_ccproxy_auth()
if authed: if authed:
console.print(f" [green]✓ OAuth: {msg}[/green]") console.print(f" [green]✓ {auth_status_label}: {msg}[/green]")
relogin = questionary.confirm( relogin = questionary.confirm(
"Re-authenticate to refresh credentials?", "Re-authenticate to refresh credentials?",
default=False, default=False,
@@ -462,11 +481,13 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
if relogin is None: if relogin is None:
raise KeyboardInterrupt() raise KeyboardInterrupt()
if relogin: if relogin:
_run_ccproxy_login("claude_api", "OAuth") _run_ccproxy_login(ccproxy_provider, auth_status_label)
else: else:
console.print(f" [yellow]OAuth not authenticated: {msg}[/yellow]") console.print(
f" [yellow]{auth_status_label} not authenticated: {msg}[/yellow]"
)
login = questionary.confirm( login = questionary.confirm(
"Log in to Claude now?", f"Log in to {prompt_login_label} now?",
default=True, default=True,
style=CONFIRM_STYLE, style=CONFIRM_STYLE,
qmark=QMARK, qmark=QMARK,
@@ -474,11 +495,32 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
if login is None: if login is None:
raise KeyboardInterrupt() raise KeyboardInterrupt()
if login: if login:
_run_ccproxy_login("claude_api", "OAuth") _run_ccproxy_login(ccproxy_provider, auth_status_label)
return auth_mode 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: def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
"""Step 2b: Select OpenAI authentication mode (API key vs Codex OAuth). """Step 2b: Select OpenAI authentication mode (API key vs Codex OAuth).
@@ -488,101 +530,16 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
Returns: Returns:
Selected auth mode: "api_key" or "oauth". Selected auth mode: "api_key" or "oauth".
""" """
from ...ccproxy_manager import check_ccproxy_auth, is_ccproxy_available return _step_oauth_auth_mode(
config,
ccproxy_available = is_ccproxy_available() provider_label="OpenAI",
ccproxy_provider="codex",
from .prompter import BACK_SENTINEL, GoBack, install_navigation_keys config_attr="openai_auth_mode",
prompt_login_label="Codex",
choices = [ oauth_choice_label="Codex OAuth",
Choice(title="API Key (direct OpenAI access)", value="api_key"), status_label="Codex OAuth",
Choice( question_label="OpenAI authentication mode",
title="Codex OAuth (via ccproxy — no API key needed)"
+ (
""
if ccproxy_available
else " [requires: pip install evoscientist[oauth]]"
),
value="oauth",
),
questionary.Separator(),
Choice(title="← Back (re-pick provider)", value=BACK_SENTINEL),
]
current = config.openai_auth_mode
if current not in ("api_key", "oauth"):
current = "api_key"
question = questionary.select(
"OpenAI authentication mode [Esc/← to go back]:",
choices=choices,
default=current,
style=WIZARD_STYLE,
qmark=QMARK,
use_indicator=True,
) )
install_navigation_keys(question, with_back=True)
auth_mode = question.ask()
if auth_mode is None:
raise KeyboardInterrupt()
if auth_mode == BACK_SENTINEL:
raise GoBack()
if auth_mode == "oauth" and not ccproxy_available:
console.print(" [yellow]✗ ccproxy not installed[/yellow]")
console.print()
install = questionary.confirm(
'Install ccproxy now? (pip install "evoscientist[oauth]")',
default=True,
style=WIZARD_STYLE,
qmark=f" {QMARK}",
).ask()
if install is None:
raise KeyboardInterrupt()
if install:
console.print()
if _install_ccproxy():
console.print(" [green]✓ ccproxy installed successfully.[/green]")
else:
console.print(" [yellow]Falling back to API key mode.[/yellow]")
return "api_key"
else:
console.print(
' [dim]Skipped. Install manually: pip install "evoscientist[oauth]"[/dim]'
)
return "api_key"
# If OAuth selected, prompt for port and check auth status
if auth_mode == "oauth":
_prompt_ccproxy_port(config)
authed, msg = check_ccproxy_auth("codex")
if authed:
console.print(f" [green]✓ Codex OAuth: {msg}[/green]")
relogin = questionary.confirm(
"Re-authenticate to refresh credentials?",
default=False,
style=CONFIRM_STYLE,
qmark=QMARK,
).ask()
if relogin is None:
raise KeyboardInterrupt()
if relogin:
_run_ccproxy_login("codex", "Codex OAuth")
else:
console.print(f" [yellow]Codex OAuth not authenticated: {msg}[/yellow]")
login = questionary.confirm(
"Log in to Codex now?",
default=True,
style=CONFIRM_STYLE,
qmark=QMARK,
).ask()
if login is None:
raise KeyboardInterrupt()
if login:
_run_ccproxy_login("codex", "Codex OAuth")
return auth_mode
def _step_provider_api_key( def _step_provider_api_key(
+258 -181
View File
@@ -129,6 +129,12 @@ _PROVIDER_KEY_ATTR = {
"custom-anthropic": "custom_anthropic_api_key", "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: def _autosave(config: EvoScientistConfig) -> None:
"""Persist current config to disk between phases. """Persist current config to disk between phases.
@@ -142,6 +148,201 @@ def _autosave(config: EvoScientistConfig) -> None:
pass 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. # Sections offered in Keep/Modify/Reset → which step labels they enable.
_SECTION_LABELS: list[tuple[str, str]] = [ _SECTION_LABELS: list[tuple[str, str]] = [
("ui", "UI backend"), ("ui", "UI backend"),
@@ -479,102 +680,17 @@ def run_onboard(
provider = _step_provider(config) provider = _step_provider(config)
config.provider = provider 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: try:
if provider == "anthropic": ollama_detected_models = _configure_provider_connection(
if strict: config,
config.anthropic_auth_mode = "api_key" provider,
else: strict=strict,
config.anthropic_auth_mode = _step_anthropic_auth_mode( skip_validation=skip_validation,
config preset_api_key=_preset("api_key"),
) require_api_key=lambda provider=provider: _require(
elif provider == "openai": "api_key", f"{provider} API key"
if strict: ),
config.openai_auth_mode = "api_key" )
else:
config.openai_auth_mode = _step_openai_auth_mode(config)
else:
# Non-Anthropic/OpenAI provider: reset OAuth modes to
# avoid stale oauth config triggering ccproxy at startup.
config.anthropic_auth_mode = "api_key"
config.openai_auth_mode = "api_key"
except GoBack: except GoBack:
# User picked "← Back" — restore config to its state at the # User picked "← Back" — restore config to its state at the
# top of this iteration (drops any base_url / region / # top of this iteration (drops any base_url / region /
@@ -594,60 +710,9 @@ def run_onboard(
ollama_detected_models = [] ollama_detected_models = []
console.print(" [dim]↩ Returning to provider selection.[/dim]") console.print(" [dim]↩ Returning to provider selection.[/dim]")
continue 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) _reconcile_oauth_modes(config)
_skip_api_key = (
provider == "ollama"
or (
provider == "anthropic"
and config.anthropic_auth_mode == "oauth"
)
or (provider == "openai" and config.openai_auth_mode == "oauth")
)
if not _skip_api_key:
key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key")
preset_api_key = _preset("api_key")
if preset_api_key is not None:
# Validate the preset key against the same validator
# the interactive path uses, unless --skip-validation
# was passed. Interactive flow shows a "Save anyway?"
# confirm on failure; the non-interactive path has no
# way to ask, so a failed validation is fatal.
if not skip_validation:
from .helpers import _provider_key_info
_info = _provider_key_info(config, provider)
validate_fn = _info[2] if _info else None
if validate_fn is not None:
console.print(
" [dim]Validating preset API key...[/dim]",
end="",
)
valid, msg = validate_fn(preset_api_key)
if valid:
console.print(f"\r [green]✓ {msg}[/green] ")
else:
console.print(f"\r [red]✗ {msg}[/red] ")
raise RuntimeError(
f"--api-key rejected by {provider} "
f"validator: {msg}. Pass "
"--skip-validation to override."
)
setattr(config, key_attr, preset_api_key)
console.print(
f" [green]✓ API key: ***{preset_api_key[-4:]}[/green]"
" [dim](--api-key)[/dim]"
)
else:
_require("api_key", f"{provider} API key")
new_key = _step_provider_api_key(
config, provider, skip_validation
)
if new_key is not None:
setattr(config, key_attr, new_key)
elif not getattr(config, key_attr):
_print_step_skipped("API Key", "not set")
_autosave(config) _autosave(config)
else: else:
# Provider section skipped — keep prior provider value to drive # 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", "kept current" if config.auxiliary_model else "not set",
) )
elif _step_auxiliary_enable(config): elif _step_auxiliary_enable(config):
# Assemble: pick provider -> base URL (custom) -> key -> model, from .prompter import GoBack
# mirroring the main flow's order. Keys/base URLs are stored
# per provider, so when the auxiliary provider matches the main aux_ollama_detected_models: list[str] = []
# one they're already set and the user just keeps them (Enter). while True:
# Ollama needs no key. Re-runs default to the saved auxiliary loop_snapshot = copy.deepcopy(config)
# provider/model rather than the main ones. aux_provider = _step_provider(
aux_provider = _step_provider(
config,
label="co-pilot",
default_value=config.auxiliary_provider,
)
config.auxiliary_provider = aux_provider
if aux_provider == "custom-openai":
config.custom_openai_base_url = _step_base_url(
config, config,
current_value=config.custom_openai_base_url label="co-pilot",
or os.environ.get("CUSTOM_OPENAI_BASE_URL", ""), default_value=config.auxiliary_provider,
) )
elif aux_provider == "custom-anthropic": config.auxiliary_provider = aux_provider
config.custom_anthropic_base_url = _step_base_url( if (
config, aux_provider == config.provider
current_value=config.custom_anthropic_base_url and _provider_connection_configured(config, aux_provider)
or os.environ.get("CUSTOM_ANTHROPIC_BASE_URL", ""), ):
) if aux_provider == "ollama":
elif aux_provider == "minimax": aux_ollama_detected_models = ollama_detected_models
config.minimax_base_url = _step_minimax_region(config) _print_step_skipped(
if aux_provider != "ollama": "Co-pilot credentials",
aux_key_attr = _PROVIDER_KEY_ATTR.get( "reusing main provider settings",
aux_provider, "openai_api_key" )
) else:
new_aux_key = _step_provider_api_key( try:
config, aux_provider, skip_validation aux_ollama_detected_models = (
) _configure_provider_connection(
if new_aux_key is not None: config,
setattr(config, aux_key_attr, new_aux_key) 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.auxiliary_model = _step_model(
config, config,
aux_provider, aux_provider,
ollama_detected_models=aux_ollama_detected_models,
label="co-pilot", label="co-pilot",
default_value=config.auxiliary_model, default_value=config.auxiliary_model,
) )
@@ -725,6 +801,7 @@ def run_onboard(
# Skip: single driver — clear any prior auxiliary config. # Skip: single driver — clear any prior auxiliary config.
config.auxiliary_provider = "" config.auxiliary_provider = ""
config.auxiliary_model = "" config.auxiliary_model = ""
_reconcile_oauth_modes(config)
_autosave(config) _autosave(config)
if "tavily" in sections_to_run: 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 = get_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True) config_path.parent.mkdir(parents=True, exist_ok=True)
try:
config_path.parent.chmod(0o700)
except OSError:
pass
data = _config_to_dict(config) data = _config_to_dict(config)
@@ -560,6 +564,10 @@ def save_config(config: EvoScientistConfig) -> None:
sort_keys=False, sort_keys=False,
allow_unicode=True, allow_unicode=True,
) )
try:
config_path.chmod(0o600)
except OSError:
pass
def reset_config() -> None: def reset_config() -> None:
+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). # Qwen 3.7 closed-source tiers — Max flagship and Plus (1M).
"qwen3.7-max": 1_000_000, "qwen3.7-max": 1_000_000,
"qwen3.7-plus": 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-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 — exception to the ``claude-`` family (200K, not 1M).
"claude-haiku-4-5": 200_000, "claude-haiku-4-5": 200_000,
# MiniMax M3 — 1M context (M2.x variants stay at provider default ~204K). # 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). # Zhipu GLM-5.2 — 1M context, an exception to the ``glm-5`` family (203K).
# Matches OpenRouter ``z-ai/glm-5.2`` via split('/')[-1]. # Matches OpenRouter ``z-ai/glm-5.2`` via split('/')[-1].
"glm-5.2": 1_000_000, "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. # 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]] = [ _KNOWN_MODEL_FAMILIES: list[tuple[str, int]] = [
# All Claude — 1M via the ``context-1m-2025-08-07`` beta header. # All Claude — 1M via the ``context-1m-2025-08-07`` beta header.
("claude-", 1_000_000), ("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 # OpenAI GPT-5.5 family — base, pro, future variants
("gpt-5.5", 1_050_000), ("gpt-5.5", 1_050_000),
# Google Gemini 3.x family — flash, flash-lite, pro (1.05M). Excludes 2.5. # 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-sonnet-4-6", "claude-sonnet-4-6", "anthropic"),
("claude-haiku-4-5", "claude-haiku-4-5", "anthropic"), ("claude-haiku-4-5", "claude-haiku-4-5", "anthropic"),
# OpenAI # 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-pro", "gpt-5.5-pro", "openai"),
("gpt-5.5", "gpt-5.5", "openai"), ("gpt-5.5", "gpt-5.5", "openai"),
("gpt-5.4", "gpt-5.4", "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-opus-4.8-fast", "anthropic/claude-opus-4.8-fast", "openrouter"),
("claude-sonnet-5", "anthropic/claude-sonnet-5", "openrouter"), ("claude-sonnet-5", "anthropic/claude-sonnet-5", "openrouter"),
("claude-sonnet-4.6", "anthropic/claude-sonnet-4.6", "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-pro", "openai/gpt-5.5-pro", "openrouter"),
("gpt-5.5", "openai/gpt-5.5", "openrouter"), ("gpt-5.5", "openai/gpt-5.5", "openrouter"),
("gpt-5.4", "openai/gpt-5.4", "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-pro", "xiaomi/mimo-v2.5-pro", "openrouter"),
("mimo-v2.5", "xiaomi/mimo-v2.5", "openrouter"), ("mimo-v2.5", "xiaomi/mimo-v2.5", "openrouter"),
("grok-build-0.1", "x-ai/grok-build-0.1", "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-max", "qwen/qwen3.7-max", "openrouter"),
("qwen3.7-plus", "qwen/qwen3.7-plus", "openrouter"), ("qwen3.7-plus", "qwen/qwen3.7-plus", "openrouter"),
("qwen3.6-flash", "qwen/qwen3.6-flash", "openrouter"), ("qwen3.6-flash", "qwen/qwen3.6-flash", "openrouter"),
+3 -5
View File
@@ -10,7 +10,7 @@
<a href="https://pypi.org/project/EvoScientist/"><picture> <a href="https://pypi.org/project/EvoScientist/"><picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-light.svg"> <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-light.svg">
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-dark.svg"> <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-dark.svg">
<img alt="PyPI v0.2.1" src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-light.svg" height="28"> <img alt="PyPI v0.2.2" src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-light.svg" height="28">
</picture></a><a href="https://EvoScientist.github.io/"><picture> </picture></a><a href="https://EvoScientist.github.io/"><picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-website-light.svg"> <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-website-light.svg">
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-website-dark.svg"> <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-website-dark.svg">
@@ -151,6 +151,7 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human
<details> <details>
<summary>📦 Release Highlights — version changelog</summary> <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.
- **[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. - **[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. - **[26 Jun 2026]** **[v0.2.0](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.2.0)** — Scheduled tasks: cron-style recurring runs via `/schedule` or natural language, run unattended with shell-access gating; self-linking memory that connects observations into a knowledge graph (complements / contradicts / supersedes); a read-only `GET /api/models` endpoint for the WebUI model picker.
- **[23 Jun 2026]** **[v0.1.9](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.9)** — Hotfix for fresh installs: the first message crashed with `The subagent `task` tool cannot be exposed via `ptc`` after deepagents 0.6.11 / langchain-quickjs 0.3 reserved `task` as the REPL global. Removed `task` from the code-interpreter PTC allowlist (`task()` stays available as the REPL global; async dispatch stays in PTC) and pinned `deepagents[quickjs]~=0.6.11`. - **[23 Jun 2026]** **[v0.1.9](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.9)** — Hotfix for fresh installs: the first message crashed with `The subagent `task` tool cannot be exposed via `ptc`` after deepagents 0.6.11 / langchain-quickjs 0.3 reserved `task` as the REPL global. Removed `task` from the code-interpreter PTC allowlist (`task()` stays available as the REPL global; async dispatch stays in PTC) and pinned `deepagents[quickjs]~=0.6.11`.
@@ -433,10 +434,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) 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 `--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.
events as line-delimited JSON on stdout (one object per line), with all human
output on stderr — the integration surface for headless clients (e.g. an agent
runtime). See [docs/stream-json.md](docs/stream-json.md) for the event schema.
</details> </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 | | [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 ## Contributing a Recipe
See the [Contributing Guide](../CONTRIBUTING.md) for general guidelines. When adding a new recipe: See the [Contributing Guide](../CONTRIBUTING.md) for general guidelines. When adding a new recipe:
+5 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "EvoScientist" name = "EvoScientist"
version = "0.2.1" version = "0.2.2"
description = "EvoScientist: Towards Self-Evolving AI Scientists for End-to-End Scientific Discovery" description = "EvoScientist: Towards Self-Evolving AI Scientists for End-to-End Scientific Discovery"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -48,6 +48,7 @@ dependencies = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"pytest-asyncio>=1.0",
"pytest-cov>=5.0", "pytest-cov>=5.0",
"pytest-timeout>=2.4", "pytest-timeout>=2.4",
"ruff>=0.5", "ruff>=0.5",
@@ -58,6 +59,7 @@ dev = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"pytest-asyncio>=1.0",
"pytest-cov>=5.0", "pytest-cov>=5.0",
"pytest-timeout>=2.4", "pytest-timeout>=2.4",
"ruff>=0.5", "ruff>=0.5",
@@ -117,6 +119,8 @@ EvoScientist = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [ filterwarnings = [
"ignore::UserWarning:langchain_nvidia_ai_endpoints", "ignore::UserWarning:langchain_nvidia_ai_endpoints",
] ]
-28
View File
@@ -1,36 +1,8 @@
"""Shared fixtures for EvoScientist tests.""" """Shared fixtures for EvoScientist tests."""
import asyncio
import pytest 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) @pytest.fixture(autouse=True)
def _reset_tool_selection_state(): def _reset_tool_selection_state():
"""Isolate the process-global tool-selection state around every test. """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 unittest.mock import MagicMock
from EvoScientist.stream.events import stream_agent_events from EvoScientist.stream.events import stream_agent_events
from tests.conftest import run_async
async def async_iter(items: Iterable[Any]) -> AsyncIterator[Any]: async def async_iter(items: Iterable[Any]) -> AsyncIterator[Any]:
@@ -17,24 +16,20 @@ async def async_iter(items: Iterable[Any]) -> AsyncIterator[Any]:
yield item yield item
def collect_events( async def collect_events(
agent, agent,
message: str = "hi", message: str = "hi",
thread_id: str = "t1", thread_id: str = "t1",
): ):
"""Collect stream_agent_events output for synchronous tests.""" """Collect stream_agent_events output for tests."""
events = []
async def _run(): async for ev in stream_agent_events(
events = [] agent,
async for ev in stream_agent_events( message,
agent, thread_id,
message, ):
thread_id, events.append(ev)
): return events
events.append(ev)
return events
return run_async(_run())
def protocol_event( 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.qq.channel import QQChannel, QQConfig
from EvoScientist.channels.signal.channel import SignalChannel, SignalConfig from EvoScientist.channels.signal.channel import SignalChannel, SignalConfig
from tests.conftest import run_async as _run
class TestEmailChannelSmoke: class TestEmailChannelSmoke:
def test_start_raises_without_required_imap_settings(self): async def test_start_raises_without_required_imap_settings(self):
channel = EmailChannel(EmailConfig()) channel = EmailChannel(EmailConfig())
with pytest.raises( with pytest.raises(
ChannelError, match="imap_host and imap_username are required" 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()) channel = EmailChannel(EmailConfig())
msg = OutboundMessage( msg = OutboundMessage(
channel="email", channel="email",
@@ -29,16 +28,16 @@ class TestEmailChannelSmoke:
content="hello", content="hello",
metadata={"chat_id": "user@example.com"}, metadata={"chat_id": "user@example.com"},
) )
assert _run(channel.send(msg)) is False assert await channel.send(msg) is False
class TestSignalChannelSmoke: class TestSignalChannelSmoke:
def test_start_raises_without_phone_number(self): async def test_start_raises_without_phone_number(self):
channel = SignalChannel(SignalConfig()) channel = SignalChannel(SignalConfig())
with pytest.raises(ChannelError, match="phone_number is required"): 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")) channel = SignalChannel(SignalConfig(phone_number="+123456789"))
msg = OutboundMessage( msg = OutboundMessage(
channel="signal", channel="signal",
@@ -46,27 +45,29 @@ class TestSignalChannelSmoke:
content="hello", content="hello",
metadata={"chat_id": "+123456789"}, metadata={"chat_id": "+123456789"},
) )
assert _run(channel.send(msg)) is False assert await channel.send(msg) is False
class TestQQChannelSmoke: 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 from EvoScientist.channels.qq import channel as qq_module
monkeypatch.setattr(qq_module, "QQ_AVAILABLE", False) monkeypatch.setattr(qq_module, "QQ_AVAILABLE", False)
channel = QQChannel(QQConfig(app_id="id", app_secret="secret")) channel = QQChannel(QQConfig(app_id="id", app_secret="secret"))
with pytest.raises(ChannelError, match="SDK not installed"): 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 from EvoScientist.channels.qq import channel as qq_module
monkeypatch.setattr(qq_module, "QQ_AVAILABLE", True) monkeypatch.setattr(qq_module, "QQ_AVAILABLE", True)
channel = QQChannel(QQConfig(app_id="", app_secret="")) channel = QQChannel(QQConfig(app_id="", app_secret=""))
with pytest.raises(ChannelError, match="app_id and app_secret are required"): 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")) channel = QQChannel(QQConfig(app_id="id", app_secret="secret"))
msg = OutboundMessage( msg = OutboundMessage(
channel="qq", channel="qq",
@@ -74,11 +75,11 @@ class TestQQChannelSmoke:
content="hello", content="hello",
metadata={"chat_id": "openid"}, metadata={"chat_id": "openid"},
) )
assert _run(channel.send(msg)) is False assert await channel.send(msg) is False
class TestIMessageChannelSmoke: 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): async def _broken_start(self):
raise RuntimeError("imsg not found") raise RuntimeError("imsg not found")
@@ -87,9 +88,9 @@ class TestIMessageChannelSmoke:
monkeypatch.setattr(imessage_module.ImsgRpcClient, "start", _broken_start) monkeypatch.setattr(imessage_module.ImsgRpcClient, "start", _broken_start)
channel = IMessageChannelRpc(IMessageConfig()) channel = IMessageChannelRpc(IMessageConfig())
with pytest.raises(ChannelError, match="Failed to start imsg"): 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()) channel = IMessageChannelRpc(IMessageConfig())
msg = OutboundMessage( msg = OutboundMessage(
channel="imessage", channel="imessage",
@@ -97,4 +98,4 @@ class TestIMessageChannelSmoke:
content="hello", content="hello",
metadata={"chat_id": "+123456789"}, 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 from __future__ import annotations
import asyncio import asyncio
import threading
import pytest import pytest
@@ -93,88 +94,95 @@ def _make_loader_fn(agent_value="AGENT", fail_with=None, capture=None):
return _loader return _loader
def _run(coro): class _GatedThreadLoader:
return asyncio.run(coro) """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: class TestBackgroundAgentLoaderStart:
def test_start_creates_task_and_forwards_kwargs(self): async def test_start_creates_task_and_forwards_kwargs(self):
captured: dict = {} captured: dict = {}
loader = BackgroundAgentLoader(_make_loader_fn(capture=captured)) loader = BackgroundAgentLoader(_make_loader_fn(capture=captured))
async def _go(): loader.start(workspace_dir="/ws", checkpointer="CK")
loader.start(workspace_dir="/ws", checkpointer="CK") assert loader.task is not None
assert loader.task is not None assert loader.is_pending
assert loader.is_pending await loader.await_ready()
await loader.await_ready()
_run(_go())
assert captured["kwargs"][0] == {"workspace_dir": "/ws", "checkpointer": "CK"} 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()) loader = BackgroundAgentLoader(_make_loader_fn())
async def _go(): assert loader._load_id == 0
assert loader._load_id == 0 loader.start()
loader.start() assert loader._load_id == 1
assert loader._load_id == 1 loader.start()
loader.start() assert loader._load_id == 2
assert loader._load_id == 2 await loader.await_ready()
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): loader = BackgroundAgentLoader(blocking)
import time loader.start()
first_task = loader.task
def _blocking(*, on_mcp_progress=None): assert first_task is not None
time.sleep(0.05) assert await _wait_for_event(blocking.started)
return "LATE" # Supersede immediately; asyncio.to_thread wrapper gets cancelled.
loader._loader_fn = _make_loader_fn("FRESH")
async def _go(): loader.start()
loader = BackgroundAgentLoader(_blocking) agent = await loader.await_ready()
loader.start() assert agent == "FRESH"
first_task = loader.task blocking.release.set()
# Supersede immediately; asyncio.to_thread wrapper gets cancelled. try:
loader._loader_fn = _make_loader_fn("FRESH") await first_task
loader.start() except asyncio.CancelledError:
agent = await loader.await_ready() pass
assert agent == "FRESH" assert first_task.cancelled() or first_task.done()
# Let the first thread drain so its done callback (gated) fires.
await asyncio.sleep(0.1)
assert first_task.cancelled() or first_task.done()
_run(_go())
class TestBackgroundAgentLoaderCallbacks: 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]] = [] events: list[tuple[str, str, str]] = []
loader = BackgroundAgentLoader( loader = BackgroundAgentLoader(
_make_loader_fn(capture={}), _make_loader_fn(capture={}),
on_progress=lambda e, s, d: events.append((e, s, d)), on_progress=lambda e, s, d: events.append((e, s, d)),
) )
async def _go(): loader.start()
loader.start() await loader.await_ready()
await loader.await_ready()
_run(_go())
assert events == [("start", "srv", ""), ("success", "srv", "1")] 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.""" """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] = [] 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): def fast_loader(*, on_mcp_progress=None):
if on_mcp_progress is not None: if on_mcp_progress is not None:
on_mcp_progress("success", "from-fast", "1") on_mcp_progress("success", "from-fast", "1")
@@ -184,36 +192,32 @@ class TestBackgroundAgentLoaderCallbacks:
slow_loader, on_progress=lambda e, s, d: seen.append(s) slow_loader, on_progress=lambda e, s, d: seen.append(s)
) )
async def _go(): loader.start()
loader.start() assert await _wait_for_event(slow_loader.started)
# Supersede before the slow thread's event fires. # Loader 1 waits so its progress event fires AFTER load 2 starts.
await asyncio.sleep(0.01) loader._loader_fn = fast_loader
loader._loader_fn = fast_loader loader.start()
loader.start() await loader.await_ready()
await loader.await_ready() slow_loader.release.set()
# Let the superseded thread finish (its event is gated out). assert await _wait_for_event(slow_loader.finished)
await asyncio.sleep(0.1)
_run(_go())
assert "from-fast" in seen assert "from-fast" in seen
assert "from-slow" not 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 = [] got = []
loader = BackgroundAgentLoader( loader = BackgroundAgentLoader(
_make_loader_fn("MY_AGENT"), _make_loader_fn("MY_AGENT"),
on_success=lambda a: got.append(a), on_success=lambda a: got.append(a),
) )
async def _go(): loader.start()
loader.start() await loader.await_ready()
await loader.await_ready() await asyncio.sleep(0) # let done-callback run
await asyncio.sleep(0) # let done-callback run
_run(_go())
assert got == ["MY_AGENT"] 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") err = RuntimeError("load failed")
got_failures = [] got_failures = []
got_successes = [] got_successes = []
@@ -223,40 +227,33 @@ class TestBackgroundAgentLoaderCallbacks:
on_failure=lambda e: got_failures.append(e), on_failure=lambda e: got_failures.append(e),
) )
async def _go(): loader.start()
loader.start() with pytest.raises(RuntimeError, match="load failed"):
with pytest.raises(RuntimeError, match="load failed"): await loader.await_ready()
await loader.await_ready() await asyncio.sleep(0)
await asyncio.sleep(0)
_run(_go())
assert got_failures == [err] assert got_failures == [err]
assert got_successes == [] assert got_successes == []
class TestBackgroundAgentLoaderAwaitReady: class TestBackgroundAgentLoaderAwaitReady:
def test_returns_cached_agent_without_reawaiting(self): async def test_returns_cached_agent_without_reawaiting(self):
captured: dict = {} captured: dict = {}
loader = BackgroundAgentLoader(_make_loader_fn("A", capture=captured)) loader = BackgroundAgentLoader(_make_loader_fn("A", capture=captured))
async def _go(): loader.start()
loader.start() assert await loader.await_ready() == "A"
assert await loader.await_ready() == "A" assert await loader.await_ready() == "A"
assert await loader.await_ready() == "A"
_run(_go())
assert len(captured["kwargs"]) == 1 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()) loader = BackgroundAgentLoader(_make_loader_fn())
async def _go(): with pytest.raises(RuntimeError, match="before start"):
with pytest.raises(RuntimeError, match="before start"): await loader.await_ready()
await loader.await_ready()
_run(_go()) async def test_reraises_real_error_on_subsequent_awaits(self):
def test_reraises_real_error_on_subsequent_awaits(self):
"""After a failure, ``await_ready`` must keep raising the real exception — """After a failure, ``await_ready`` must keep raising the real exception —
not the "before start()" sentinel — until ``start`` is called again.""" not the "before start()" sentinel — until ``start`` is called again."""
@@ -265,16 +262,13 @@ class TestBackgroundAgentLoaderAwaitReady:
loader = BackgroundAgentLoader(_fail) loader = BackgroundAgentLoader(_fail)
async def _go(): loader.start()
loader.start() with pytest.raises(RuntimeError, match="bad MCP config"):
with pytest.raises(RuntimeError, match="bad MCP config"): await loader.await_ready()
await loader.await_ready() with pytest.raises(RuntimeError, match="bad MCP config"):
with pytest.raises(RuntimeError, match="bad MCP config"): await loader.await_ready()
await loader.await_ready()
_run(_go()) async def test_needs_restart_flags_failed_load_for_retry(self):
def test_needs_restart_flags_failed_load_for_retry(self):
calls = {"n": 0} calls = {"n": 0}
def flaky(*, on_mcp_progress=None): def flaky(*, on_mcp_progress=None):
@@ -285,17 +279,14 @@ class TestBackgroundAgentLoaderAwaitReady:
loader = BackgroundAgentLoader(flaky) loader = BackgroundAgentLoader(flaky)
async def _go(): assert loader.needs_restart # never started
assert loader.needs_restart # never started loader.start()
loader.start() with pytest.raises(RuntimeError):
with pytest.raises(RuntimeError): await loader.await_ready()
await loader.await_ready() assert loader.needs_restart # failed, caller may retry
assert loader.needs_restart # failed, caller may retry loader.start()
loader.start() assert await loader.await_ready() == "SECOND"
assert await loader.await_ready() == "SECOND" assert not loader.needs_restart # success → no retry
assert not loader.needs_restart # success → no retry
_run(_go())
class TestBackgroundAgentLoaderAdopt: class TestBackgroundAgentLoaderAdopt:
@@ -305,26 +296,19 @@ class TestBackgroundAgentLoaderAdopt:
assert loader.agent == "EXTERNAL" assert loader.agent == "EXTERNAL"
assert not loader.is_pending 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.""" """A late background completion must not overwrite an adopted agent."""
import time slow_loader = _GatedThreadLoader("FROM_BACKGROUND")
def _slow(*, on_mcp_progress=None): loader = BackgroundAgentLoader(slow_loader)
time.sleep(0.08)
return "FROM_BACKGROUND"
loader = BackgroundAgentLoader(_slow) loader.start()
assert await _wait_for_event(slow_loader.started)
async def _go(): loader.adopt("FROM_MODEL")
loader.start() slow_loader.release.set()
await asyncio.sleep(0.01) assert await _wait_for_event(slow_loader.finished)
loader.adopt("FROM_MODEL") await asyncio.sleep(0)
# Give the background thread time to finish and fire its assert loader.agent == "FROM_MODEL"
# done-callback; the generation token should make it a no-op.
await asyncio.sleep(0.1)
assert loader.agent == "FROM_MODEL"
_run(_go())
class TestBackgroundAgentLoaderIsPending: class TestBackgroundAgentLoaderIsPending:
@@ -332,29 +316,22 @@ class TestBackgroundAgentLoaderIsPending:
loader = BackgroundAgentLoader(_make_loader_fn()) loader = BackgroundAgentLoader(_make_loader_fn())
assert not loader.is_pending assert not loader.is_pending
def test_false_after_completion(self): async def test_false_after_completion(self):
loader = BackgroundAgentLoader(_make_loader_fn()) loader = BackgroundAgentLoader(_make_loader_fn())
async def _go(): loader.start()
loader.start() await loader.await_ready()
await loader.await_ready()
_run(_go())
assert not loader.is_pending assert not loader.is_pending
def test_true_between_start_and_completion(self): async def test_true_between_start_and_completion(self):
import time wait_loader = _GatedThreadLoader("ok")
def _wait_loader(*, on_mcp_progress=None): loader = BackgroundAgentLoader(wait_loader)
time.sleep(0.05)
return "ok"
loader = BackgroundAgentLoader(_wait_loader) loader.start()
assert await _wait_for_event(wait_loader.started)
async def _go(): assert loader.is_pending
loader.start() wait_loader.release.set()
assert loader.is_pending await loader.await_ready()
await loader.await_ready() assert not loader.is_pending
assert not loader.is_pending
_run(_go())
+129 -201
View File
@@ -5,6 +5,8 @@ import queue
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest
from EvoScientist.cli import async_notifier from EvoScientist.cli import async_notifier
from EvoScientist.cli.async_notifier import ( from EvoScientist.cli.async_notifier import (
dedup_notifications, dedup_notifications,
@@ -28,12 +30,6 @@ def test_notification_dataclass_fields():
def test_notification_queue_is_module_level_fifo(): 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", "") n1 = async_notifier.AsyncTaskNotification("a", "x", "success", "")
n2 = async_notifier.AsyncTaskNotification("b", "x", "success", "") n2 = async_notifier.AsyncTaskNotification("b", "x", "success", "")
async_notifier._notification_queue.put(n1) async_notifier._notification_queue.put(n1)
@@ -51,7 +47,7 @@ def _drain_queue(q):
return items 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( gateway = FakeGraphGateway(
state_values={ state_values={
"async_tasks": { "async_tasks": {
@@ -60,18 +56,16 @@ def test_read_async_tasks_from_gateway_reads_state_values(run_async):
} }
) )
tasks = run_async( tasks = await async_notifier.read_async_tasks_from_gateway(
async_notifier.read_async_tasks_from_gateway( gateway,
gateway, GraphTarget(local_graph=MagicMock()),
GraphTarget(local_graph=MagicMock()), "tid",
"tid",
)
) )
assert tasks == {"task-1": {"status": "success"}} 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 # Stream yields one "values" chunk with the final state, then closes
final_state = { final_state = {
"messages": [{"type": "ai", "content": "Quantum superposition is..."}] "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 # runs.get is used to fetch terminal status when stream ends
client.runs.get = AsyncMock(return_value={"status": "success"}) client.runs.get = AsyncMock(return_value={"status": "success"})
_drain_all(async_notifier) await async_notifier.watch_run_and_notify(client, "thr-1", "run-1", "writing-agent")
run_async(
async_notifier.watch_run_and_notify(client, "thr-1", "run-1", "writing-agent")
)
notifs = _drain_queue(async_notifier._notification_queue) notifs = _drain_queue(async_notifier._notification_queue)
assert len(notifs) == 1 assert len(notifs) == 1
@@ -99,7 +90,7 @@ def test_watcher_pushes_notification_on_stream_end(run_async):
assert notifs[0].status == "success" 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): async def fake_stream(*a, **kw):
raise RuntimeError("network broken") raise RuntimeError("network broken")
yield # unreachable; makes this an async generator 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"} return_value={"status": "error", "error": "network broken"}
) )
_drain_all(async_notifier) await async_notifier.watch_run_and_notify(client, "thr-4", "run-4", "agentZ")
run_async(async_notifier.watch_run_and_notify(client, "thr-4", "run-4", "agentZ"))
notif = async_notifier._notification_queue.get_nowait() notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "error" 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 """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 and registers the new one — supports update_async_task creating a new
run_id on the same thread_id.""" 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.join_stream = fake_stream_long
client.runs.get = AsyncMock(return_value={"status": "success"}) client.runs.get = AsyncMock(return_value={"status": "success"})
async def scenario(): # First spawn for thread X, run R1
# Clear all queues and the watcher registries t1 = async_notifier.spawn_watcher(client, "thr-X", "R1", "agent")
async_notifier._active_watchers.clear() assert t1 is not None
async_notifier._watcher_by_thread.clear() assert async_notifier._watcher_by_thread["thr-X"] is t1
_drain_all(async_notifier) await asyncio.sleep(0.02) # let it start streaming
# First spawn for thread X, run R1 # Second spawn for SAME thread X, NEW run R2
t1 = async_notifier.spawn_watcher(client, "thr-X", "R1", "agent") t2 = async_notifier.spawn_watcher(client, "thr-X", "R2", "agent")
assert t1 is not None assert t2 is not None
assert async_notifier._watcher_by_thread["thr-X"] is t1 assert t2 is not t1
await asyncio.sleep(0.02) # let it start streaming assert async_notifier._watcher_by_thread["thr-X"] is t2
# Second spawn for SAME thread X, NEW run R2 # Old watcher should be cancelled
t2 = async_notifier.spawn_watcher(client, "thr-X", "R2", "agent") await asyncio.sleep(0.02)
assert t2 is not None assert t1.cancelled() or t1.done()
assert t2 is not t1
assert async_notifier._watcher_by_thread["thr-X"] is t2
# Old watcher should be cancelled # Cleanup the new task too
await asyncio.sleep(0.02) t2.cancel()
assert t1.cancelled() or t1.done() try:
await t2
except asyncio.CancelledError:
pass
# Cleanup the new task too # Cancelled watchers don't push notifications
t2.cancel() assert _drain_one_queue_helper(async_notifier._notification_queue) == []
try: assert _drain_one_queue_helper(async_notifier._unrouted_queue) == []
await t2 for q in async_notifier._notifications_by_thread.values():
except asyncio.CancelledError: assert _drain_one_queue_helper(q) == []
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())
# ============================================================================ # ============================================================================
@@ -324,13 +305,6 @@ def test_format_notification_lines_timeout_uses_warning_icon():
def test_drain_returns_all_pending_and_empties_queue(): def test_drain_returns_all_pending_and_empties_queue():
"""drain_notifications pulls every pending notification 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 # Add three notifications
for tid in ("a", "b", "c"): for tid in ("a", "b", "c"):
async_notifier._notification_queue.put( 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 """When notifications arrive and agent is idle, consume_notifications fires
the supplied async runner once with the formatted batch message and notifs list.""" the supplied async runner once with the formatted batch message and notifs list."""
from EvoScientist.cli import async_notifier as an from EvoScientist.cli import async_notifier as an
# Set up two pending notifications, no dedup match # 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("t1", "wA", "success", "", ""))
an._notification_queue.put(an.AsyncTaskNotification("t2", "wB", "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: async def fake_state_reader() -> dict:
return {} # no dedup info 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 "wA" in captured["text"]
assert "wB" in captured["text"] assert "wB" in captured["text"]
assert len(captured["notifs"]) == 2 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 from EvoScientist.cli import async_notifier as an
while True:
try:
an._notification_queue.get_nowait()
except queue.Empty:
break
called = False called = False
async def fake_runner(text: str, notifs: list): 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(): async def fake_state_reader():
return {} return {}
run_async(an.consume_notifications(fake_runner, fake_state_reader)) await an.consume_notifications(fake_runner, fake_state_reader)
assert called is False 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. """The _notification_consuming guard prevents two overlapping consumers.
Verifies the flag contract used by _consume_notifications_tui: 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 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} state = {"inject_count": 0, "consuming": False}
async def counting_runner(text: str, notifs: list) -> None: 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", "", "") n1 = an.AsyncTaskNotification("g1", "writing-agent", "success", "", "")
n2 = an.AsyncTaskNotification("g2", "data-agent", "success", "", "") n2 = an.AsyncTaskNotification("g2", "data-agent", "success", "", "")
async def scenario(): # Scenario 1: normal flow — flag cleared, second consumer runs fine.
# Scenario 1: normal flow — flag cleared, second consumer runs fine. await guarded_consume(n1)
await guarded_consume(n1) assert state["inject_count"] == 1
assert state["inject_count"] == 1 assert state["consuming"] is False # finally ran
assert state["consuming"] is False # finally ran
state["inject_count"] = 0 state["inject_count"] = 0
await guarded_consume(n2) await guarded_consume(n2)
assert state["inject_count"] == 1 assert state["inject_count"] == 1
assert state["consuming"] is False assert state["consuming"] is False
# Scenario 2: flag pre-set (first consumer in-flight) → second bails. # Scenario 2: flag pre-set (first consumer in-flight) → second bails.
state["inject_count"] = 0 state["inject_count"] = 0
state["consuming"] = True # simulate first consumer running state["consuming"] = True # simulate first consumer running
an._notification_queue.put(n1) an._notification_queue.put(n1)
await guarded_consume(n1) # should be blocked immediately await guarded_consume(n1) # should be blocked immediately
assert state["inject_count"] == 0 # runner never called assert state["inject_count"] == 0 # runner never called
state["consuming"] = False # cleanup state["consuming"] = False # cleanup
# Scenario 3: exception in runner → flag still cleared by finally. # Scenario 3: exception in runner → flag still cleared by finally.
async def raising_runner(text: str, notifs: list) -> None: async def raising_runner(text: str, notifs: list) -> None:
raise RuntimeError("boom") raise RuntimeError("boom")
async def guarded_consume_raising(notif): async def guarded_consume_raising(notif):
if state["consuming"]: if state["consuming"]:
return return
state["consuming"] = True state["consuming"] = True
try: try:
an._notification_queue.put(notif) an._notification_queue.put(notif)
await an.consume_notifications(raising_runner, fake_state_reader) await an.consume_notifications(raising_runner, fake_state_reader)
except RuntimeError: except RuntimeError:
pass pass
finally: finally:
state["consuming"] = False state["consuming"] = False
await guarded_consume_raising(n2) await guarded_consume_raising(n2)
assert state["consuming"] is False # cleared despite exception assert state["consuming"] is False # cleared despite exception
run_async(scenario())
# ============================================================================ # ============================================================================
@@ -613,33 +566,42 @@ def test_notification_consuming_flag_prevents_reentry(run_async):
def _drain_all(an_mod): def _drain_all(an_mod):
"""Drain every queue (per-thread + unrouted) so tests start clean.""" """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: while True:
try: try:
an_mod._notification_queue.get_nowait() q.get_nowait()
except queue.Empty:
break
if hasattr(an_mod, "_notifications_by_thread"):
for q in list(an_mod._notifications_by_thread.values()):
while True:
try:
q.get_nowait()
except queue.Empty:
break
if hasattr(an_mod, "_unrouted_queue"):
while True:
try:
an_mod._unrouted_queue.get_nowait()
except queue.Empty: except queue.Empty:
break 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 """Notifications tagged with origin_cli_thread_id only drain when the
consumer is invoked with the matching current_thread_id.""" consumer is invoked with the matching current_thread_id."""
from EvoScientist.cli import async_notifier as an from EvoScientist.cli import async_notifier as an
_drain_all(an)
n_a = an.AsyncTaskNotification( n_a = an.AsyncTaskNotification(
"tA", "writing-agent", "success", "", "", origin_cli_thread_id="threadA" "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: async def state_reader() -> dict:
return {} return {}
run_async( await an.consume_notifications(runner, state_reader, current_thread_id="threadA")
an.consume_notifications(runner, state_reader, current_thread_id="threadA")
)
assert captured["runs"] == [["tA"]] assert captured["runs"] == [["tA"]]
# B's notification should still be queued # B's notification should still be queued
assert an.has_pending_notifications("threadB") 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 """Notifications without origin_cli_thread_id (legacy / direct put) drain
regardless of the current_thread_id arg.""" regardless of the current_thread_id arg."""
from EvoScientist.cli import async_notifier as an from EvoScientist.cli import async_notifier as an
_drain_all(an)
an._notification_queue.put( an._notification_queue.put(
an.AsyncTaskNotification("tU", "writing-agent", "success", "", "") 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: async def state_reader() -> dict:
return {} return {}
run_async( await an.consume_notifications(runner, state_reader, current_thread_id="anything")
an.consume_notifications(runner, state_reader, current_thread_id="anything")
)
assert [n.task_id for n in captured["notifs"]] == ["tU"] 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 """Pending notifications for thread B are not delivered while consumer
asks for thread A; once consumer runs with thread B they drain.""" asks for thread A; once consumer runs with thread B they drain."""
from EvoScientist.cli import async_notifier as an from EvoScientist.cli import async_notifier as an
_drain_all(an)
an._enqueue( an._enqueue(
an.AsyncTaskNotification( an.AsyncTaskNotification(
"tB", "writing-agent", "success", "", "", origin_cli_thread_id="threadB" "tB", "writing-agent", "success", "", "", origin_cli_thread_id="threadB"
@@ -712,25 +666,19 @@ def test_thread_switch_drains_pending(run_async):
return {} return {}
# First consume in thread A → no drain, B's notif still queued # First consume in thread A → no drain, B's notif still queued
run_async( await an.consume_notifications(runner, state_reader, current_thread_id="threadA")
an.consume_notifications(runner, state_reader, current_thread_id="threadA")
)
assert captured["runs"] == [] assert captured["runs"] == []
assert an.has_pending_notifications("threadB") assert an.has_pending_notifications("threadB")
# Now switch to thread B → drains # Now switch to thread B → drains
run_async( await an.consume_notifications(runner, state_reader, current_thread_id="threadB")
an.consume_notifications(runner, state_reader, current_thread_id="threadB")
)
assert captured["runs"] == [["tB"]] assert captured["runs"] == [["tB"]]
_drain_all(an)
def test_has_pending_notifications_respects_routing(): def test_has_pending_notifications_respects_routing():
"""has_pending_notifications returns true only for matching or unrouted.""" """has_pending_notifications returns true only for matching or unrouted."""
from EvoScientist.cli import async_notifier as an from EvoScientist.cli import async_notifier as an
_drain_all(an)
# Unrouted always counts # Unrouted always counts
an._notification_queue.put( an._notification_queue.put(
an.AsyncTaskNotification("tU", "writing-agent", "success", "", "") 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("threadA") is True
assert an.has_pending_notifications("threadB") is False assert an.has_pending_notifications("threadB") is False
assert an.has_pending_notifications() is False # no unrouted, no current_thread 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'.""" """SSE error event in the stream → notification.status == 'error'."""
async def fake_stream(*a, **kw): 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"} return_value={"status": "success"}
) # would mislead — should NOT be consulted ) # would mislead — should NOT be consulted
_drain_all(async_notifier) await async_notifier.watch_run_and_notify(client, "thrE", "rE", "agentE")
run_async(async_notifier.watch_run_and_notify(client, "thrE", "rE", "agentE"))
notif = async_notifier._notification_queue.get_nowait() notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "error" 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() 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.""" """Clean stream exit + runs.get reports success → status=success."""
async def fake_stream(*a, **kw): 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.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "success"}) client.runs.get = AsyncMock(return_value={"status": "success"})
_drain_all(async_notifier) await async_notifier.watch_run_and_notify(client, "thrS", "rS", "agentS")
run_async(async_notifier.watch_run_and_notify(client, "thrS", "rS", "agentS"))
notif = async_notifier._notification_queue.get_nowait() notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success" assert notif.status == "success"
client.runs.get.assert_awaited_once() 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' """Clean stream exit + no in-band error event + runs.get returns 'error'
→ status=success (race-safe). → 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.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "error"}) client.runs.get = AsyncMock(return_value={"status": "error"})
_drain_all(async_notifier) await async_notifier.watch_run_and_notify(client, "thrS", "rS", "agentS")
run_async(async_notifier.watch_run_and_notify(client, "thrS", "rS", "agentS"))
notif = async_notifier._notification_queue.get_nowait() notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success" 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 """Reproduces the production bug: clean SSE close while run is still
actually running (HTTP keep-alive timeout under concurrency). 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.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "running"}) client.runs.get = AsyncMock(return_value={"status": "running"})
_drain_all(async_notifier) await async_notifier.watch_run_and_notify(
run_async( client, "thr-bug", "rB", "data-analysis-agent"
async_notifier.watch_run_and_notify(
client, "thr-bug", "rB", "data-analysis-agent"
)
) )
# No notification should have been enqueued anywhere. # 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 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 """Future / unrecognized status values should trigger a re-join, not a
false-positive notification. false-positive notification.
@@ -893,8 +834,7 @@ def test_watcher_unknown_status_treated_as_non_terminal(run_async):
side_effect=[{"status": "queued"}, {"status": "success"}] side_effect=[{"status": "queued"}, {"status": "success"}]
) )
_drain_all(async_notifier) await async_notifier.watch_run_and_notify(client, "thrU", "rU", "agentU")
run_async(async_notifier.watch_run_and_notify(client, "thrU", "rU", "agentU"))
notif = async_notifier._notification_queue.get_nowait() notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success" 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 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 """If ``runs.get`` keeps raising, the watcher cannot verify terminal
state and MUST drop the notification rather than default to state and MUST drop the notification rather than default to
``"success"`` — otherwise a transient server outage reintroduces the ``"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) monkeypatch.setattr(async_notifier.asyncio, "sleep", _no_sleep)
_drain_all(async_notifier) await async_notifier.watch_run_and_notify(client, "thrG", "rG", "agentG")
run_async(async_notifier.watch_run_and_notify(client, "thrG", "rG", "agentG"))
# No notification — watcher exhausted the reconnect budget. Check every # No notification — watcher exhausted the reconnect budget. Check every
# queue routing could send to so a future routing change can't make this # queue routing could send to so a future routing change can't make this
# test silently false-pass. # test silently false-pass.
assert _drain_one_queue_helper(async_notifier._unrouted_queue) == [] assert _drain_one_queue_helper(async_notifier._unrouted_queue) == []
assert _drain_one_queue_helper(async_notifier._notification_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():
for q in async_notifier._notifications_by_thread.values(): assert _drain_one_queue_helper(q) == []
assert _drain_one_queue_helper(q) == []
# 1 initial + _MAX_RECONNECT_ATTEMPTS retries = 11 calls total. # 1 initial + _MAX_RECONNECT_ATTEMPTS retries = 11 calls total.
assert client.runs.get.await_count == async_notifier._MAX_RECONNECT_ATTEMPTS + 1 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 """A single ``runs.get`` failure followed by a successful response on
retry must produce a correct notification — verifies the bounded retry must produce a correct notification — verifies the bounded
retry path actually recovers from transient outages instead of just 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) monkeypatch.setattr(async_notifier.asyncio, "sleep", _no_sleep)
_drain_all(async_notifier) await async_notifier.watch_run_and_notify(client, "thrT", "rT", "agentT")
run_async(async_notifier.watch_run_and_notify(client, "thrT", "rT", "agentT"))
notif = async_notifier._notification_queue.get_nowait() notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success" assert notif.status == "success"
assert client.runs.get.await_count == 2 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 """When runs.get returns 'running' on attempt N but a terminal status
on attempt N+1, the watcher re-joins, observes the terminal status, on attempt N+1, the watcher re-joins, observes the terminal status,
and enqueues the notification correctly.""" 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"}] side_effect=[{"status": "running"}, {"status": "success"}]
) )
_drain_all(async_notifier) await async_notifier.watch_run_and_notify(client, "thrR", "rR", "agentR")
run_async(async_notifier.watch_run_and_notify(client, "thrR", "rR", "agentR"))
notif = async_notifier._notification_queue.get_nowait() notif = async_notifier._notification_queue.get_nowait()
assert notif.status == "success" 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 """If the run_message callback raises, consume_notifications propagates
the exception to the caller — pollers wrap it in try/except so the the exception to the caller — pollers wrap it in try/except so the
poller task does not die.""" poller task does not die."""
import pytest
from EvoScientist.cli import async_notifier as an from EvoScientist.cli import async_notifier as an
_drain_all(an)
an._notification_queue.put( an._notification_queue.put(
an.AsyncTaskNotification("tX", "writing-agent", "success", "", "") an.AsyncTaskNotification("tX", "writing-agent", "success", "", "")
) )
@@ -1015,11 +948,10 @@ def test_consume_notifications_propagates_inject_exception(run_async):
return {} return {}
with pytest.raises(RuntimeError, match="kaboom"): with pytest.raises(RuntimeError, match="kaboom"):
run_async(an.consume_notifications(boom_runner, state_reader)) await an.consume_notifications(boom_runner, state_reader)
_drain_all(an)
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 """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 (e.g. ``pending`` because the run is still alive), the watcher must
NOT enqueue a notification — otherwise the user sees a confusing 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.join_stream = fake_stream
client.runs.get = AsyncMock(return_value={"status": "pending"}) client.runs.get = AsyncMock(return_value={"status": "pending"})
_drain_all(async_notifier) await async_notifier.watch_run_and_notify(client, "thrP", "rP", "agentP")
run_async(async_notifier.watch_run_and_notify(client, "thrP", "rP", "agentP"))
# No notification should have been enqueued in any queue. # 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._unrouted_queue) == []
assert _drain_one_queue_helper(async_notifier._notification_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():
for q in async_notifier._notifications_by_thread.values(): assert _drain_one_queue_helper(q) == []
assert _drain_one_queue_helper(q) == []
def _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 (otherwise consume_notifications grace period would block thread A by up
to 3s waiting for thread B's unrelated watchers to finish).""" 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 # Sentinel handles — only their identity matters here, not their type
handle_a = object() handle_a = object()
handle_b = 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 from __future__ import annotations
import asyncio
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -105,7 +104,7 @@ def _make_middleware():
return mw, fake_client 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.""" """A successful start_async_task tool call must spawn one watcher per task."""
from langgraph.types import Command 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): 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 isinstance(result, Command)
assert spawn_calls == [ 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.""" """A successful update_async_task call must also spawn a (replacement) watcher."""
from langgraph.types import Command 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): 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 assert len(spawn_calls) == 1
args, kwargs = spawn_calls[0] 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" 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. """update_async_task must cancel the existing watcher BEFORE invoking the handler.
Otherwise the new run interrupts the old run's stream, which closes 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: try:
with patch.object(async_notifier, "spawn_watcher"): 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: finally:
async_notifier._watcher_by_thread.pop("task-1", None) async_notifier._watcher_by_thread.pop("task-1", None)
assert cancel_observed_before_handler["value"] is True 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.""" """A non-launch tool call must not spawn any watcher and must return result unchanged."""
mw, _ = _make_middleware() mw, _ = _make_middleware()
@@ -240,13 +239,13 @@ def test_middleware_passes_through_unrelated_tools():
request = _build_request("ls", {"path": "/"}, thread_id="t") request = _build_request("ls", {"path": "/"}, thread_id="t")
with patch.object(async_notifier, "spawn_watcher") as mock_spawn: 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 result is sentinel
assert mock_spawn.call_count == 0 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.""" """If the launch tool returns a string (validation error), no watcher is spawned."""
mw, _ = _make_middleware() 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: 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 result == "Unknown async subagent type `bogus`"
assert mock_spawn.call_count == 0 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).""" """When runtime.config is empty, origin_cli_thread_id must be None (not crash)."""
from langgraph.types import Command 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): 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 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.""" """spawn_watcher errors must not propagate up — middleware logs and continues."""
from langgraph.types import Command 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): with patch.object(async_notifier, "spawn_watcher", side_effect=boom):
# Should not raise. # 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) 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'.""" """start_async_task uses 'description'; update_async_task uses 'message'."""
from langgraph.types import Command 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") request = _build_request(tool_name, args, thread_id="t")
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn): 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 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`. """update_async_task with extra `description` arg must still use `message`.
Guards against the previous `args.get('description') or args.get('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): 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" 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.""" """A faulty old-watcher handle must not block the handler from running."""
from langgraph.types import Command from langgraph.types import Command
@@ -460,7 +461,7 @@ def test_middleware_pre_cancel_swallows_unexpected_errors():
try: try:
with patch.object(async_notifier, "spawn_watcher"): with patch.object(async_notifier, "spawn_watcher"):
# Should not raise. # Should not raise.
asyncio.run(mw.awrap_tool_call(request, fake_handler)) await mw.awrap_tool_call(request, fake_handler)
finally: finally:
async_notifier._watcher_by_thread.pop("t1", None) async_notifier._watcher_by_thread.pop("t1", None)
+6 -7
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
from types import SimpleNamespace from types import SimpleNamespace
@@ -917,16 +916,16 @@ class _AsyncFakeCrons:
return [{"cron_id": "cron-async"}] 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() crons = _AsyncFakeCrons()
client = SimpleNamespace(crons=crons) client = SimpleNamespace(crons=crons)
monkeypatch.setattr("langgraph_sdk.get_client", lambda **_kwargs: client) monkeypatch.setattr("langgraph_sdk.get_client", lambda **_kwargs: client)
rows = asyncio.run( rows = await alist_autoskill_schedules(
alist_autoskill_schedules( EvoScientistConfig(),
EvoScientistConfig(), limit=3,
limit=3,
)
) )
assert rows == [{"cron_id": "cron-async"}] 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) 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.""" """Elapsed for an exited process freezes at its runtime, it must not keep growing."""
pid = bg.launch(_true_cmd(), str(tmp_path)) pid = bg.launch(_true_cmd(), str(tmp_path))
assert _wait_until(lambda: bg._PROCESSES[pid].finished_ts is not None) 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] proc = bg._PROCESSES[pid]
assert proc.finished_ts is not None assert proc.finished_ts is not None
first = bg._elapsed(proc) 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 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.events import InboundMessage
from EvoScientist.channels.bus.message_bus import MessageBus from EvoScientist.channels.bus.message_bus import MessageBus
from EvoScientist.channels.channel_manager import ChannelManager from EvoScientist.channels.channel_manager import ChannelManager
from tests.conftest import run_async as _run
from tests.fakes import QueueFakeChannel as FakeChannel from tests.fakes import QueueFakeChannel as FakeChannel
@@ -58,7 +57,7 @@ def clean_channel_state():
class TestBusInboundConsumer: class TestBusInboundConsumer:
"""Test the _bus_inbound_consumer queue bridge.""" """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.""" """InboundMessage -> queue -> response -> OutboundMessage flow."""
from EvoScientist.cli.channel import ( from EvoScientist.cli.channel import (
_bus_inbound_consumer, _bus_inbound_consumer,
@@ -68,54 +67,51 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue) _drain_queue(_message_queue)
async def _test(): bus = MessageBus()
bus = MessageBus() manager = ChannelManager(bus)
manager = ChannelManager(bus) ch = FakeChannel()
ch = FakeChannel() manager.register(ch)
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( await bus.publish_inbound(
InboundMessage( InboundMessage(
channel="fake", channel="fake",
sender_id="user1", sender_id="user1",
chat_id="chat1", chat_id="chat1",
content="hello agent", content="hello agent",
)
) )
)
# Wait for consumer to enqueue the message # Wait for consumer to enqueue the message
for _ in range(20): for _ in range(20):
if not _message_queue.empty(): if not _message_queue.empty():
break break
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
msg = _message_queue.get_nowait() msg = _message_queue.get_nowait()
assert msg.content == "hello agent" assert msg.content == "hello agent"
assert msg.sender == "user1" assert msg.sender == "user1"
assert msg.channel_type == "fake" assert msg.channel_type == "fake"
# Simulate main-thread response # Simulate main-thread response
_set_channel_response(msg.msg_id, "Reply to: hello agent") _set_channel_response(msg.msg_id, "Reply to: hello agent")
outbound = await asyncio.wait_for( outbound = await asyncio.wait_for(
bus.consume_outbound(), bus.consume_outbound(),
timeout=2.0, timeout=2.0,
) )
assert outbound.channel == "fake" assert outbound.channel == "fake"
assert outbound.chat_id == "chat1" assert outbound.chat_id == "chat1"
assert "Reply to: hello agent" in outbound.content assert "Reply to: hello agent" in outbound.content
consumer.cancel() consumer.cancel()
try: try:
await consumer await consumer
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
_run(_test()) async def test_no_response_fallback(self):
def test_no_response_fallback(self):
"""Empty response is replaced with 'No response' fallback.""" """Empty response is replaced with 'No response' fallback."""
from EvoScientist.cli.channel import ( from EvoScientist.cli.channel import (
_bus_inbound_consumer, _bus_inbound_consumer,
@@ -125,47 +121,44 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue) _drain_queue(_message_queue)
async def _test(): bus = MessageBus()
bus = MessageBus() manager = ChannelManager(bus)
manager = ChannelManager(bus) ch = FakeChannel()
ch = FakeChannel() manager.register(ch)
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( await bus.publish_inbound(
InboundMessage( InboundMessage(
channel="fake", channel="fake",
sender_id="user1", sender_id="user1",
chat_id="chat1", chat_id="chat1",
content="test", content="test",
)
) )
)
for _ in range(20): for _ in range(20):
if not _message_queue.empty(): if not _message_queue.empty():
break break
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
msg = _message_queue.get_nowait() msg = _message_queue.get_nowait()
# Set empty response — falsy, so consumer falls back to "No response" # Set empty response — falsy, so consumer falls back to "No response"
_set_channel_response(msg.msg_id, "") _set_channel_response(msg.msg_id, "")
outbound = await asyncio.wait_for( outbound = await asyncio.wait_for(
bus.consume_outbound(), bus.consume_outbound(),
timeout=2.0, timeout=2.0,
) )
assert outbound.content == "No response" assert outbound.content == "No response"
consumer.cancel() consumer.cancel()
try: try:
await consumer await consumer
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
_run(_test()) async def test_late_response_after_timeout_still_publishes(self, monkeypatch):
def test_late_response_after_timeout_still_publishes(self, monkeypatch):
"""A response that arrives after the bridge timeout is still forwarded.""" """A response that arrives after the bridge timeout is still forwarded."""
from EvoScientist.cli import channel as channel_mod from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import ( from EvoScientist.cli.channel import (
@@ -179,56 +172,53 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue) _drain_queue(_message_queue)
async def _test(): bus = MessageBus()
bus = MessageBus() manager = ChannelManager(bus)
manager = ChannelManager(bus) ch = FakeChannel()
ch = FakeChannel() manager.register(ch)
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( await bus.publish_inbound(
InboundMessage( InboundMessage(
channel="fake", channel="fake",
sender_id="user1", sender_id="user1",
chat_id="chat1", chat_id="chat1",
content="slow request", content="slow request",
message_id="msg-123", message_id="msg-123",
)
) )
)
for _ in range(20): for _ in range(20):
if not _message_queue.empty(): if not _message_queue.empty():
break break
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
msg = _message_queue.get_nowait() msg = _message_queue.get_nowait()
notice = await asyncio.wait_for( notice = await asyncio.wait_for(
bus.consume_outbound(), bus.consume_outbound(),
timeout=1.0, timeout=1.0,
) )
assert "Still working on it" in notice.content assert "Still working on it" in notice.content
assert notice.reply_to == "msg-123" 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( outbound = await asyncio.wait_for(
bus.consume_outbound(), bus.consume_outbound(),
timeout=1.0, timeout=1.0,
) )
assert outbound.content == "final answer" assert outbound.content == "final answer"
assert outbound.reply_to == "msg-123" assert outbound.reply_to == "msg-123"
consumer.cancel() consumer.cancel()
try: try:
await consumer await consumer
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
_run(_test()) async def test_late_timeout_keeps_active_request_cancellable(self, monkeypatch):
def test_late_timeout_keeps_active_request_cancellable(self, monkeypatch):
"""Late timeout must not discard an active request's cancel scope.""" """Late timeout must not discard an active request's cancel scope."""
from EvoScientist.cli import channel as channel_mod from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import ( from EvoScientist.cli.channel import (
@@ -243,191 +233,179 @@ class TestBusInboundConsumer:
monkeypatch.setattr(channel_mod, "_RESPONSE_TIMEOUT", 0.05) monkeypatch.setattr(channel_mod, "_RESPONSE_TIMEOUT", 0.05)
monkeypatch.setattr(channel_mod, "_LATE_RESPONSE_TIMEOUT", 0.05) monkeypatch.setattr(channel_mod, "_LATE_RESPONSE_TIMEOUT", 0.05)
async def _test(): bus = MessageBus()
bus = MessageBus() manager = ChannelManager(bus)
manager = ChannelManager(bus) ch = FakeChannel()
ch = FakeChannel() manager.register(ch)
manager.register(ch)
task = asyncio.create_task( task = asyncio.create_task(
_handle_bus_message( _handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="still running",
message_id="msg-active",
),
)
)
queued = None
for _ in range(20):
with _message_queue.mutex:
queued = _message_queue.queue[0] if _message_queue.queue else None
if queued is not None:
break
await asyncio.sleep(0.05)
assert queued is not None
assert _claim_channel_request(queued) is True
notice = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
assert "Still working on it" in notice.content
await task
assert _channel_request_state(queued.msg_id) == "active"
cancel_scope = _channel_message_cancel_scope(queued)
assert not display_mod.is_stream_cancel_requested(cancel_scope)
await _handle_bus_message(
bus, bus,
manager, manager,
InboundMessage( InboundMessage(
channel="fake", channel="fake",
sender_id="user1", sender_id="user1",
chat_id="chat1", chat_id="chat1",
content="/stop", content="still running",
message_id="msg-stop-active", message_id="msg-active",
), ),
) )
)
ack = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) queued = None
assert ack.content == "Stopped." for _ in range(20):
assert ack.reply_to == "msg-stop-active" with _message_queue.mutex:
assert display_mod.is_stream_cancel_requested(cancel_scope) 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.""" """Cancelling a pending bus message should not leak its response slot."""
from EvoScientist.cli import channel as channel_mod from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import _handle_bus_message, _message_queue from EvoScientist.cli.channel import _handle_bus_message, _message_queue
async def _test(): bus = MessageBus()
bus = MessageBus() manager = ChannelManager(bus)
manager = ChannelManager(bus) ch = FakeChannel()
ch = FakeChannel() manager.register(ch)
manager.register(ch)
task = asyncio.create_task( task = asyncio.create_task(
_handle_bus_message( _handle_bus_message(
bus, bus,
manager, manager,
InboundMessage( InboundMessage(
channel="fake", channel="fake",
sender_id="user1", sender_id="user1",
chat_id="chat1", chat_id="chat1",
content="cancel me", content="cancel me",
), ),
)
) )
)
for _ in range(20): for _ in range(20):
if not _message_queue.empty(): if not _message_queue.empty():
break break
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
queued = _message_queue.get_nowait() queued = _message_queue.get_nowait()
with channel_mod._response_lock: with channel_mod._response_lock:
assert queued.msg_id in channel_mod._pending_responses assert queued.msg_id in channel_mod._pending_responses
task.cancel() task.cancel()
with pytest.raises(asyncio.CancelledError): with pytest.raises(asyncio.CancelledError):
await task await task
with channel_mod._response_lock: with channel_mod._response_lock:
assert queued.msg_id not in channel_mod._pending_responses assert queued.msg_id not in channel_mod._pending_responses
_run(_test()) async def test_consumer_shutdown_cleans_pending_response(self):
def test_consumer_shutdown_cleans_pending_response(self):
"""Stopping the consumer should cancel late waits and clear state.""" """Stopping the consumer should cancel late waits and clear state."""
from EvoScientist.cli import channel as channel_mod from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import _bus_inbound_consumer, _message_queue from EvoScientist.cli.channel import _bus_inbound_consumer, _message_queue
async def _test(): bus = MessageBus()
bus = MessageBus() manager = ChannelManager(bus)
manager = ChannelManager(bus) ch = FakeChannel()
ch = FakeChannel() manager.register(ch)
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( await bus.publish_inbound(
InboundMessage( InboundMessage(
channel="fake", channel="fake",
sender_id="user1", sender_id="user1",
chat_id="chat1", chat_id="chat1",
content="slow shutdown", content="slow shutdown",
)
) )
)
for _ in range(20): for _ in range(20):
if not _message_queue.empty(): if not _message_queue.empty():
break break
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
queued = _message_queue.get_nowait() queued = _message_queue.get_nowait()
with channel_mod._response_lock: with channel_mod._response_lock:
assert queued.msg_id in channel_mod._pending_responses assert queued.msg_id in channel_mod._pending_responses
consumer.cancel() consumer.cancel()
await consumer await consumer
with channel_mod._response_lock: with channel_mod._response_lock:
assert queued.msg_id not in channel_mod._pending_responses assert queued.msg_id not in channel_mod._pending_responses
_run(_test()) async def test_stop_during_hitl_wait_releases_wait_and_acks(self):
def test_stop_during_hitl_wait_releases_wait_and_acks(self):
"""`/stop` should wake pending HITL wait and publish immediate ack.""" """`/stop` should wake pending HITL wait and publish immediate ack."""
from EvoScientist.cli import channel as channel_mod from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import _bus_inbound_consumer, _message_queue from EvoScientist.cli.channel import _bus_inbound_consumer, _message_queue
async def _test(): bus = MessageBus()
bus = MessageBus() manager = ChannelManager(bus)
manager = ChannelManager(bus) ch = FakeChannel()
ch = FakeChannel() manager.register(ch)
manager.register(ch)
hitl_event = channel_mod._register_hitl_wait("fake", "chat1") hitl_event = channel_mod._register_hitl_wait("fake", "chat1")
consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager)) consumer = asyncio.create_task(_bus_inbound_consumer(bus, manager))
await bus.publish_inbound( await bus.publish_inbound(
InboundMessage( InboundMessage(
channel="fake", channel="fake",
sender_id="user1", sender_id="user1",
chat_id="chat1", chat_id="chat1",
content="/stop", content="/stop",
message_id="m-stop-1", message_id="m-stop-1",
)
) )
)
for _ in range(20): for _ in range(20):
if hitl_event.is_set(): if hitl_event.is_set():
break break
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
assert hitl_event.is_set() assert hitl_event.is_set()
assert channel_mod._pop_hitl_reply("fake", "chat1") == "/stop" assert channel_mod._pop_hitl_reply("fake", "chat1") == "/stop"
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=2.0) outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=2.0)
assert outbound.content == "Stopped." assert outbound.content == "Stopped."
assert outbound.reply_to == "m-stop-1" assert outbound.reply_to == "m-stop-1"
assert _message_queue.empty() assert _message_queue.empty()
consumer.cancel() consumer.cancel()
try: try:
await consumer await consumer
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
_run(_test()) async def test_stop_cancels_queued_request_before_main_thread_processes_it(self):
def test_stop_cancels_queued_request_before_main_thread_processes_it(self):
"""`/stop` should cancel a queued request instead of only acking.""" """`/stop` should cancel a queued request instead of only acking."""
from EvoScientist.cli import channel as channel_mod from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import ( from EvoScientist.cli.channel import (
@@ -436,73 +414,70 @@ class TestBusInboundConsumer:
_message_queue, _message_queue,
) )
async def _test(): bus = MessageBus()
bus = MessageBus() manager = ChannelManager(bus)
manager = ChannelManager(bus) ch = FakeChannel()
ch = FakeChannel() manager.register(ch)
manager.register(ch)
task = asyncio.create_task( task = asyncio.create_task(
_handle_bus_message( _handle_bus_message(
bus,
manager,
InboundMessage(
channel="fake",
sender_id="user1",
chat_id="chat1",
content="please work",
message_id="m-work-1",
),
)
)
queued = None
for _ in range(20):
with _message_queue.mutex:
queued = _message_queue.queue[0] if _message_queue.queue else None
if queued is not None:
break
await asyncio.sleep(0.05)
assert queued is not None
with channel_mod._response_lock:
assert queued.msg_id in channel_mod._pending_responses
await _handle_bus_message(
bus, bus,
manager, manager,
InboundMessage( InboundMessage(
channel="fake", channel="fake",
sender_id="user1", sender_id="user1",
chat_id="chat1", chat_id="chat1",
content="/stop", content="please work",
message_id="m-stop-2", message_id="m-work-1",
), ),
) )
)
with pytest.raises(asyncio.CancelledError): queued = None
await task 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 queued is not None
assert skipped.msg_id == queued.msg_id with channel_mod._response_lock:
assert _claim_or_complete_channel_request(skipped) is False assert queued.msg_id in channel_mod._pending_responses
with channel_mod._response_lock: await _handle_bus_message(
assert queued.msg_id not in channel_mod._pending_responses bus,
with channel_mod._channel_request_lock: manager,
assert queued.msg_id not in channel_mod._channel_requests InboundMessage(
assert queued.msg_id not in channel_mod._cancelled_channel_messages channel="fake",
assert "fake:chat1" not in channel_mod._session_requests sender_id="user1",
chat_id="chat1",
content="/stop",
message_id="m-stop-2",
),
)
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=2.0) with pytest.raises(asyncio.CancelledError):
assert outbound.content == "Stopped." await task
assert outbound.reply_to == "m-stop-2"
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(bus.consume_outbound(), timeout=0.2)
_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.""" """`/stop` must not steal a response whose waiter already resolved."""
from EvoScientist.cli import channel as channel_mod from EvoScientist.cli import channel as channel_mod
from EvoScientist.cli.channel import ( from EvoScientist.cli.channel import (
@@ -515,42 +490,39 @@ class TestBusInboundConsumer:
_set_channel_response, _set_channel_response,
) )
async def _test(): msg = ChannelMessage(
msg = ChannelMessage( msg_id="msg-resolved",
msg_id="msg-resolved", content="already answered",
content="already answered", sender="user1",
sender="user1", channel_type="fake",
channel_type="fake", metadata={},
metadata={}, channel_ref=None,
channel_ref=None, bus_ref=None,
bus_ref=None, chat_id="chat1",
chat_id="chat1", message_id="m-resolved",
message_id="m-resolved", )
)
waiter = _enqueue_channel_message(msg) waiter = _enqueue_channel_message(msg)
assert _claim_channel_request(msg) is True assert _claim_channel_request(msg) is True
_set_channel_response(msg.msg_id, "final answer") _set_channel_response(msg.msg_id, "final answer")
assert await asyncio.wait_for(asyncio.shield(waiter), timeout=1.0) == ( assert await asyncio.wait_for(asyncio.shield(waiter), timeout=1.0) == (
"final answer" "final answer"
) )
cancelled_count, active_count = _cancel_channel_session("fake", "chat1") cancelled_count, active_count = _cancel_channel_session("fake", "chat1")
assert cancelled_count == 0 assert cancelled_count == 0
assert active_count == 0 assert active_count == 0
with channel_mod._response_lock: with channel_mod._response_lock:
assert msg.msg_id in channel_mod._pending_responses assert msg.msg_id in channel_mod._pending_responses
with channel_mod._channel_request_lock: with channel_mod._channel_request_lock:
assert msg.msg_id not in channel_mod._cancelled_channel_messages assert msg.msg_id not in channel_mod._cancelled_channel_messages
assert _pop_channel_response(msg.msg_id) == "final answer" assert _pop_channel_response(msg.msg_id) == "final answer"
_complete_channel_request(msg.msg_id) _complete_channel_request(msg.msg_id)
_run(_test()) async def test_message_counting(self):
def test_message_counting(self):
"""Messages are counted via record_message.""" """Messages are counted via record_message."""
from EvoScientist.cli.channel import ( from EvoScientist.cli.channel import (
_bus_inbound_consumer, _bus_inbound_consumer,
@@ -560,45 +532,42 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue) _drain_queue(_message_queue)
async def _test(): bus = MessageBus()
bus = MessageBus() manager = ChannelManager(bus)
manager = ChannelManager(bus) ch = FakeChannel()
ch = FakeChannel() manager.register(ch)
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( await bus.publish_inbound(
InboundMessage( InboundMessage(
channel="fake", channel="fake",
sender_id="u1", sender_id="u1",
chat_id="c1", chat_id="c1",
content="test", content="test",
)
) )
)
for _ in range(20): for _ in range(20):
if not _message_queue.empty(): if not _message_queue.empty():
break break
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
msg = _message_queue.get_nowait() msg = _message_queue.get_nowait()
_set_channel_response(msg.msg_id, "ok") _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"]["received"] == 1
assert manager._message_counts["fake"]["sent"] == 1 assert manager._message_counts["fake"]["sent"] == 1
consumer.cancel() consumer.cancel()
try: try:
await consumer await consumer
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
_run(_test()) async def test_channel_message_carries_metadata(self):
def test_channel_message_carries_metadata(self):
"""ChannelMessage carries metadata, chat_id, and message_id.""" """ChannelMessage carries metadata, chat_id, and message_id."""
from EvoScientist.cli.channel import ( from EvoScientist.cli.channel import (
_bus_inbound_consumer, _bus_inbound_consumer,
@@ -608,49 +577,46 @@ class TestBusInboundConsumer:
_drain_queue(_message_queue) _drain_queue(_message_queue)
async def _test(): bus = MessageBus()
bus = MessageBus() manager = ChannelManager(bus)
manager = ChannelManager(bus) ch = FakeChannel()
ch = FakeChannel() manager.register(ch)
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( await bus.publish_inbound(
InboundMessage( InboundMessage(
channel="fake", channel="fake",
sender_id="user1", sender_id="user1",
chat_id="chat1", chat_id="chat1",
content="with metadata", content="with metadata",
metadata={"key": "value"}, metadata={"key": "value"},
message_id="msg-123", message_id="msg-123",
)
) )
)
for _ in range(20): for _ in range(20):
if not _message_queue.empty(): if not _message_queue.empty():
break break
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
msg = _message_queue.get_nowait() msg = _message_queue.get_nowait()
assert msg.content == "with metadata" assert msg.content == "with metadata"
assert msg.metadata == {"key": "value"} assert msg.metadata == {"key": "value"}
assert msg.chat_id == "chat1" assert msg.chat_id == "chat1"
assert msg.message_id == "msg-123" assert msg.message_id == "msg-123"
assert msg.channel_ref is ch 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( outbound = await asyncio.wait_for(
bus.consume_outbound(), bus.consume_outbound(),
timeout=2.0, timeout=2.0,
) )
assert outbound.reply_to == "msg-123" assert outbound.reply_to == "msg-123"
consumer.cancel() consumer.cancel()
try: try:
await consumer await consumer
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
_run(_test())
+10 -12
View File
@@ -3,8 +3,6 @@
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx(): def _ctx():
from EvoScientist.commands.base import ChannelRuntime, CommandContext from EvoScientist.commands.base import ChannelRuntime, CommandContext
@@ -55,7 +53,7 @@ class TestNeedsAgent:
class TestStartPath: class TestStartPath:
"""Start flow must propagate agent/thread_id globals.""" """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 from EvoScientist.commands.implementation.channel import ChannelCommand
ctx, _ui = _ctx() ctx, _ui = _ctx()
@@ -77,11 +75,11 @@ class TestStartPath:
return_value=config, 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.agent is ctx.agent
assert ctx.channel_runtime.thread_id == "tid-42" 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.""" """send_thinking flag must reach _start_channels_bus_mode."""
from EvoScientist.commands.implementation.channel import ChannelCommand from EvoScientist.commands.implementation.channel import ChannelCommand
@@ -111,14 +109,14 @@ class TestStartPath:
return_value=config, return_value=config,
), ),
): ):
_run(ChannelCommand().execute(ctx, ["telegram"])) await ChannelCommand().execute(ctx, ["telegram"])
assert captured["agent"] is ctx.agent assert captured["agent"] is ctx.agent
assert captured["thread_id"] == "tid-42" assert captured["thread_id"] == "tid-42"
assert captured["send_thinking"] is False assert captured["send_thinking"] is False
class TestAddToRunningPath: 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 from EvoScientist.commands.implementation.channel import ChannelCommand
ctx, _ui = _ctx() ctx, _ui = _ctx()
@@ -140,11 +138,11 @@ class TestAddToRunningPath:
return_value=config, 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.agent is ctx.agent
assert ctx.channel_runtime.thread_id == "tid-42" 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.""" """Adding to a running bus must honor config.channel_send_thinking."""
from EvoScientist.commands.implementation.channel import ChannelCommand from EvoScientist.commands.implementation.channel import ChannelCommand
@@ -173,13 +171,13 @@ class TestAddToRunningPath:
return_value=config, return_value=config,
), ),
): ):
_run(ChannelCommand().execute(ctx, ["discord"])) await ChannelCommand().execute(ctx, ["discord"])
assert captured["channel_type"] == "discord" assert captured["channel_type"] == "discord"
assert captured["send_thinking"] is True assert captured["send_thinking"] is True
class TestStatusPath: class TestStatusPath:
def test_status_without_running_channels(self): async def test_status_without_running_channels(self):
from EvoScientist.commands.implementation.channel import ChannelCommand from EvoScientist.commands.implementation.channel import ChannelCommand
ctx, ui = _ctx() ctx, ui = _ctx()
@@ -198,6 +196,6 @@ class TestStatusPath:
return_value=config, 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("No messaging channels" in m for m in msgs) 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.commands.channel_ui import ChannelCommandUI
from EvoScientist.gateway import ThreadStore from EvoScientist.gateway import ThreadStore
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore 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() callback = AsyncMock()
bus_ref = SimpleNamespace(publish_outbound=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, 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") callback.assert_awaited_once_with("thread-42", "/workspace")
assert thread_store.calls == [("get_thread_messages", "thread-42")] 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 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")) callback = AsyncMock(side_effect=RuntimeError("workspace conflict"))
bus_ref = SimpleNamespace(publish_outbound=AsyncMock()) bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
thread_store = FakeThreadStore() thread_store = FakeThreadStore()
@@ -95,7 +94,7 @@ def test_handle_session_resume_propagates_callback_abort_without_history():
) )
with pytest.raises(RuntimeError, match="workspace conflict"): 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") callback.assert_awaited_once_with("thread-42", "/workspace")
assert thread_store.calls == [] assert thread_store.calls == []
@@ -103,7 +102,7 @@ def test_handle_session_resume_propagates_callback_abort_without_history():
assert captured == [] assert captured == []
def test_handle_session_resume_reports_history_load_error(): async def test_handle_session_resume_reports_history_load_error():
callback = AsyncMock() callback = AsyncMock()
bus_ref = SimpleNamespace(publish_outbound=AsyncMock()) bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
ui, captured = _make_ui( 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") callback.assert_awaited_once_with("thread-42", "/workspace")
assert captured == [] assert captured == []
@@ -123,7 +122,7 @@ def test_handle_session_resume_reports_history_load_error():
assert "history unavailable: db locked" in text 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()) bus_ref = SimpleNamespace(publish_outbound=AsyncMock())
ui, captured = _make_ui( ui, captured = _make_ui(
bus_ref=bus_ref, 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 == [ assert captured == [
"Resumed session: thread-42\nNo displayable messages in this session." "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, emit_debug_event_if,
) )
from .conftest import run_async
def test_debug_trace_enabled_from_bool(): def test_debug_trace_enabled_from_bool():
assert debug_trace_enabled(True) is True assert debug_trace_enabled(True) is True
@@ -75,10 +73,10 @@ def _make_channel_context(*, debug_trace=True, name="test_channel"):
return {"channel": 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 from EvoScientist.channels.middleware import DedupMiddleware
async def _run(): with caplog.at_level(logging.DEBUG):
mw = DedupMiddleware() mw = DedupMiddleware()
ctx = _make_channel_context() ctx = _make_channel_context()
raw = _make_raw(message_id="dup1") raw = _make_raw(message_id="dup1")
@@ -91,49 +89,40 @@ def test_middleware_dedup_emits_structured_event(caplog):
caplog.clear() caplog.clear()
result = await mw.process_inbound(raw, ctx) result = await mw.process_inbound(raw, ctx)
assert result is None assert result is None
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "middleware_dedup_drop" in caplog.text assert "middleware_dedup_drop" in caplog.text
assert "message_id=dup1" 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 from EvoScientist.channels.middleware import AllowListMiddleware
async def _run(): with caplog.at_level(logging.DEBUG):
mw = AllowListMiddleware(allowed_senders={"allowed_user"}) mw = AllowListMiddleware(allowed_senders={"allowed_user"})
ctx = _make_channel_context() ctx = _make_channel_context()
raw = _make_raw(sender_id="blocked_user") raw = _make_raw(sender_id="blocked_user")
result = await mw.process_inbound(raw, ctx) result = await mw.process_inbound(raw, ctx)
assert result is None assert result is None
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "middleware_allowlist_drop" in caplog.text assert "middleware_allowlist_drop" in caplog.text
assert "reason=sender_not_allowed" 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 from EvoScientist.channels.middleware import MentionGatingMiddleware
async def _run(): with caplog.at_level(logging.DEBUG):
mw = MentionGatingMiddleware(require_mention="group") mw = MentionGatingMiddleware(require_mention="group")
ctx = _make_channel_context() ctx = _make_channel_context()
raw = _make_raw(is_group=True, was_mentioned=False) raw = _make_raw(is_group=True, was_mentioned=False)
result = await mw.process_inbound(raw, ctx) result = await mw.process_inbound(raw, ctx)
assert result is None assert result is None
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "middleware_mention_drop" in caplog.text assert "middleware_mention_drop" in caplog.text
assert "policy=group" 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 from EvoScientist.channels.middleware import TypingManager
async def _run(): with caplog.at_level(logging.DEBUG):
send_action = AsyncMock(side_effect=RuntimeError("typing api down")) send_action = AsyncMock(side_effect=RuntimeError("typing api down"))
mgr = TypingManager( mgr = TypingManager(
send_action, send_action,
@@ -144,17 +133,14 @@ def test_typing_manager_emits_trace_events(caplog):
await mgr.start("chat1") await mgr.start("chat1")
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
await mgr.stop("chat1") await mgr.stop("chat1")
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "typing_error" in caplog.text assert "typing_error" in caplog.text
assert "chat_id=chat1" 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 from EvoScientist.channels.middleware import AckReactionMiddleware
async def _run(): with caplog.at_level(logging.DEBUG):
send_fn = AsyncMock() send_fn = AsyncMock()
remove_fn = AsyncMock(side_effect=RuntimeError("remove failed")) remove_fn = AsyncMock(side_effect=RuntimeError("remove failed"))
ack = AckReactionMiddleware( ack = AckReactionMiddleware(
@@ -178,16 +164,13 @@ def test_ack_reaction_emits_error_traces(caplog):
send_fn.reset_mock() send_fn.reset_mock()
send_fn.side_effect = RuntimeError("api down") send_fn.side_effect = RuntimeError("api down")
await ack.send_ack("chat2", "msg2") await ack.send_ack("chat2", "msg2")
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "ack_send_error" in caplog.text assert "ack_send_error" in caplog.text
assert "ack_remove_error" in caplog.text assert "ack_remove_error" in caplog.text
assert "api down" in caplog.text assert "api down" in caplog.text
assert "remove failed" 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.""" """Integration-style: _enqueue_raw emits inbound_raw at the top."""
from EvoScientist.channels.base import Channel, RawIncoming from EvoScientist.channels.base import Channel, RawIncoming
@@ -218,19 +201,16 @@ def test_inbound_raw_event_emitted(caplog):
config.ack_scope = "off" config.ack_scope = "off"
config.dedup_ttl = 3600 config.dedup_ttl = 3600
async def _run(): with caplog.at_level(logging.DEBUG):
with patch.object(Channel, "__abstractmethods__", set()): with patch.object(Channel, "__abstractmethods__", set()):
ch = _TestChannel(config) ch = _TestChannel(config)
raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi", message_id="m1") raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi", message_id="m1")
await ch._enqueue_raw(raw) await ch._enqueue_raw(raw)
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "inbound_raw" in caplog.text assert "inbound_raw" in caplog.text
assert "sender_id=u1" 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.""" """_send_with_format_fallback emits outbound_format_fallback on fallback."""
from EvoScientist.channels.base import Channel from EvoScientist.channels.base import Channel
@@ -268,13 +248,10 @@ def test_format_fallback_emits_event(caplog):
if call_count == 1: if call_count == 1:
raise ValueError("parse error in formatted text") raise ValueError("parse error in formatted text")
async def _run(): with caplog.at_level(logging.DEBUG):
with patch.object(Channel, "__abstractmethods__", set()): with patch.object(Channel, "__abstractmethods__", set()):
ch = _TestChannel(config) ch = _TestChannel(config)
await ch._send_with_format_fallback(_failing_send, "<b>hi</b>", "hi") 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 "outbound_format_fallback" in caplog.text
assert call_count == 2 assert call_count == 2
@@ -304,7 +281,7 @@ def test_trace_mixin_trace_event(caplog):
assert "key=val" in caplog.text 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 import MessageBus
from EvoScientist.channels.bus.events import OutboundMessage from EvoScientist.channels.bus.events import OutboundMessage
from EvoScientist.channels.standalone import standalone_outbound_dispatcher 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) channel.send = AsyncMock(return_value=False)
bus = MessageBus() bus = MessageBus()
async def _run(): with caplog.at_level(logging.DEBUG):
task = asyncio.create_task(standalone_outbound_dispatcher(bus, channel)) task = asyncio.create_task(standalone_outbound_dispatcher(bus, channel))
await bus.publish_outbound( await bus.publish_outbound(
OutboundMessage(channel="test", chat_id="c1", content="hi") OutboundMessage(channel="test", chat_id="c1", content="hi")
@@ -326,14 +303,11 @@ def test_standalone_dispatcher_treats_false_send_as_error(caplog):
await task await task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
with caplog.at_level(logging.DEBUG):
run_async(_run())
assert "standalone_dispatch_error" in caplog.text assert "standalone_dispatch_error" in caplog.text
assert "send() returned False" 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 import MessageBus
from EvoScientist.channels.bus.events import OutboundMessage from EvoScientist.channels.bus.events import OutboundMessage
from EvoScientist.channels.standalone import standalone_outbound_dispatcher 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) channel.send_media = AsyncMock(return_value=True)
bus = MessageBus() bus = MessageBus()
async def _run(): task = asyncio.create_task(standalone_outbound_dispatcher(bus, channel))
task = asyncio.create_task(standalone_outbound_dispatcher(bus, channel)) await bus.publish_outbound(
await bus.publish_outbound( OutboundMessage(channel="test", chat_id="c1", content="", media=["/tmp/a.png"])
OutboundMessage( )
channel="test", chat_id="c1", content="", media=["/tmp/a.png"] await asyncio.sleep(0.05)
) task.cancel()
) try:
await asyncio.sleep(0.05) await task
task.cancel() except asyncio.CancelledError:
try: pass
await task
except asyncio.CancelledError:
pass
run_async(_run())
channel.send_media.assert_awaited_once_with( channel.send_media.assert_awaited_once_with(
recipient="c1", recipient="c1",
file_path="/tmp/a.png", file_path="/tmp/a.png",
+145 -180
View File
@@ -14,7 +14,6 @@ from EvoScientist.cli.channel import (
from EvoScientist.cli.channel import ( from EvoScientist.cli.channel import (
dispatch_channel_slash_command as _dispatch_channel_slash_command, dispatch_channel_slash_command as _dispatch_channel_slash_command,
) )
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore 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.""" """Plain text messages must fall through to the agent."""
msg = _make_msg(content="hello agent") msg = _make_msg(content="hello agent")
append = MagicMock() append = MagicMock()
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent=None,
agent=None, thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=append,
append_system=append,
)
) )
assert handled is False assert handled is False
append.assert_not_called() 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).""" """Unknown slash commands must fall through (matches TUI behavior)."""
msg = _make_msg(content="/unknown-cmd") msg = _make_msg(content="/unknown-cmd")
append = MagicMock() append = MagicMock()
@@ -69,20 +66,18 @@ def test_unresolved_slash_returns_false():
"EvoScientist.commands.manager.manager.resolve", "EvoScientist.commands.manager.manager.resolve",
return_value=None, return_value=None,
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent=None,
agent=None, thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=append,
append_system=append,
)
) )
assert handled is False 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, """Known slash command: cmd_manager.execute ran, helper returns True,
sends a confirmation to the channel user, and appends a local log line.""" sends a confirmation to the channel user, and appends a local log line."""
msg = _make_msg() msg = _make_msg()
@@ -100,15 +95,13 @@ def test_successful_slash_execution_sets_response_and_breadcrumb():
) as mock_execute, ) as mock_execute,
patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp, patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent="fake-agent",
agent="fake-agent", thread_id="t1",
thread_id="t1", workspace_dir="/tmp",
workspace_dir="/tmp", checkpointer=None,
checkpointer=None, append_system=append,
append_system=append,
)
) )
assert handled is True assert handled is True
mock_execute.assert_awaited_once() 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) 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() msg = _make_msg()
fake_cmd = MagicMock() fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = False 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"), patch("EvoScientist.cli.channel._set_channel_response"),
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent="fake-agent",
agent="fake-agent", thread_id="t1",
thread_id="t1", workspace_dir="/tmp",
workspace_dir="/tmp", checkpointer=None,
checkpointer=None, append_system=append,
append_system=append, graph_gateway=graph_gateway,
graph_gateway=graph_gateway,
)
) )
assert handled is True assert handled is True
assert captured["graph_gateway"] is graph_gateway 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 """Commands with needs_agent=True must await the loader and the
resulting agent must flow through the CommandContext.""" resulting agent must flow through the CommandContext."""
msg = _make_msg() msg = _make_msg()
@@ -182,16 +173,14 @@ def test_needs_agent_awaits_loader_and_passes_result():
) as mock_execute, ) as mock_execute,
patch("EvoScientist.cli.channel._set_channel_response"), patch("EvoScientist.cli.channel._set_channel_response"),
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent=None,
agent=None, thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=append,
append_system=append, await_agent_ready=_await_ready,
await_agent_ready=_await_ready,
)
) )
assert handled is True assert handled is True
await_called.assert_called_once() await_called.assert_called_once()
@@ -201,7 +190,7 @@ def test_needs_agent_awaits_loader_and_passes_result():
assert ctx_arg.agent == "ready-agent" 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() msg = _make_msg()
fake_cmd = MagicMock() fake_cmd = MagicMock()
fake_cmd.needs_agent.return_value = True 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, patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent=None,
agent=None, thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=append,
append_system=append, await_agent_ready=_await_ready,
await_agent_ready=_await_ready,
)
) )
assert handled is True assert handled is True
mock_set_resp.assert_called_once() 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 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 """If cmd_manager.execute raises past its own try/except, the helper
must absorb it, return True, and report via _set_channel_response.""" must absorb it, return True, and report via _set_channel_response."""
msg = _make_msg() 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, patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent=None,
agent=None, thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=append,
append_system=append,
)
) )
assert handled is True assert handled is True
mock_set_resp.assert_called_once() mock_set_resp.assert_called_once()
@@ -270,7 +255,7 @@ def test_cmd_manager_raises_returns_true_with_error():
assert "boom" in resp_text 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 """After a successful slash execute, the on_cmd_completed hook must
be awaited with (ctx, original_agent, cmd) so Rich CLI can adopt an be awaited with (ctx, original_agent, cmd) so Rich CLI can adopt an
``/model`` agent swap and refresh status for state-mutating commands.""" ``/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"), patch("EvoScientist.cli.channel._set_channel_response"),
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent="original-agent",
agent="original-agent", thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=append,
append_system=append, on_cmd_completed=_on_completed,
on_cmd_completed=_on_completed,
)
) )
assert handled is True assert handled is True
assert captured["ctx_agent"] == "swapped-agent" 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" 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 """``/new`` / ``/compact`` invoked via channel must flow the cmd into
the hook so the callback can still refresh status when the agent the hook so the callback can still refresh status when the agent
didn't swap — mirrors REPL ``interactive.py:1027-1030``.""" 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"), patch("EvoScientist.cli.channel._set_channel_response"),
): ):
_run( await dispatch_channel_slash_command(
dispatch_channel_slash_command( _make_msg(content=cmd_name),
_make_msg(content=cmd_name), agent="same-agent",
agent="same-agent", thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=MagicMock(),
append_system=MagicMock(), on_cmd_completed=_on_completed,
on_cmd_completed=_on_completed,
)
) )
assert captured["cmd_name"] == cmd_name, cmd_name 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 """The hook must NOT fire for unresolved slash, non-slash text, or
when cmd_manager.execute raised.""" when cmd_manager.execute raised."""
fake_cmd = MagicMock() fake_cmd = MagicMock()
@@ -369,16 +350,14 @@ def test_on_cmd_completed_skipped_on_fall_through_and_error():
# Non-slash # Non-slash
with patch("EvoScientist.cli.channel._set_channel_response"): with patch("EvoScientist.cli.channel._set_channel_response"):
_run( await dispatch_channel_slash_command(
dispatch_channel_slash_command( _make_msg(content="hi"),
_make_msg(content="hi"), agent=None,
agent=None, thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=MagicMock(),
append_system=MagicMock(), on_cmd_completed=_noop,
on_cmd_completed=_noop,
)
) )
# Unresolved slash # Unresolved slash
with ( with (
@@ -388,16 +367,14 @@ def test_on_cmd_completed_skipped_on_fall_through_and_error():
), ),
patch("EvoScientist.cli.channel._set_channel_response"), patch("EvoScientist.cli.channel._set_channel_response"),
): ):
_run( await dispatch_channel_slash_command(
dispatch_channel_slash_command( _make_msg(content="/nope"),
_make_msg(content="/nope"), agent=None,
agent=None, thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=MagicMock(),
append_system=MagicMock(), on_cmd_completed=_noop,
on_cmd_completed=_noop,
)
) )
# Execute raises # Execute raises
with ( with (
@@ -411,22 +388,20 @@ def test_on_cmd_completed_skipped_on_fall_through_and_error():
), ),
patch("EvoScientist.cli.channel._set_channel_response"), patch("EvoScientist.cli.channel._set_channel_response"),
): ):
_run( await dispatch_channel_slash_command(
dispatch_channel_slash_command( _make_msg(),
_make_msg(), agent=None,
agent=None, thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=MagicMock(),
append_system=MagicMock(), on_cmd_completed=_noop,
on_cmd_completed=_noop,
)
) )
completed.assert_not_called() 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.""" """A command caught as failed by CommandManager must not look successful."""
msg = _make_msg(content="/resume abc") msg = _make_msg(content="/resume abc")
fake_cmd = MagicMock() 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, patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent=None,
agent=None, thread_id="old-thread",
thread_id="old-thread", workspace_dir="/old-workspace",
workspace_dir="/old-workspace", checkpointer=None,
checkpointer=None, append_system=MagicMock(),
append_system=MagicMock(), on_cmd_completed=completed,
on_cmd_completed=completed,
)
) )
assert handled is True 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") 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.""" """An empty string error is still a command failure sentinel."""
msg = _make_msg(content="/resume abc") msg = _make_msg(content="/resume abc")
fake_cmd = MagicMock() 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, patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent=None,
agent=None, thread_id="old-thread",
thread_id="old-thread", workspace_dir="/old-workspace",
workspace_dir="/old-workspace", checkpointer=None,
checkpointer=None, append_system=MagicMock(),
append_system=MagicMock(), on_cmd_completed=completed,
on_cmd_completed=completed,
)
) )
assert handled is True 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)") 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.""" """A raising hook must NOT prevent the channel response from being set."""
msg = _make_msg() msg = _make_msg()
fake_cmd = MagicMock() 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, patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent="orig",
agent="orig", thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=MagicMock(),
append_system=MagicMock(), on_cmd_completed=_boom,
on_cmd_completed=_boom,
)
) )
assert handled is True assert handled is True
mock_set_resp.assert_called_once() mock_set_resp.assert_called_once()
assert "Command executed" in mock_set_resp.call_args[0][1] 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 """Last-ditch safety net: if anything inside the dispatch pipeline
raises unexpectedly (lazy import failure, ChannelCommandUI ctor, raises unexpectedly (lazy import failure, ChannelCommandUI ctor,
terminal I/O from append_system, ...), the helper must NOT 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, patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent=None,
agent=None, thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=MagicMock(),
append_system=MagicMock(),
)
) )
assert handled is True assert handled is True
mock_set_resp.assert_called_once() mock_set_resp.assert_called_once()
@@ -574,7 +541,7 @@ def test_top_level_exception_is_absorbed():
assert "exploded during resolve" in resp_text 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), """When cmd_manager.execute returns False (empty/unparseable input),
the helper must return False so the caller falls through to the agent.""" the helper must return False so the caller falls through to the agent."""
msg = _make_msg(content="/") 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, patch("EvoScientist.cli.channel._set_channel_response") as mock_set_resp,
): ):
handled = _run( handled = await dispatch_channel_slash_command(
dispatch_channel_slash_command( msg,
msg, agent=None,
agent=None, thread_id="t1",
thread_id="t1", workspace_dir=None,
workspace_dir=None, checkpointer=None,
checkpointer=None, append_system=append,
append_system=append,
)
) )
assert handled is False assert handled is False
mock_set_resp.assert_not_called() mock_set_resp.assert_not_called()
+4 -7
View File
@@ -1,6 +1,5 @@
"""Tests for CLI interactive UI backend dispatch.""" """Tests for CLI interactive UI backend dispatch."""
import asyncio
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@@ -101,7 +100,7 @@ def test_background_agent_server_starts_even_when_async_subagents_disabled(
assert calls == [(config, "/tmp/workspace")] 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, monkeypatch,
): ):
import EvoScientist.cli.commands as cmds 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) config = SimpleNamespace(enable_async_subagents=False)
asyncio.run( await cmds._sync_background_agent_server_workspace(
cmds._sync_background_agent_server_workspace( config,
config, workspace_dir="/tmp/resumed-workspace",
workspace_dir="/tmp/resumed-workspace",
)
) )
assert calls == [(config, "/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 unittest.mock import AsyncMock, MagicMock, patch
from EvoScientist.gateway import GraphTarget from EvoScientist.gateway import GraphTarget
from tests.conftest import run_async as _run
from tests.fakes import FakeCommandUI, FakeGraphGateway from tests.fakes import FakeCommandUI, FakeGraphGateway
_TARGET = GraphTarget() _TARGET = GraphTarget()
def _compact( async def _compact(
graph_gateway: FakeGraphGateway, graph_gateway: FakeGraphGateway,
*, *,
thread_id: str = "tid-1", thread_id: str = "tid-1",
@@ -18,30 +17,28 @@ def _compact(
): ):
from EvoScientist.cli.commands import compact_conversation from EvoScientist.cli.commands import compact_conversation
return _run( return await compact_conversation(
compact_conversation( graph_gateway=graph_gateway,
graph_gateway=graph_gateway, thread_id=thread_id,
thread_id=thread_id, target=_TARGET,
target=_TARGET, input_tokens_hint=input_tokens_hint,
input_tokens_hint=input_tokens_hint,
)
) )
class TestCompactGuards: class TestCompactGuards:
"""Guard conditions that return early without touching the middleware.""" """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": []}) graph_gateway = FakeGraphGateway(state_values={"messages": []})
result = _compact(graph_gateway) result = await _compact(graph_gateway)
assert result.status == "noop" assert result.status == "noop"
assert "no messages" in result.message 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")) graph_gateway = FakeGraphGateway(state_error=RuntimeError("DB gone"))
result = _compact(graph_gateway) result = await _compact(graph_gateway)
assert result.status == "error" assert result.status == "error"
assert "Failed to read state" in result.message assert "Failed to read state" in result.message
@@ -49,7 +46,7 @@ class TestCompactGuards:
class TestCompactCutoffZero: class TestCompactCutoffZero:
"""When cutoff == 0, conversation is within retention budget.""" """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)] msgs = [MagicMock() for _ in range(3)]
graph_gateway = FakeGraphGateway(state_values={"messages": msgs}) graph_gateway = FakeGraphGateway(state_values={"messages": msgs})
@@ -79,7 +76,7 @@ class TestCompactCutoffZero:
return_value=500, return_value=500,
), ),
): ):
result = _compact(graph_gateway) result = await _compact(graph_gateway)
assert result.status == "noop" assert result.status == "noop"
assert "within the retention budget" in result.message assert "within the retention budget" in result.message
@@ -89,7 +86,7 @@ class TestCompactCutoffZero:
class TestCompactNegligibleSavings: class TestCompactNegligibleSavings:
"""When cutoff > 0 but savings are too small to be worth it.""" """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)] msgs = [MagicMock() for _ in range(15)]
graph_gateway = FakeGraphGateway( graph_gateway = FakeGraphGateway(
state_values={"messages": msgs, "_summarization_event": None} state_values={"messages": msgs, "_summarization_event": None}
@@ -126,14 +123,14 @@ class TestCompactNegligibleSavings:
side_effect=lambda x: next(token_values), side_effect=lambda x: next(token_values),
), ),
): ):
result = _compact(graph_gateway) result = await _compact(graph_gateway)
assert result.status == "noop" assert result.status == "noop"
assert "not worth" in result.message assert "not worth" in result.message
# No LLM call should have been made # No LLM call should have been made
mock_middleware_inst._acreate_summary.assert_not_called() 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.""" """2 messages but they account for >2% of tokens — should compact."""
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
@@ -178,7 +175,7 @@ class TestCompactNegligibleSavings:
side_effect=lambda x: next(token_values), side_effect=lambda x: next(token_values),
), ),
): ):
result = _compact(graph_gateway) result = await _compact(graph_gateway)
assert result.status == "ok" assert result.status == "ok"
assert len(graph_gateway.updated_states) == 1 assert len(graph_gateway.updated_states) == 1
@@ -187,7 +184,7 @@ class TestCompactNegligibleSavings:
class TestCompactSuccess: class TestCompactSuccess:
"""Normal compaction flow.""" """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)] msgs = [MagicMock() for _ in range(20)]
graph_gateway = FakeGraphGateway( graph_gateway = FakeGraphGateway(
state_values={"messages": msgs, "_summarization_event": None} state_values={"messages": msgs, "_summarization_event": None}
@@ -217,7 +214,7 @@ class TestCompactSuccess:
return_value=30_000, return_value=30_000,
), ),
): ):
result = _compact(graph_gateway) result = await _compact(graph_gateway)
assert result.status == "noop" assert result.status == "noop"
assert "40%" in result.message assert "40%" in result.message
@@ -225,7 +222,7 @@ class TestCompactSuccess:
mock_middleware_inst._determine_cutoff_index.assert_not_called() mock_middleware_inst._determine_cutoff_index.assert_not_called()
mock_middleware_inst._acreate_summary.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 from langchain_core.messages import HumanMessage
msgs = [MagicMock() for _ in range(20)] msgs = [MagicMock() for _ in range(20)]
@@ -273,7 +270,7 @@ class TestCompactSuccess:
side_effect=lambda x: next(token_values), side_effect=lambda x: next(token_values),
), ),
): ):
result = _compact(graph_gateway) result = await _compact(graph_gateway)
assert result.status == "ok" assert result.status == "ok"
assert result.messages_compacted == 15 assert result.messages_compacted == 15
@@ -291,7 +288,7 @@ class TestCompactSuccess:
assert "_summarization_event" in event_data assert "_summarization_event" in event_data
assert event_data["_summarization_event"]["cutoff_index"] == 15 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.""" """Offload failure should not prevent compaction."""
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
@@ -335,7 +332,7 @@ class TestCompactSuccess:
return_value=1000, return_value=1000,
), ),
): ):
result = _compact(graph_gateway) result = await _compact(graph_gateway)
assert result.status == "ok" assert result.status == "ok"
assert len(graph_gateway.updated_states) == 1 assert len(graph_gateway.updated_states) == 1
@@ -377,7 +374,7 @@ class TestRenderCompactResult:
class TestCompactCommandUI: class TestCompactCommandUI:
"""TUI-specific compact progress indicator behavior.""" """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.cli.commands import CompactResult
from EvoScientist.commands.base import CommandContext from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.session import CompactCommand from EvoScientist.commands.implementation.session import CompactCommand
@@ -413,7 +410,7 @@ class TestCompactCommandUI:
return_value="summary-panel", return_value="summary-panel",
), ),
): ):
_run(CompactCommand().execute(ctx, [])) await CompactCommand().execute(ctx, [])
assert ui.started == 1 assert ui.started == 1
assert ui.stopped == 1 assert ui.stopped == 1
+17
View File
@@ -242,6 +242,23 @@ class TestLoadSaveReset:
assert data["provider"] == "openai" assert data["provider"] == "openai"
assert data["model"] == "gpt-4o" 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): def test_load_reads_saved_config(self, temp_config_dir, clean_env):
"""Test that load reads previously saved config.""" """Test that load reads previously saved config."""
original = EvoScientistConfig( original = EvoScientistConfig(
+6 -7
View File
@@ -15,7 +15,6 @@ from EvoScientist.middleware.configurable_model import (
ConfigurableModelMiddleware, ConfigurableModelMiddleware,
_read_model_override, _read_model_override,
) )
from tests.conftest import run_async as _run
@contextmanager @contextmanager
@@ -134,7 +133,7 @@ class TestPassThrough:
handler.assert_called_once_with(req) handler.assert_called_once_with(req)
req.override.assert_not_called() 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() mw = ConfigurableModelMiddleware()
req = _make_request() req = _make_request()
@@ -143,7 +142,7 @@ class TestPassThrough:
return "ok" return "ok"
with _patched_config({}): with _patched_config({}):
result = _run(mw.awrap_model_call(req, handler)) result = await mw.awrap_model_call(req, handler)
assert result == "ok" assert result == "ok"
req.override.assert_not_called() req.override.assert_not_called()
@@ -185,7 +184,7 @@ class TestModelOverride:
assert called_with is not req assert called_with is not req
assert called_with.model is new_model assert called_with.model is new_model
def test_async_override_path_parity(self): async def test_async_override_path_parity(self):
mw = ConfigurableModelMiddleware() mw = ConfigurableModelMiddleware()
req = _make_request() req = _make_request()
new_model = MagicMock() new_model = MagicMock()
@@ -202,7 +201,7 @@ class TestModelOverride:
"EvoScientist.llm.get_chat_model", return_value=new_model "EvoScientist.llm.get_chat_model", return_value=new_model
) as mock_get, ) as mock_get,
): ):
result = _run(mw.awrap_model_call(req, handler)) result = await mw.awrap_model_call(req, handler)
assert result == "ok" assert result == "ok"
mock_get.assert_called_once_with(model="claude-opus-4-8", provider="anthropic") 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) handler.assert_called_once_with(req)
req.override.assert_not_called() 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() mw = ConfigurableModelMiddleware()
req = _make_request() req = _make_request()
@@ -333,7 +332,7 @@ class TestResolveFailure:
side_effect=ValueError("unknown model"), 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 result == "ok"
assert called == [req] assert called == [req]
@@ -66,7 +66,6 @@ def test_wrap_model_call_raises_context_overflow():
assert handler.call_count == 1 assert handler.call_count == 1
@pytest.mark.anyio
async def test_awrap_model_call_raises_context_overflow(): async def test_awrap_model_call_raises_context_overflow():
# Setup mocks # Setup mocks
msgs = [HumanMessage(content=f"msg {i}") for i in range(10)] 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 assert handler.call_count == 1
@pytest.mark.anyio
async def test_awrap_model_call_passes_through_other_errors(): async def test_awrap_model_call_passes_through_other_errors():
request = ModelRequest( request = ModelRequest(
messages=[], messages=[],
+4 -6
View File
@@ -2,11 +2,9 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
from tests.conftest import run_async as _run
class TestCurrentCommand: 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.base import CommandContext
from EvoScientist.commands.implementation.general import CurrentCommand from EvoScientist.commands.implementation.general import CurrentCommand
@@ -17,14 +15,14 @@ class TestCurrentCommand:
ui=ui, ui=ui,
workspace_dir="/tmp/ws", workspace_dir="/tmp/ws",
) )
_run(CurrentCommand().execute(ctx, [])) await CurrentCommand().execute(ctx, [])
# Three append_system calls: Thread, Workspace, Memory dir. # Three append_system calls: Thread, Workspace, Memory dir.
calls = [c.args[0] for c in ui.append_system.call_args_list] 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("Thread: abc123" in s for s in calls)
assert any("Workspace:" 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) 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.base import CommandContext
from EvoScientist.commands.implementation.general import CurrentCommand from EvoScientist.commands.implementation.general import CurrentCommand
@@ -35,7 +33,7 @@ class TestCurrentCommand:
ui=ui, ui=ui,
workspace_dir=None, workspace_dir=None,
) )
_run(CurrentCommand().execute(ctx, [])) await CurrentCommand().execute(ctx, [])
calls = [c.args[0] for c in ui.append_system.call_args_list] 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("Thread: abc123" in s for s in calls)
assert not any("Workspace:" 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 unittest.mock import AsyncMock, MagicMock
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -21,17 +20,17 @@ def _ctx(thread_id="current", thread_store=None):
class TestDeleteCommand: class TestDeleteCommand:
def test_refuses_to_delete_current(self): async def test_refuses_to_delete_current(self):
from EvoScientist.commands.implementation.session import DeleteCommand from EvoScientist.commands.implementation.session import DeleteCommand
thread_store = FakeThreadStore(resolved_thread_id="current", deleted=True) thread_store = FakeThreadStore(resolved_thread_id="current", deleted=True)
ctx, ui = _ctx(thread_id="current", thread_store=thread_store) 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 assert ("delete_thread", "current") not in thread_store.calls
msgs = [c.args[0] for c in ui.append_system.call_args_list] 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) 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 from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx( ctx, ui = _ctx(
@@ -41,45 +40,45 @@ class TestDeleteCommand:
deleted=True, 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] 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) 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 from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx() 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("not found" in m for m in msgs) 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 from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx(thread_store=FakeThreadStore(matches=["abc-one", "abc-two"])) 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Ambiguous" in m for m in msgs) 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 from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx( ctx, ui = _ctx(
thread_store=FakeThreadStore(resolved_thread_id="abc-one", deleted=True) 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] 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) 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 from EvoScientist.commands.implementation.session import DeleteCommand
ctx, ui = _ctx() ctx, ui = _ctx()
_run(DeleteCommand().execute(ctx, [])) await DeleteCommand().execute(ctx, [])
msgs = [c.args[0] for c in ui.append_system.call_args_list] 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) 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.""" """When no arg and picker returns None, nothing is deleted."""
from EvoScientist.commands.implementation.session import DeleteCommand from EvoScientist.commands.implementation.session import DeleteCommand
@@ -96,5 +95,5 @@ class TestDeleteCommand:
] ]
store = FakeThreadStore(threads=threads) store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store) ctx.graph_gateway = FakeGraphGateway(thread_store=store)
_run(DeleteCommand().execute(ctx, [])) await DeleteCommand().execute(ctx, [])
ui.wait_for_thread_pick.assert_awaited_once() 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.base import ChannelError, OutboundMessage
from EvoScientist.channels.dingtalk.channel import DingTalkChannel, DingTalkConfig from EvoScientist.channels.dingtalk.channel import DingTalkChannel, DingTalkConfig
from tests.conftest import run_async as _run
class TestDingTalkConfig: class TestDingTalkConfig:
@@ -41,30 +40,30 @@ class TestDingTalkChannel:
assert channel._running is False assert channel._running is False
assert channel.name == "dingtalk" 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="") config = DingTalkConfig(client_id="", client_secret="")
channel = DingTalkChannel(config) channel = DingTalkChannel(config)
with pytest.raises(ChannelError, match="client_id and client_secret"): 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") config = DingTalkConfig(client_id="", client_secret="secret")
channel = DingTalkChannel(config) channel = DingTalkChannel(config)
with pytest.raises(ChannelError, match="client_id and client_secret"): 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="") config = DingTalkConfig(client_id="id", client_secret="")
channel = DingTalkChannel(config) channel = DingTalkChannel(config)
with pytest.raises(ChannelError, match="client_id and client_secret"): 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") config = DingTalkConfig(client_id="test-id", client_secret="test-secret")
channel = DingTalkChannel(config) 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") config = DingTalkConfig(client_id="test-id", client_secret="test-secret")
channel = DingTalkChannel(config) channel = DingTalkChannel(config)
msg = OutboundMessage( msg = OutboundMessage(
@@ -73,7 +72,7 @@ class TestDingTalkChannel:
content="hello", content="hello",
metadata={"chat_id": "user123"}, metadata={"chat_id": "user123"},
) )
result = _run(channel.send(msg)) result = await channel.send(msg)
assert result is False assert result is False
def test_capabilities(self): def test_capabilities(self):
@@ -130,20 +129,20 @@ class TestDingTalkWsMessageParsing:
channel._token_expires = 9999999999 channel._token_expires = 9999999999
return channel return channel
def test_system_ping_ack(self): async def test_system_ping_ack(self):
channel = self._make_channel() channel = self._make_channel()
data = { data = {
"type": "SYSTEM", "type": "SYSTEM",
"headers": {"topic": "ping", "messageId": "ping-1"}, "headers": {"topic": "ping", "messageId": "ping-1"},
"data": "pong-data", "data": "pong-data",
} }
_run(channel._on_ws_message(data)) await channel._on_ws_message(data)
channel._ws_session.send_str.assert_called_once() channel._ws_session.send_str.assert_called_once()
sent = json.loads(channel._ws_session.send_str.call_args[0][0]) sent = json.loads(channel._ws_session.send_str.call_args[0][0])
assert sent["code"] == 200 assert sent["code"] == 200
assert sent["data"] == "pong-data" assert sent["data"] == "pong-data"
def test_callback_text_message(self): async def test_callback_text_message(self):
channel = self._make_channel() channel = self._make_channel()
channel._enqueue_raw = AsyncMock() channel._enqueue_raw = AsyncMock()
@@ -158,14 +157,14 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-1", "contentType": "application/json"}, "headers": {"messageId": "msg-1", "contentType": "application/json"},
"data": json.dumps(payload), "data": json.dumps(payload),
} }
_run(channel._on_ws_message(data)) await channel._on_ws_message(data)
channel._enqueue_raw.assert_called_once() channel._enqueue_raw.assert_called_once()
raw = channel._enqueue_raw.call_args[0][0] raw = channel._enqueue_raw.call_args[0][0]
assert raw.text == "hello bot" assert raw.text == "hello bot"
assert raw.sender_id == "staff123" assert raw.sender_id == "staff123"
assert raw.is_group is False 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 = self._make_channel()
channel._enqueue_raw = AsyncMock() channel._enqueue_raw = AsyncMock()
@@ -181,12 +180,12 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-2"}, "headers": {"messageId": "msg-2"},
"data": json.dumps(payload), "data": json.dumps(payload),
} }
_run(channel._on_ws_message(data)) await channel._on_ws_message(data)
raw = channel._enqueue_raw.call_args[0][0] raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True assert raw.is_group is True
assert raw.was_mentioned 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 = self._make_channel()
channel._enqueue_raw = AsyncMock() channel._enqueue_raw = AsyncMock()
@@ -201,12 +200,12 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-3"}, "headers": {"messageId": "msg-3"},
"data": json.dumps(payload), "data": json.dumps(payload),
} }
_run(channel._on_ws_message(data)) await channel._on_ws_message(data)
raw = channel._enqueue_raw.call_args[0][0] raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True assert raw.is_group is True
assert raw.was_mentioned is False assert raw.was_mentioned is False
def test_ignores_non_callback(self): async def test_ignores_non_callback(self):
channel = self._make_channel() channel = self._make_channel()
channel._enqueue_raw = AsyncMock() channel._enqueue_raw = AsyncMock()
@@ -215,10 +214,10 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-x"}, "headers": {"messageId": "msg-x"},
"data": "{}", "data": "{}",
} }
_run(channel._on_ws_message(data)) await channel._on_ws_message(data)
channel._enqueue_raw.assert_not_called() channel._enqueue_raw.assert_not_called()
def test_ignores_empty_content(self): async def test_ignores_empty_content(self):
channel = self._make_channel() channel = self._make_channel()
channel._enqueue_raw = AsyncMock() channel._enqueue_raw = AsyncMock()
@@ -232,20 +231,20 @@ class TestDingTalkWsMessageParsing:
"headers": {"messageId": "msg-e"}, "headers": {"messageId": "msg-e"},
"data": json.dumps(payload), "data": json.dumps(payload),
} }
_run(channel._on_ws_message(data)) await channel._on_ws_message(data)
channel._enqueue_raw.assert_not_called() 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 = self._make_channel()
channel._enqueue_raw = AsyncMock() 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() channel._enqueue_raw.assert_not_called()
class TestDingTalkSendChunk: class TestDingTalkSendChunk:
"""Test _send_chunk with mocked HTTP client.""" """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") config = DingTalkConfig(client_id="test-app", client_secret="test-secret")
channel = DingTalkChannel(config) channel = DingTalkChannel(config)
channel._access_token = "fake-token" channel._access_token = "fake-token"
@@ -256,7 +255,7 @@ class TestDingTalkSendChunk:
channel._http_client = MagicMock() channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response) 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() channel._http_client.post.assert_called_once()
call_args = channel._http_client.post.call_args call_args = channel._http_client.post.call_args
body = call_args.kwargs.get("json") or call_args[1].get("json") body = call_args.kwargs.get("json") or call_args[1].get("json")
@@ -273,21 +272,21 @@ class TestDingTalkChannelRegistration:
class TestDingTalkProbe: class TestDingTalkProbe:
def test_missing_credentials(self): async def test_missing_credentials(self):
from EvoScientist.channels.dingtalk.probe import validate_dingtalk from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, msg = _run(validate_dingtalk("", "")) ok, msg = await validate_dingtalk("", "")
assert ok is False assert ok is False
assert "required" in msg 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 from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, _msg = _run(validate_dingtalk("", "secret")) ok, _msg = await validate_dingtalk("", "secret")
assert ok is False 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 from EvoScientist.channels.dingtalk.probe import validate_dingtalk
ok, _msg = _run(validate_dingtalk("id", "")) ok, _msg = await validate_dingtalk("id", "")
assert ok is False assert ok is False
+6 -7
View File
@@ -4,7 +4,6 @@ import pytest
from EvoScientist.channels.base import ChannelError from EvoScientist.channels.base import ChannelError
from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig
from tests.conftest import run_async as _run
class TestDiscordChannel: class TestDiscordChannel:
@@ -14,18 +13,18 @@ class TestDiscordChannel:
assert channel.config is config assert channel.config is config
assert channel._running is False 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="") config = DiscordConfig(bot_token="")
channel = DiscordChannel(config) channel = DiscordChannel(config)
with pytest.raises(ChannelError): 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") config = DiscordConfig(bot_token="test")
channel = DiscordChannel(config) 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 from EvoScientist.channels.base import OutboundMessage
config = DiscordConfig(bot_token="test") config = DiscordConfig(bot_token="test")
@@ -36,5 +35,5 @@ class TestDiscordChannel:
content="hello", content="hello",
metadata={"chat_id": "123"}, metadata={"chat_id": "123"},
) )
result = _run(channel.send(msg)) result = await channel.send(msg)
assert result is False assert result is False
+8 -10
View File
@@ -2,8 +2,6 @@
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from tests.conftest import run_async as _run
def _ctx(supports_interactive=True): def _ctx(supports_interactive=True):
from EvoScientist.commands.base import CommandContext from EvoScientist.commands.base import CommandContext
@@ -31,7 +29,7 @@ _INDEX = [
class TestInstallSkills: class TestInstallSkills:
def test_picker_cancel_no_install(self): async def test_picker_cancel_no_install(self):
from EvoScientist.commands.implementation.skills import InstallSkills from EvoScientist.commands.implementation.skills import InstallSkills
ctx, ui = _ctx() ctx, ui = _ctx()
@@ -45,12 +43,12 @@ class TestInstallSkills:
"EvoScientist.tools.skills_manager.install_skill", "EvoScientist.tools.skills_manager.install_skill",
) as install_mock, ) as install_mock,
): ):
_run(InstallSkills().execute(ctx, [])) await InstallSkills().execute(ctx, [])
install_mock.assert_not_called() install_mock.assert_not_called()
msgs = [c.args[0] for c in ui.append_system.call_args_list] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Browse cancelled" in m for m in msgs) 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 from EvoScientist.commands.implementation.skills import InstallSkills
ctx, ui = _ctx() ctx, ui = _ctx()
@@ -68,10 +66,10 @@ class TestInstallSkills:
return_value={"success": True, "name": "x"}, return_value={"success": True, "name": "x"},
) as install_mock, ) as install_mock,
): ):
_run(InstallSkills().execute(ctx, [])) await InstallSkills().execute(ctx, [])
assert install_mock.call_count == 2 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.""" """Non-interactive UI + tag arg → auto-installs matching skills."""
from EvoScientist.commands.implementation.skills import InstallSkills from EvoScientist.commands.implementation.skills import InstallSkills
@@ -86,12 +84,12 @@ class TestInstallSkills:
return_value={"success": True, "name": "x"}, return_value={"success": True, "name": "x"},
) as install_mock, ) as install_mock,
): ):
_run(InstallSkills().execute(ctx, ["core"])) await InstallSkills().execute(ctx, ["core"])
# "core" matches research-ideation only → 1 install, no picker call # "core" matches research-ideation only → 1 install, no picker call
assert install_mock.call_count == 1 assert install_mock.call_count == 1
ui.wait_for_skill_browse.assert_not_called() 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 from EvoScientist.commands.implementation.skills import InstallSkills
ctx, ui = _ctx() ctx, ui = _ctx()
@@ -99,6 +97,6 @@ class TestInstallSkills:
"EvoScientist.tools.skills_manager.fetch_remote_skill_index", "EvoScientist.tools.skills_manager.fetch_remote_skill_index",
side_effect=RuntimeError("network fail"), 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Failed to fetch" in m for m in msgs) 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 unittest.mock import MagicMock
from tests.conftest import run_async as _run
class TestExitCommand: 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.base import CommandContext
from EvoScientist.commands.implementation.session import ExitCommand from EvoScientist.commands.implementation.session import ExitCommand
@@ -17,7 +15,7 @@ class TestExitCommand:
ui=ui, ui=ui,
) )
cmd = ExitCommand() cmd = ExitCommand()
_run(cmd.execute(ctx, [])) await cmd.execute(ctx, [])
ui.force_quit.assert_called_once() ui.force_quit.assert_called_once()
def test_aliases_registered(self): def test_aliases_registered(self):
+38 -39
View File
@@ -14,7 +14,6 @@ from EvoScientist.channels.feishu.channel import (
_parse_inline_elements, _parse_inline_elements,
_parse_inline_text, _parse_inline_text,
) )
from tests.conftest import run_async as _run
class TestFeishuConfig: class TestFeishuConfig:
@@ -57,24 +56,24 @@ class TestFeishuChannel:
assert channel._running is False assert channel._running is False
assert channel.name == "feishu" 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") config = FeishuConfig(app_id="", app_secret="test-secret")
channel = FeishuChannel(config) channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="app_id"): 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="") config = FeishuConfig(app_id="test-id", app_secret="")
channel = FeishuChannel(config) channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="app_secret"): 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") config = FeishuConfig(app_id="test-id", app_secret="test-secret")
channel = FeishuChannel(config) 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") config = FeishuConfig(app_id="test-id", app_secret="test-secret")
channel = FeishuChannel(config) channel = FeishuChannel(config)
msg = OutboundMessage( msg = OutboundMessage(
@@ -83,7 +82,7 @@ class TestFeishuChannel:
content="hello", content="hello",
metadata={"chat_id": "oc_test"}, metadata={"chat_id": "oc_test"},
) )
result = _run(channel.send(msg)) result = await channel.send(msg)
assert result is False assert result is False
def test_capabilities(self): def test_capabilities(self):
@@ -201,7 +200,7 @@ class TestFeishuWebhookEvent:
channel._enqueue_raw = AsyncMock() channel._enqueue_raw = AsyncMock()
return channel return channel
def test_text_message_v2(self): async def test_text_message_v2(self):
channel = self._make_channel() channel = self._make_channel()
event = { event = {
"sender": { "sender": {
@@ -217,7 +216,7 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000", "create_time": "1700000000000",
}, },
} }
_run(channel._on_message(event)) await channel._on_message(event)
channel._enqueue_raw.assert_called_once() channel._enqueue_raw.assert_called_once()
raw = channel._enqueue_raw.call_args[0][0] raw = channel._enqueue_raw.call_args[0][0]
assert raw.text == "hello feishu" assert raw.text == "hello feishu"
@@ -225,7 +224,7 @@ class TestFeishuWebhookEvent:
assert raw.chat_id == "oc_chat1" assert raw.chat_id == "oc_chat1"
assert raw.is_group is False 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() channel = self._make_channel()
event = { event = {
"sender": { "sender": {
@@ -242,13 +241,13 @@ class TestFeishuWebhookEvent:
"mentions": [{"key": "@_user_1", "id": {}}], "mentions": [{"key": "@_user_1", "id": {}}],
}, },
} }
_run(channel._on_message(event)) await channel._on_message(event)
raw = channel._enqueue_raw.call_args[0][0] raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True assert raw.is_group is True
assert raw.was_mentioned is True assert raw.was_mentioned is True
assert channel._mention_names == ["@_user_1"] 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() channel = self._make_channel()
event = { event = {
"sender": { "sender": {
@@ -264,12 +263,12 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000", "create_time": "1700000000000",
}, },
} }
_run(channel._on_message(event)) await channel._on_message(event)
raw = channel._enqueue_raw.call_args[0][0] raw = channel._enqueue_raw.call_args[0][0]
assert raw.is_group is True assert raw.is_group is True
assert raw.was_mentioned is False assert raw.was_mentioned is False
def test_skips_bot_messages(self): async def test_skips_bot_messages(self):
channel = self._make_channel() channel = self._make_channel()
event = { event = {
"sender": { "sender": {
@@ -283,10 +282,10 @@ class TestFeishuWebhookEvent:
"content": json.dumps({"text": "bot reply"}), "content": json.dumps({"text": "bot reply"}),
}, },
} }
_run(channel._on_message(event)) await channel._on_message(event)
channel._enqueue_raw.assert_not_called() channel._enqueue_raw.assert_not_called()
def test_post_message(self): async def test_post_message(self):
channel = self._make_channel() channel = self._make_channel()
post_content = { post_content = {
"zh_cn": { "zh_cn": {
@@ -308,12 +307,12 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000", "create_time": "1700000000000",
}, },
} }
_run(channel._on_message(event)) await channel._on_message(event)
raw = channel._enqueue_raw.call_args[0][0] raw = channel._enqueue_raw.call_args[0][0]
assert "Test" in raw.text assert "Test" in raw.text
assert "Post body" 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() channel = self._make_channel()
event = { event = {
"sender": { "sender": {
@@ -329,7 +328,7 @@ class TestFeishuWebhookEvent:
"create_time": "1700000000000", "create_time": "1700000000000",
}, },
} }
_run(channel._on_message(event)) await channel._on_message(event)
raw = channel._enqueue_raw.call_args[0][0] raw = channel._enqueue_raw.call_args[0][0]
assert "share_chat" in raw.text assert "share_chat" in raw.text
@@ -337,7 +336,7 @@ class TestFeishuWebhookEvent:
class TestFeishuSendChunk: class TestFeishuSendChunk:
"""Test _send_chunk with mocked HTTP client.""" """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") config = FeishuConfig(app_id="test-app", app_secret="test-secret")
channel = FeishuChannel(config) channel = FeishuChannel(config)
channel._access_token = "fake-token" channel._access_token = "fake-token"
@@ -348,14 +347,14 @@ class TestFeishuSendChunk:
channel._http_client = MagicMock() channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response) 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() channel._http_client.post.assert_called()
# Should try post format first # Should try post format first
call_args = channel._http_client.post.call_args call_args = channel._http_client.post.call_args
body = call_args.kwargs.get("json") or call_args[1].get("json") body = call_args.kwargs.get("json") or call_args[1].get("json")
assert body["receive_id"] == "oc_chat1" 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") config = FeishuConfig(app_id="test-app", app_secret="test-secret")
channel = FeishuChannel(config) channel = FeishuChannel(config)
channel._access_token = "fake-token" channel._access_token = "fake-token"
@@ -366,7 +365,7 @@ class TestFeishuSendChunk:
channel._http_client = MagicMock() channel._http_client = MagicMock()
channel._http_client.post = AsyncMock(return_value=mock_response) 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 # Should call the reply API endpoint
first_call_url = channel._http_client.post.call_args_list[0][0][0] first_call_url = channel._http_client.post.call_args_list[0][0][0]
assert "reply" in first_call_url assert "reply" in first_call_url
@@ -484,17 +483,17 @@ class TestFeishuChannelRegistration:
class TestFeishuProbe: class TestFeishuProbe:
def test_missing_app_id(self): async def test_missing_app_id(self):
from EvoScientist.channels.feishu.probe import validate_feishu_credentials 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 ok is False
assert "app_id" in msg 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 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 ok is False
assert "app_secret" in msg assert "app_secret" in msg
@@ -510,7 +509,7 @@ class TestFeishuWebSocketMode:
) )
assert config.subscription_mode == "websocket" 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( config = FeishuConfig(
app_id="test-id", app_id="test-id",
app_secret="test-secret", app_secret="test-secret",
@@ -520,9 +519,9 @@ class TestFeishuWebSocketMode:
# Temporarily hide lark_oapi if it's installed # Temporarily hide lark_oapi if it's installed
with patch.dict(sys.modules, {"lark_oapi": None}): with patch.dict(sys.modules, {"lark_oapi": None}):
with pytest.raises(ChannelError, match="lark-oapi"): 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.""" """Ensure subscription_mode='webhook' still validates as before."""
config = FeishuConfig( config = FeishuConfig(
app_id="", app_id="",
@@ -531,9 +530,9 @@ class TestFeishuWebSocketMode:
) )
channel = FeishuChannel(config) channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="app_id"): 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( config = FeishuConfig(
app_id="test-id", app_id="test-id",
app_secret="test-secret", app_secret="test-secret",
@@ -541,9 +540,9 @@ class TestFeishuWebSocketMode:
) )
channel = FeishuChannel(config) channel = FeishuChannel(config)
with pytest.raises(ChannelError, match="Invalid feishu_subscription_mode"): 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.""" """Test that _on_lark_sdk_message enqueues event dict via queue."""
import queue as queue_mod import queue as queue_mod
@@ -594,14 +593,14 @@ class TestFeishuWebSocketMode:
) )
# Verify the consumer processes it correctly # Verify the consumer processes it correctly
_run(channel._on_message(event_dict)) await channel._on_message(event_dict)
channel._enqueue_raw.assert_called_once() channel._enqueue_raw.assert_called_once()
raw = channel._enqueue_raw.call_args[0][0] raw = channel._enqueue_raw.call_args[0][0]
assert raw.text == "hello from websocket" assert raw.text == "hello from websocket"
assert raw.sender_id == "ou_test_ws" assert raw.sender_id == "ou_test_ws"
assert raw.is_group is False assert raw.is_group is False
def test_cleanup_websocket_mode(self): async def test_cleanup_websocket_mode(self):
config = FeishuConfig( config = FeishuConfig(
app_id="test-id", app_id="test-id",
app_secret="test-secret", app_secret="test-secret",
@@ -617,7 +616,7 @@ class TestFeishuWebSocketMode:
channel._ws_consumer_task = None channel._ws_consumer_task = None
channel._access_token = "fake-token" channel._access_token = "fake-token"
_run(channel._cleanup()) await channel._cleanup()
mock_client.aclose.assert_called_once() mock_client.aclose.assert_called_once()
assert channel._http_client is None assert channel._http_client is None
+31 -41
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock 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") 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,
): ):
monkeypatch.setattr( 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()), lambda **_kwargs: SimpleNamespace(threads=_Threads(), runs=_Runs()),
) )
async def run() -> None: with pytest.raises(RuntimeError, match="run creation failed"):
with pytest.raises(RuntimeError, match="run creation failed"): await background_runs.alaunch_background_run(_request())
await background_runs.alaunch_background_run(_request())
asyncio.run(run())
assert deleted == ["thread-1"] assert deleted == ["thread-1"]
@@ -278,7 +274,7 @@ def test_sync_status_watcher_preserves_thread_on_poll_failure(
assert deleted == [] 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] = [] finished: list[background_runs.BackgroundRun] = []
aborted: list[background_runs.BackgroundRun] = [] aborted: list[background_runs.BackgroundRun] = []
deleted: list[str] = [] 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): async def delete(self, thread_id: str):
deleted.append(thread_id) deleted.append(thread_id)
async def run() -> None: await background_runs.awatch_background_run(
await background_runs.awatch_background_run( SimpleNamespace(runs=_Runs(), threads=_Threads()),
SimpleNamespace(runs=_Runs(), threads=_Threads()), thread_id="thread-1",
thread_id="thread-1", run_id="run-1",
run_id="run-1", name="test worker",
name="test worker", hooks=background_runs.BackgroundRunHooks(
hooks=background_runs.BackgroundRunHooks( on_finished=finished.append,
on_finished=finished.append, on_aborted=aborted.append,
on_aborted=aborted.append, ),
), watcher_config=background_runs.BackgroundRunWatcherConfig(
watcher_config=background_runs.BackgroundRunWatcherConfig( poll_interval_seconds=0,
poll_interval_seconds=0, ),
), )
)
asyncio.run(run())
assert finished == [] assert finished == []
assert [run.run_id for run in aborted] == ["run-1"] assert [run.run_id for run in aborted] == ["run-1"]
assert deleted == ["thread-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] = [] finished: list[background_runs.BackgroundRun] = []
class _Runs: class _Runs:
@@ -324,21 +317,18 @@ def test_async_status_watcher_preserves_run_url():
async def delete(self, _thread_id: str): async def delete(self, _thread_id: str):
return None return None
async def run() -> None: await background_runs.awatch_background_run(
await background_runs.awatch_background_run( SimpleNamespace(runs=_Runs(), threads=_Threads()),
SimpleNamespace(runs=_Runs(), threads=_Threads()), url="http://worker.example",
url="http://worker.example", thread_id="thread-1",
thread_id="thread-1", run_id="run-1",
run_id="run-1", name="test worker",
name="test worker", hooks=background_runs.BackgroundRunHooks(
hooks=background_runs.BackgroundRunHooks( on_finished=finished.append,
on_finished=finished.append, ),
), watcher_config=background_runs.BackgroundRunWatcherConfig(
watcher_config=background_runs.BackgroundRunWatcherConfig( poll_interval_seconds=0,
poll_interval_seconds=0, ),
), )
)
asyncio.run(run())
assert [run.url for run in finished] == ["http://worker.example"] 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.gateway.server import _THREAD_SEARCH_LIMIT
from EvoScientist.stream import display as display_mod from EvoScientist.stream import display as display_mod
from tests.conftest import run_async
from tests.fakes import ( from tests.fakes import (
FakeGraphGateway, FakeGraphGateway,
FakeLangGraphClient, 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] = {} seen: dict[str, Any] = {}
async def _streamer(agent, message, thread_id, **kwargs): 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)] return [event async for event in gateway.stream_events(request)]
with patch("EvoScientist.stream.events.stream_agent_events", new=_streamer): with patch("EvoScientist.stream.events.stream_agent_events", new=_streamer):
events = run_async(_collect()) events = await _collect()
assert events == [ assert events == [
{"type": "text", "content": "hi"}, {"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( thread_store = FakeThreadStore(
generated_thread_id="new12345", generated_thread_id="new12345",
threads=[{"thread_id": "abc12345"}], threads=[{"thread_id": "abc12345"}],
@@ -102,7 +101,7 @@ def test_local_graph_gateway_delegates_thread_operations():
"deleted": await gateway.delete_thread("abc12345"), "deleted": await gateway.delete_thread("abc12345"),
} }
result = run_async(_run()) result = await _run()
assert result["created"] == "new12345" assert result["created"] == "new12345"
assert result["threads"] == [{"thread_id": "abc12345"}] 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 = MagicMock()
agent.aget_state = AsyncMock( agent.aget_state = AsyncMock(
return_value=SimpleNamespace(values={"async_tasks": {"task-1": {}}}) return_value=SimpleNamespace(values={"async_tasks": {"task-1": {}}})
) )
gateway = LocalGraphGateway() gateway = LocalGraphGateway()
values = run_async( values = await gateway.get_state_values(GraphTarget(local_graph=agent), "abc12345")
gateway.get_state_values(GraphTarget(local_graph=agent), "abc12345")
)
assert values == {"async_tasks": {"task-1": {}}} assert values == {"async_tasks": {"task-1": {}}}
agent.aget_state.assert_awaited_once_with( 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 = MagicMock()
agent.aupdate_state = AsyncMock() agent.aupdate_state = AsyncMock()
gateway = LocalGraphGateway() gateway = LocalGraphGateway()
run_async( await gateway.update_state_values(
gateway.update_state_values( GraphTarget(local_graph=agent),
GraphTarget(local_graph=agent), "abc12345",
"abc12345", {"_summarization_event": {"cutoff_index": 2}},
{"_summarization_event": {"cutoff_index": 2}},
)
) )
agent.aupdate_state.assert_awaited_once_with( 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 cleanup_ran = False
async def _streamer(_agent, _message, _thread_id, **_kwargs): 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 assert cleanup_ran is True
with patch("EvoScientist.stream.events.stream_agent_events", new=_streamer): with patch("EvoScientist.stream.events.stream_agent_events", new=_streamer):
run_async(_run()) await _run()
def test_run_streaming_can_consume_injected_gateway(): 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.base import CommandContext
from EvoScientist.commands.implementation.session import ResumeCommand 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), 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.thread_id == "abc12345"
assert ctx.workspace_dir == "/restored" 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 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 = FakeLangGraphThreadsClient(
threads=[ threads=[
{ {
@@ -335,7 +330,7 @@ def test_langgraph_server_thread_store_delegates_to_sdk_threads():
"deleted": await store.delete_thread("abc12345"), "deleted": await store.delete_thread("abc12345"),
} }
result = run_async(_run()) result = await _run()
assert result["created"] == "server-thread" assert result["created"] == "server-thread"
assert len(threads.created) == 1 assert len(threads.created) == 1
@@ -379,7 +374,7 @@ def test_langgraph_server_thread_store_delegates_to_sdk_threads():
assert threads.deleted == ["abc12345"] 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 = [ rows = [
{ {
"thread_id": f"thread-{index}", "thread_id": f"thread-{index}",
@@ -392,7 +387,7 @@ def test_langgraph_server_thread_store_limit_zero_pages_all_threads():
client=FakeLangGraphClient(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] == [ assert [row["thread_id"] for row in result] == [
f"thread-{index}" for index in range(_THREAD_SEARCH_LIMIT + 1) 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 = FakeLangGraphThreadsClient(
threads=[ threads=[
{ {
@@ -417,7 +412,7 @@ def test_langgraph_server_thread_store_positive_limit_uses_single_search():
client=FakeLangGraphClient(threads), 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 [row["thread_id"] for row in result] == ["thread-0", "thread-1"]
assert [(search["limit"], search["offset"]) for search in threads.searches] == [ 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 = FakeLangGraphThreadsClient(
threads=[ threads=[
{ {
@@ -438,14 +433,14 @@ def test_langgraph_server_thread_store_prefix_resolution_skips_exact_lookup():
client=FakeLangGraphClient(threads), client=FakeLangGraphClient(threads),
) )
result = run_async(store.resolve_thread_id_prefix("abc")) result = await store.resolve_thread_id_prefix("abc")
assert result == ("abc12345", []) assert result == ("abc12345", [])
assert threads.gets == [] assert threads.gets == []
assert len(threads.searches) == 1 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 = [ rows = [
{ {
"thread_id": f"thread-{index}", "thread_id": f"thread-{index}",
@@ -464,7 +459,7 @@ def test_langgraph_server_thread_store_prefix_resolution_pages_all_threads():
client=FakeLangGraphClient(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 result == ("older-thread-match", [])
assert [(search["limit"], search["offset"]) for search in threads.searches] == [ 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" thread_id = "019ed9e4-4253-7f62-b50f-f0470a4b3c9f"
threads = FakeLangGraphThreadsClient( threads = FakeLangGraphThreadsClient(
threads=[ threads=[
@@ -487,14 +482,14 @@ def test_langgraph_server_thread_store_uuid_resolution_uses_exact_lookup():
client=FakeLangGraphClient(threads), 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 result == (thread_id, [])
assert threads.gets == [thread_id] assert threads.gets == [thread_id]
assert threads.searches == [] 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" thread_id = "019ed9e4-4253-7f62-b50f-f0470a4b3c9f"
threads = FakeLangGraphThreadsClient( threads = FakeLangGraphThreadsClient(
threads=[ threads=[
@@ -508,7 +503,7 @@ def test_langgraph_server_thread_store_uuid_resolution_filters_graph_id():
client=FakeLangGraphClient(threads), 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 result == (None, [])
assert threads.gets == [thread_id] 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_metadata = {
"clone_purpose": "memory_extraction", "clone_purpose": "memory_extraction",
"source_thread_id": "source-thread", "source_thread_id": "source-thread",
@@ -534,8 +529,8 @@ def test_langgraph_server_thread_store_clones_thread_with_metadata():
client=FakeLangGraphClient(threads), client=FakeLangGraphClient(threads),
) )
cloned_thread_id = run_async( cloned_thread_id = await store.clone_thread(
store.clone_thread("source-thread", metadata=clone_metadata) "source-thread", metadata=clone_metadata
) )
assert cloned_thread_id == "source-thread-copy" 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 = FakeLangGraphThreadsClient(
threads=[{"thread_id": "source-thread", "metadata": {"graph_id": "agent"}}], threads=[{"thread_id": "source-thread", "metadata": {"graph_id": "agent"}}],
copy_response=None, copy_response=None,
@@ -565,10 +560,10 @@ def test_langgraph_server_thread_store_rejects_copy_without_thread_id():
await store.clone_thread("source-thread") await store.clone_thread("source-thread")
with pytest.raises(RuntimeError, match="did not return a cloned thread id"): 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 = FakeLangGraphThreadsClient(
threads=[{"thread_id": "source-thread", "metadata": {"graph_id": "agent"}}] 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( cloned_thread_id = await gateway.clone_thread(
gateway.clone_thread( "source-thread",
"source-thread", metadata={"clone_purpose": "manual"},
metadata={"clone_purpose": "manual"}, target=GraphTarget(graph_id="agent"),
target=GraphTarget(graph_id="agent"),
)
) )
assert cloned_thread_id == "source-thread-copy" 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(): async def _run():
await LocalGraphGateway().clone_thread("source-thread") await LocalGraphGateway().clone_thread("source-thread")
with pytest.raises(NotImplementedError, match="does not support thread cloning"): with pytest.raises(NotImplementedError, match="does not support thread cloning"):
run_async(_run()) await _run()
def test_runtime_gateways_can_use_langgraph_server_backend(): 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 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 = FakeLangGraphThreadsClient(
threads=[{"thread_id": "abc12345", "metadata": {"graph_id": "EvoScientist"}}], threads=[{"thread_id": "abc12345", "metadata": {"graph_id": "EvoScientist"}}],
states={"abc12345": {"values": {"async_tasks": {"task-1": {}}}}}, 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": {}}} 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 = FakeLangGraphThreadsClient(
threads=[{"thread_id": "abc12345", "metadata": {"graph_id": "EvoScientist"}}], threads=[{"thread_id": "abc12345", "metadata": {"graph_id": "EvoScientist"}}],
states={ 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 len(messages) == 2
assert isinstance(messages[0], AIMessage) assert isinstance(messages[0], AIMessage)
@@ -667,7 +660,7 @@ def test_langgraph_server_gateway_messages_apply_summarization_event():
assert messages[1].content == "third" 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 = FakeLangGraphThreadsClient(
threads=[{"thread_id": "abc12345", "metadata": {"graph_id": "EvoScientist"}}], threads=[{"thread_id": "abc12345", "metadata": {"graph_id": "EvoScientist"}}],
) )
@@ -677,12 +670,10 @@ def test_langgraph_server_gateway_updates_state_values():
) )
) )
run_async( await gateway.update_state_values(
gateway.update_state_values( GraphTarget(),
GraphTarget(), "abc12345",
"abc12345", {"_summarization_event": {"cutoff_index": 2}},
{"_summarization_event": {"cutoff_index": 2}},
)
) )
assert threads.state_updates == [ 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( stream = FakeLangGraphThreadStream(
"abc12345", "abc12345",
events=[ 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 len(threads.created) == 1
assert threads.created[0]["thread_id"] == "abc12345" 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]], events: list[dict[str, object]],
*, *,
state_messages: list[dict[str, object]] | None = None, 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(): async def test_langgraph_server_gateway_streams_value_message_snapshots():
events = _collect_server_gateway_stream( events = await _collect_server_gateway_stream(
[ [
_value_snapshot([_OLD_AI, _HUMAN]), _value_snapshot([_OLD_AI, _HUMAN]),
_value_snapshot([_OLD_AI, _HUMAN, _NEW_AI]), _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(): async def test_langgraph_server_gateway_values_do_not_duplicate_message_stream():
events = _collect_server_gateway_stream( events = await _collect_server_gateway_stream(
[ [
_root_text_delta("new"), _root_text_delta("new"),
_root_message_finish(), _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(): async def test_langgraph_server_gateway_ignores_non_root_value_messages():
events = _collect_server_gateway_stream( events = await _collect_server_gateway_stream(
[ [
_value_snapshot( _value_snapshot(
[{"type": "ai", "content": "subagent text", "id": "subagent-ai"}], [{"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": ""} 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( stream = FakeLangGraphThreadStream(
"abc12345", "abc12345",
events=[], events=[],
@@ -930,7 +921,7 @@ def test_langgraph_server_gateway_emits_state_interrupt_before_done():
) )
] ]
events = run_async(_collect()) events = await _collect()
assert events == [ 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( stream = FakeLangGraphThreadStream(
"abc12345", "abc12345",
events=[ events=[
@@ -1003,7 +994,7 @@ def test_langgraph_server_gateway_streams_subagent_protocol_events():
) )
] ]
events = run_async(_collect()) events = await _collect()
assert events == [ 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 from langgraph.types import Command
stream = FakeLangGraphThreadStream( 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.starts == []
assert stream.run.responses == [ assert stream.run.responses == [
+4 -4
View File
@@ -378,7 +378,7 @@ class TestHitlConfig:
class TestInterruptEventParsing: 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__ in updates mode yields interrupt event."""
interrupt_data = { interrupt_data = {
"__interrupt__": [ "__interrupt__": [
@@ -405,7 +405,7 @@ class TestInterruptEventParsing:
protocol_event("updates", interrupt_data), 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] types = [e["type"] for e in events]
assert "interrupt" in types assert "interrupt" in types
@@ -415,14 +415,14 @@ class TestInterruptEventParsing:
assert interrupt_ev["action_requests"][0]["name"] == "execute" assert interrupt_ev["action_requests"][0]["name"] == "execute"
assert interrupt_ev["interrupt_id"] == "main" 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.""" """Regular updates mode data is skipped as before."""
agent = FakeV3Agent( agent = FakeV3Agent(
[ [
protocol_event("updates", {"some_node": {"key": "value"}}), 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] types = [e["type"] for e in events]
assert "interrupt" not in types assert "interrupt" not in types
+10 -12
View File
@@ -2,8 +2,6 @@
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx(): def _ctx():
from EvoScientist.commands.base import CommandContext from EvoScientist.commands.base import CommandContext
@@ -14,15 +12,15 @@ def _ctx():
class TestInstallSkill: 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 from EvoScientist.commands.implementation.skills import InstallSkill
ctx, ui = _ctx() ctx, ui = _ctx()
_run(InstallSkill().execute(ctx, [])) await InstallSkill().execute(ctx, [])
msgs = [c.args[0] for c in ui.append_system.call_args_list] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Usage:" in m for m in msgs) 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 from EvoScientist.commands.implementation.skills import InstallSkill
ctx, ui = _ctx() ctx, ui = _ctx()
@@ -35,21 +33,21 @@ class TestInstallSkill:
"path": "/tmp/demo", "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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Installed: demo-skill" in m for m in msgs) assert any("Installed: demo-skill" in m for m in msgs)
class TestUninstallSkill: 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 from EvoScientist.commands.implementation.skills import UninstallSkill
ctx, ui = _ctx() ctx, ui = _ctx()
_run(UninstallSkill().execute(ctx, [])) await UninstallSkill().execute(ctx, [])
msgs = [c.args[0] for c in ui.append_system.call_args_list] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Usage:" in m for m in msgs) 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 from EvoScientist.commands.implementation.skills import UninstallSkill
ctx, ui = _ctx() ctx, ui = _ctx()
@@ -57,11 +55,11 @@ class TestUninstallSkill:
"EvoScientist.tools.skills_manager.uninstall_skill", "EvoScientist.tools.skills_manager.uninstall_skill",
return_value={"success": True}, 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Uninstalled: demo-skill" in m for m in msgs) 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 from EvoScientist.commands.implementation.skills import UninstallSkill
ctx, ui = _ctx() ctx, ui = _ctx()
@@ -69,6 +67,6 @@ class TestUninstallSkill:
"EvoScientist.tools.skills_manager.uninstall_skill", "EvoScientist.tools.skills_manager.uninstall_skill",
return_value={"success": False, "error": "not found"}, 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Failed: not found" in m for m in msgs) 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 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.""" """Each event dict is serialized to exactly one JSON line, in order."""
events = [ events = [
{"type": "thinking", "content": "hmm", "id": 0}, {"type": "thinking", "content": "hmm", "id": 0},
@@ -34,7 +34,7 @@ def test_writes_each_event_as_one_jsonl_line(run_async):
] ]
out = io.StringIO() out = io.StringIO()
run_async(write_events_as_json(_agen(events), out)) await write_events_as_json(_agen(events), out)
lines = out.getvalue().splitlines() lines = out.getvalue().splitlines()
assert len(lines) == len(events) 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"} 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.""" """The sink returns the response text carried by the terminal `done` event."""
events = [ events = [
{"type": "text", "content": "partial"}, {"type": "text", "content": "partial"},
@@ -51,12 +51,12 @@ def test_returns_final_response_from_done_event(run_async):
] ]
out = io.StringIO() 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" 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.""" """A non-JSON-serializable value degrades to its str form instead of raising."""
class Weird: class Weird:
@@ -72,7 +72,7 @@ def test_non_serializable_arg_does_not_crash_the_stream(run_async):
] ]
out = io.StringIO() out = io.StringIO()
run_async(write_events_as_json(_agen(events), out)) await write_events_as_json(_agen(events), out)
lines = out.getvalue().splitlines() lines = out.getvalue().splitlines()
# Both lines must be valid JSON; the non-serializable value falls back to str. # 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" 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 """stream_json pulls events from gateway.stream_events(request) and serializes
them — it does not reach past the gateway abstraction.""" them — it does not reach past the gateway abstraction."""
seen: dict[str, object] = {} seen: dict[str, object] = {}
@@ -98,7 +98,7 @@ def test_stream_json_sources_events_from_gateway(run_async):
return _agen(events) return _agen(events)
out = io.StringIO() out = io.StringIO()
result = run_async(stream_json(_FakeGateway(), object(), out=out)) result = await stream_json(_FakeGateway(), object(), out=out)
assert result == "hi" assert result == "hi"
assert "request" in seen # the request was forwarded to the gateway 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"] 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 """An error from the gateway stream propagates out of stream_json so the CLI
dispatch can turn it into a clean exit.""" dispatch can turn it into a clean exit."""
@@ -124,4 +124,4 @@ def test_stream_json_propagates_gateway_errors(run_async):
out = io.StringIO() out = io.StringIO()
with pytest.raises(RuntimeError, match="boom"): with pytest.raises(RuntimeError, match="boom"):
run_async(stream_json(_FakeGateway(), object(), out=out)) await stream_json(_FakeGateway(), object(), out=out)
-5
View File
@@ -972,7 +972,6 @@ class TestPatchOpenAICompatContent:
called_msgs = orig.call_args[0][0] called_msgs = orig.call_args[0][0]
assert called_msgs[0].content == "hello" assert called_msgs[0].content == "hello"
@pytest.mark.anyio
async def test_agenerate_flattened(self): async def test_agenerate_flattened(self):
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
@@ -1003,7 +1002,6 @@ class TestPatchOpenAICompatContent:
called_msgs = orig.call_args[0][0] called_msgs = orig.call_args[0][0]
assert called_msgs[0].content == "hello" assert called_msgs[0].content == "hello"
@pytest.mark.anyio
async def test_astream_flattened(self): async def test_astream_flattened(self):
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
@@ -1044,7 +1042,6 @@ class TestPatchOpenAICompatContent:
called_msgs = orig.call_args[0][0] called_msgs = orig.call_args[0][0]
assert called_msgs[0].content == [{"type": "text", "text": "see"}, img] assert called_msgs[0].content == [{"type": "text", "text": "see"}, img]
@pytest.mark.anyio
async def test_agenerate_preserves_media(self): async def test_agenerate_preserves_media(self):
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
@@ -1077,7 +1074,6 @@ class TestPatchOpenAICompatContent:
called_msgs = orig.call_args[0][0] called_msgs = orig.call_args[0][0]
assert called_msgs[0].content == [{"type": "text", "text": "see"}, img] assert called_msgs[0].content == [{"type": "text", "text": "see"}, img]
@pytest.mark.anyio
async def test_astream_preserves_media(self): async def test_astream_preserves_media(self):
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
@@ -1550,7 +1546,6 @@ class TestNoVisionFallback:
assert out == ["x", "y"] assert out == ["x", "y"]
assert len(calls) == 2 assert len(calls) == 2
@pytest.mark.anyio
async def test_astream_falls_back(self): async def test_astream_falls_back(self):
from unittest.mock import MagicMock from unittest.mock import MagicMock
+10 -18
View File
@@ -1399,9 +1399,7 @@ class TestLoadToolsProgressCallback:
monkeypatch.setattr(lc_client, "MultiServerMCPClient", _FakeClient) monkeypatch.setattr(lc_client, "MultiServerMCPClient", _FakeClient)
def test_success_emits_start_then_success_with_tool_count(self, monkeypatch): async def test_success_emits_start_then_success_with_tool_count(self, monkeypatch):
import asyncio
from EvoScientist.mcp.client import _load_tools from EvoScientist.mcp.client import _load_tools
events: list[tuple[str, str, str]] = [] events: list[tuple[str, str, str]] = []
@@ -1415,16 +1413,14 @@ class TestLoadToolsProgressCallback:
def record(event, name, detail): def record(event, name, detail):
events.append((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 == [ assert events == [
("start", "srv", ""), ("start", "srv", ""),
("success", "srv", "3"), ("success", "srv", "3"),
] ]
def test_failure_emits_start_then_error_with_detail(self, monkeypatch): async def test_failure_emits_start_then_error_with_detail(self, monkeypatch):
import asyncio
from EvoScientist.mcp.client import _load_tools from EvoScientist.mcp.client import _load_tools
events: list[tuple[str, str, str]] = [] events: list[tuple[str, str, str]] = []
@@ -1435,16 +1431,14 @@ class TestLoadToolsProgressCallback:
def record(event, name, detail): def record(event, name, detail):
events.append((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 == [ assert events == [
("start", "srv", ""), ("start", "srv", ""),
("error", "srv", "boom"), ("error", "srv", "boom"),
] ]
def test_mixed_fleet_reports_each_server_independently(self, monkeypatch): async def test_mixed_fleet_reports_each_server_independently(self, monkeypatch):
import asyncio
from EvoScientist.mcp.client import _load_tools from EvoScientist.mcp.client import _load_tools
events: list[tuple[str, str, str]] = [] events: list[tuple[str, str, str]] = []
@@ -1464,7 +1458,7 @@ class TestLoadToolsProgressCallback:
def record(event, name, detail): def record(event, name, detail):
events.append((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 = {} by_server = {}
for ev, name, detail in events: for ev, name, detail in events:
@@ -1472,9 +1466,7 @@ class TestLoadToolsProgressCallback:
assert by_server["ok_srv"] == [("start", ""), ("success", "1")] assert by_server["ok_srv"] == [("start", ""), ("success", "1")]
assert by_server["bad_srv"] == [("start", ""), ("error", "refused")] assert by_server["bad_srv"] == [("start", ""), ("error", "refused")]
def test_callback_errors_do_not_break_the_load(self, monkeypatch): async def test_callback_errors_do_not_break_the_load(self, monkeypatch):
import asyncio
from EvoScientist.mcp.client import _load_tools from EvoScientist.mcp.client import _load_tools
self._patch_client(monkeypatch, {"srv": ["tool1"]}) self._patch_client(monkeypatch, {"srv": ["tool1"]})
@@ -1484,10 +1476,10 @@ class TestLoadToolsProgressCallback:
def bad_callback(event, name, detail): def bad_callback(event, name, detail):
raise RuntimeError("callback bug") 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"]} 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.""" """Many configured servers must not all spawn at once."""
import asyncio import asyncio
@@ -1516,7 +1508,7 @@ class TestLoadToolsProgressCallback:
config = { config = {
f"srv{i}": {"transport": "stdio", "command": "demo"} for i in range(10) 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"] <= 3
assert inflight["peak"] > 1 # sanity: we *are* parallelizing assert inflight["peak"] > 1 # sanity: we *are* parallelizing
+16 -18
View File
@@ -2,8 +2,6 @@
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx(): def _ctx():
from EvoScientist.commands.base import CommandContext from EvoScientist.commands.base import CommandContext
@@ -14,16 +12,16 @@ def _ctx():
class TestMCPCommandDispatch: class TestMCPCommandDispatch:
def test_no_args_lists(self): async def test_no_args_lists(self):
from EvoScientist.commands.implementation.mcp import MCPCommand from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx() ctx, ui = _ctx()
with patch("EvoScientist.mcp.load_mcp_config", return_value={}): 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] 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) 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 from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx() ctx, ui = _ctx()
@@ -31,10 +29,10 @@ class TestMCPCommandDispatch:
"srv1": {"transport": "stdio", "tools": ["foo"], "expose_to": ["main"]}, "srv1": {"transport": "stdio", "tools": ["foo"], "expose_to": ["main"]},
} }
with patch("EvoScientist.mcp.load_mcp_config", return_value=cfg): 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() 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 from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, _ui = _ctx() ctx, _ui = _ctx()
@@ -48,10 +46,10 @@ class TestMCPCommandDispatch:
return_value={"transport": "stdio"}, return_value={"transport": "stdio"},
) as add_mock, ) as add_mock,
): ):
_run(MCPCommand().execute(ctx, ["add", "srv1", "python"])) await MCPCommand().execute(ctx, ["add", "srv1", "python"])
add_mock.assert_called_once() 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 from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, _ui = _ctx() ctx, _ui = _ctx()
@@ -64,28 +62,28 @@ class TestMCPCommandDispatch:
"EvoScientist.mcp.edit_mcp_server", "EvoScientist.mcp.edit_mcp_server",
) as edit_mock, ) 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"]) 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 from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx() ctx, ui = _ctx()
with patch("EvoScientist.mcp.remove_mcp_server", return_value=True): 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] 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) 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 from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx() ctx, ui = _ctx()
with patch("EvoScientist.mcp.remove_mcp_server", return_value=False): 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Server not found" in m for m in msgs) 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.""" """/mcp install should instantiate InstallMCPCommand and execute it."""
from EvoScientist.commands.implementation.mcp import MCPCommand from EvoScientist.commands.implementation.mcp import MCPCommand
@@ -101,13 +99,13 @@ class TestMCPCommandDispatch:
instance.execute = fake_execute instance.execute = fake_execute
klass.return_value = instance klass.return_value = instance
_run(MCPCommand().execute(ctx, ["install", "foo"])) await MCPCommand().execute(ctx, ["install", "foo"])
klass.assert_called_once() 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 from EvoScientist.commands.implementation.mcp import MCPCommand
ctx, ui = _ctx() 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("MCP commands:" in m for m in msgs) 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 import pytest
from tests.conftest import run_async as _run
class TestExtractModelAndProvider: class TestExtractModelAndProvider:
"""Unit tests for the argument parser helper.""" """Unit tests for the argument parser helper."""
@@ -80,7 +78,7 @@ class TestExtractModelAndProvider:
class TestModelCommandUnknownModel: class TestModelCommandUnknownModel:
"""Verify error message for unknown models.""" """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 from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand() cmd = ModelCommand()
@@ -95,7 +93,7 @@ class TestModelCommandUnknownModel:
"EvoScientist.EvoScientist._ensure_config", "EvoScientist.EvoScientist._ensure_config",
return_value=cfg, return_value=cfg,
): ):
_run(cmd.execute(ctx, ["nonexistent-model-xyz"])) await cmd.execute(ctx, ["nonexistent-model-xyz"])
ui.append_system.assert_called_once() ui.append_system.assert_called_once()
call_args = ui.append_system.call_args call_args = ui.append_system.call_args
@@ -106,7 +104,7 @@ class TestModelCommandUnknownModel:
class TestModelCommandPickerCancelled: class TestModelCommandPickerCancelled:
"""Verify no-op when the interactive picker is cancelled.""" """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 from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand() cmd = ModelCommand()
@@ -122,7 +120,7 @@ class TestModelCommandPickerCancelled:
"EvoScientist.EvoScientist._ensure_config", "EvoScientist.EvoScientist._ensure_config",
return_value=cfg, return_value=cfg,
): ):
_run(cmd.execute(ctx, [])) await cmd.execute(ctx, [])
# No model switch should have happened # No model switch should have happened
ui.append_system.assert_not_called() ui.append_system.assert_not_called()
@@ -131,7 +129,7 @@ class TestModelCommandPickerCancelled:
class TestModelCommandSwitch: class TestModelCommandSwitch:
"""Verify a successful model switch updates config and rebuilds agent.""" """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 from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand() cmd = ModelCommand()
@@ -158,7 +156,7 @@ class TestModelCommandSwitch:
return_value=new_agent, 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 # The switch is committed via set_active_config(temp_cfg), not by
# mutating the original cfg object in place. # mutating the original cfg object in place.
@@ -176,7 +174,7 @@ class TestModelCommandSwitch:
assert "claude-opus-4-8" in msg assert "claude-opus-4-8" in msg
assert "anthropic" 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 from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand() cmd = ModelCommand()
@@ -203,7 +201,7 @@ class TestModelCommandSwitch:
), ),
patch("EvoScientist.config.settings.set_config_value") as mock_save, 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 # Config file should be updated
mock_save.assert_any_call("model", "claude-opus-4-8") mock_save.assert_any_call("model", "claude-opus-4-8")
@@ -213,7 +211,7 @@ class TestModelCommandSwitch:
msg = ui.append_system.call_args[0][0] msg = ui.append_system.call_args[0][0]
assert "saved to config" in msg 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 from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand() cmd = ModelCommand()
@@ -240,7 +238,7 @@ class TestModelCommandSwitch:
), ),
patch("EvoScientist.config.settings.set_config_value") as mock_save, 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 # Config file should NOT be updated
mock_save.assert_not_called() mock_save.assert_not_called()
@@ -253,7 +251,7 @@ class TestModelCommandSwitch:
class TestModelCommandFailure: class TestModelCommandFailure:
"""Verify error handling when chat-model construction raises.""" """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 from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand() cmd = ModelCommand()
@@ -276,7 +274,7 @@ class TestModelCommandFailure:
side_effect=RuntimeError("API key missing"), side_effect=RuntimeError("API key missing"),
) as mock_build, ) as mock_build,
): ):
_run(cmd.execute(ctx, ["claude-opus-4-8"])) await cmd.execute(ctx, ["claude-opus-4-8"])
mock_build.assert_called_once() mock_build.assert_called_once()
ui.append_system.assert_called_once() ui.append_system.assert_called_once()
@@ -446,7 +444,7 @@ class TestApplyModelIntegration:
pair so we can assert on identity. 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.commands.implementation.model import ModelCommand
from EvoScientist.config.settings import EvoScientistConfig from EvoScientist.config.settings import EvoScientistConfig
@@ -502,7 +500,7 @@ class TestApplyModelIntegration:
), ),
): ):
cmd = ModelCommand() 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 # The agent produced by _apply_model must be bound to the
# NEWLY requested model, threaded in via chat_model=. # 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). 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.commands.implementation.model import ModelCommand
from EvoScientist.config.settings import EvoScientistConfig from EvoScientist.config.settings import EvoScientistConfig
@@ -592,7 +592,7 @@ class TestApplyModelPreservesConfigByReference:
("minimax-m2.7", "openrouter"), ("minimax-m2.7", "openrouter"),
("claude-sonnet-4-6", "anthropic"), ("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 # The held reference must reflect the LATEST switch on every
# iteration — not just the first — and stay the active config. # iteration — not just the first — and stay the active config.
assert agent_holder["config"].model == model assert agent_holder["config"].model == model
@@ -610,7 +610,7 @@ class TestModelCommandLoadAgentFailure:
the ordering could silently regress (e.g. if ``_apply_model`` were the ordering could silently regress (e.g. if ``_apply_model`` were
reordered to call ``set_chat_model`` first).""" 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 from EvoScientist.commands.implementation.model import ModelCommand
cmd = ModelCommand() cmd = ModelCommand()
@@ -646,7 +646,7 @@ class TestModelCommandLoadAgentFailure:
# Pass ``--save`` to strengthen the assertion: if the ordering # Pass ``--save`` to strengthen the assertion: if the ordering
# ever regresses, ``set_config_value`` would be called with # ever regresses, ``set_config_value`` would be called with
# stale data. # 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). # _load_agent was attempted (transactional first step).
mock_load.assert_called_once() mock_load.assert_called_once()
@@ -677,7 +677,7 @@ class TestApplyModelLoadAgentFailureTransactional:
downstream setters never run on failure. 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.commands.implementation.model import ModelCommand
from EvoScientist.config.settings import EvoScientistConfig from EvoScientist.config.settings import EvoScientistConfig
@@ -721,7 +721,7 @@ class TestApplyModelLoadAgentFailureTransactional:
), ),
): ):
cmd = ModelCommand() 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. # All four globals are unchanged — nothing was committed.
assert mod._config is cfg assert mod._config is cfg
@@ -753,7 +753,7 @@ class TestModelCommandOllamaPicker:
ctx.ui = ui ctx.ui = ui
return ctx, cfg, 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 """When Ollama is reachable, detected models appear in entries with
provider='ollama' and the Custom sentinel is appended.""" provider='ollama' and the Custom sentinel is appended."""
from EvoScientist.commands.implementation.model import ModelCommand from EvoScientist.commands.implementation.model import ModelCommand
@@ -773,7 +773,7 @@ class TestModelCommandOllamaPicker:
side_effect=fake_discover, side_effect=fake_discover,
), ),
): ):
_run(ModelCommand().execute(ctx, [])) await ModelCommand().execute(ctx, [])
entries = ui.wait_for_model_pick.call_args[0][0] entries = ui.wait_for_model_pick.call_args[0][0]
ollama_rows = [(n, mid, p) for (n, mid, p) in entries if p == "ollama"] ollama_rows = [(n, mid, p) for (n, mid, p) in entries if p == "ollama"]
@@ -785,7 +785,7 @@ class TestModelCommandOllamaPicker:
"ollama", "ollama",
) in ollama_rows ) 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 """Daemon unreachable / no models pulled — sentinel is the user's
escape hatch and must always be present.""" escape hatch and must always be present."""
from EvoScientist.commands.implementation.model import ModelCommand from EvoScientist.commands.implementation.model import ModelCommand
@@ -805,7 +805,7 @@ class TestModelCommandOllamaPicker:
side_effect=fake_discover, side_effect=fake_discover,
), ),
): ):
_run(ModelCommand().execute(ctx, [])) await ModelCommand().execute(ctx, [])
entries = ui.wait_for_model_pick.call_args[0][0] entries = ui.wait_for_model_pick.call_args[0][0]
ollama_rows = [(n, mid, p) for (n, mid, p) in entries if p == "ollama"] 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") ("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, """ollama_base_url unset → no discovery call, no ollama entries,
no sentinel (issue non-goal: no implicit localhost detection).""" no sentinel (issue non-goal: no implicit localhost detection)."""
from EvoScientist.commands.implementation.model import ModelCommand from EvoScientist.commands.implementation.model import ModelCommand
@@ -832,13 +832,13 @@ class TestModelCommandOllamaPicker:
discovery, discovery,
), ),
): ):
_run(ModelCommand().execute(ctx, [])) await ModelCommand().execute(ctx, [])
discovery.assert_not_called() discovery.assert_not_called()
entries = ui.wait_for_model_pick.call_args[0][0] entries = ui.wait_for_model_pick.call_args[0][0]
assert not any(p == "ollama" for (_, _, p) in entries) 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 """getattr(cfg, 'ollama_base_url', None) fallback: old configs
(or SimpleNamespace test fixtures) may not carry the attribute (or SimpleNamespace test fixtures) may not carry the attribute
at all. Must not raise AttributeError, must not probe.""" at all. Must not raise AttributeError, must not probe."""
@@ -864,13 +864,13 @@ class TestModelCommandOllamaPicker:
discovery, discovery,
), ),
): ):
_run(ModelCommand().execute(ctx, [])) await ModelCommand().execute(ctx, [])
discovery.assert_not_called() discovery.assert_not_called()
entries = ui.wait_for_model_pick.call_args[0][0] entries = ui.wait_for_model_pick.call_args[0][0]
assert not any(p == "ollama" for (_, _, p) in entries) 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 """Defense-in-depth: if the widget ever returns the sentinel name
itself (shouldn't happen — it should substitute the typed name), itself (shouldn't happen — it should substitute the typed name),
dispatch treats it as a cancel and does NOT call _apply_model.""" 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, patch("EvoScientist.cli.agent._load_agent") as load_agent,
): ):
_run(ModelCommand().execute(ctx, [])) await ModelCommand().execute(ctx, [])
load_agent.assert_not_called() load_agent.assert_not_called()
assert cfg.model == "claude-sonnet-4-6" # unchanged 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 """User picks a live-detected Ollama model → _apply_model is invoked
with (name, "ollama") and the agent is rebuilt.""" with (name, "ollama") and the agent is rebuilt."""
from EvoScientist.commands.implementation.model import ModelCommand from EvoScientist.commands.implementation.model import ModelCommand
@@ -928,7 +928,7 @@ class TestModelCommandOllamaPicker:
return_value=MagicMock(), return_value=MagicMock(),
), ),
): ):
_run(ModelCommand().execute(ctx, [])) await ModelCommand().execute(ctx, [])
# Committed via set_active_config(temp_cfg); original cfg untouched. # Committed via set_active_config(temp_cfg); original cfg untouched.
set_cfg.assert_called_once() set_cfg.assert_called_once()
+22 -27
View File
@@ -20,7 +20,6 @@ from EvoScientist.middleware.model_fallback import (
clear_fallbacks, clear_fallbacks,
set_ui_emit, set_ui_emit,
) )
from tests.conftest import run_async as _run
# ── Helpers ────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────
@@ -146,7 +145,7 @@ class TestIsNonFallbackable:
class TestTryFallbacks: class TestTryFallbacks:
"""End-to-end tests for the fallback chain traversal.""" """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.""" """When the first fallback model works, return its response."""
add_fallback("fb-model", "fb-provider") add_fallback("fb-model", "fb-provider")
req = _fake_request() req = _fake_request()
@@ -154,13 +153,13 @@ class TestTryFallbacks:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm: with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock() 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 result is AI_RESPONSE
invoke.assert_awaited_once() invoke.assert_awaited_once()
mock_gcm.assert_called_once_with(model="fb-model", provider="fb-provider") 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.""" """When the first fallback fails, try the second."""
add_fallback("fb-bad", "prov-a") add_fallback("fb-bad", "prov-a")
add_fallback("fb-good", "prov-b") add_fallback("fb-good", "prov-b")
@@ -177,12 +176,12 @@ class TestTryFallbacks:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm: with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock() 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 result is AI_RESPONSE
assert call_count == 2 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.""" """When every fallback fails, re-raise the last exception."""
add_fallback("fb-a", "prov-a") add_fallback("fb-a", "prov-a")
add_fallback("fb-b", "prov-b") add_fallback("fb-b", "prov-b")
@@ -202,11 +201,11 @@ class TestTryFallbacks:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm: with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock() mock_gcm.return_value = MagicMock()
with pytest.raises(Exception, match="429 from fb-b") as exc_info: 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 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.""" """A non-fallbackable error from a fallback model aborts the chain."""
add_fallback("fb-a", "prov-a") add_fallback("fb-a", "prov-a")
add_fallback("fb-b", "prov-b") # should never be reached 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: with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock() mock_gcm.return_value = MagicMock()
with pytest.raises(Exception, match="context_length_exceeded"): 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), # get_chat_model should only have been called once (for fb-a),
# fb-b should never be reached. # fb-b should never be reached.
@@ -233,43 +232,41 @@ class TestTryFallbacks:
class TestGuardAndFallback: class TestGuardAndFallback:
"""Verify that non-fallbackable errors are re-raised before trying the chain.""" """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") add_fallback("fb", "prov")
req = _fake_request() req = _fake_request()
invoke = AsyncMock() invoke = AsyncMock()
with pytest.raises(ContextOverflowError): with pytest.raises(ContextOverflowError):
_run(_guard_and_fallback(ContextOverflowError("overflow"), req, invoke)) await _guard_and_fallback(ContextOverflowError("overflow"), req, invoke)
invoke.assert_not_awaited() invoke.assert_not_awaited()
def test_malformed_400_raises_immediately(self): async def test_malformed_400_raises_immediately(self):
add_fallback("fb", "prov") add_fallback("fb", "prov")
req = _fake_request() req = _fake_request()
invoke = AsyncMock() invoke = AsyncMock()
with pytest.raises(Exception, match="invalid_request_error"): with pytest.raises(Exception, match="invalid_request_error"):
_run( await _guard_and_fallback(
_guard_and_fallback( Exception("400: invalid_request_error"), req, invoke
Exception("400: invalid_request_error"), req, invoke
)
) )
invoke.assert_not_awaited() 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") add_fallback("fb", "prov")
req = _fake_request() req = _fake_request()
invoke = AsyncMock(return_value=AI_RESPONSE) invoke = AsyncMock(return_value=AI_RESPONSE)
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm: with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock() 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 assert result is AI_RESPONSE
invoke.assert_awaited_once() 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).""" """Auth errors should try the fallback chain (different provider)."""
add_fallback("fb", "other-prov") add_fallback("fb", "other-prov")
req = _fake_request() req = _fake_request()
@@ -277,10 +274,8 @@ class TestGuardAndFallback:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm: with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock() mock_gcm.return_value = MagicMock()
result = _run( result = await _guard_and_fallback(
_guard_and_fallback( Exception("400 Bad Request: invalid_api_key"), req, invoke
Exception("400 Bad Request: invalid_api_key"), req, invoke
)
) )
assert result is AI_RESPONSE assert result is AI_RESPONSE
@@ -295,7 +290,7 @@ class TestGuardAndFallback:
class TestUiEmit: class TestUiEmit:
"""Verify that fallback events are surfaced via the registered callback.""" """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") add_fallback("fb", "prov")
req = _fake_request() req = _fake_request()
invoke = AsyncMock(return_value=AI_RESPONSE) invoke = AsyncMock(return_value=AI_RESPONSE)
@@ -305,14 +300,14 @@ class TestUiEmit:
with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm: with patch("EvoScientist.llm.models.get_chat_model") as mock_gcm:
mock_gcm.return_value = MagicMock() 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] texts = [t for t, _ in messages]
assert any("Primary model failed" in t for t in texts) 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("Falling back to fb (prov)" in t for t in texts)
assert any("succeeded" 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") add_fallback("fb", "prov")
req = _fake_request() req = _fake_request()
invoke = AsyncMock() invoke = AsyncMock()
@@ -321,7 +316,7 @@ class TestUiEmit:
set_ui_emit(lambda text, style: messages.append((text, style))) set_ui_emit(lambda text, style: messages.append((text, style)))
with pytest.raises(ContextOverflowError): 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] texts = [t for t, _ in messages]
assert any("not eligible for fallback" in t for t in texts) 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 import pytest
from EvoScientist.llm import patches as patches_mod from EvoScientist.llm import patches as patches_mod
from tests.conftest import run_async as _run
# ============================================================================= # =============================================================================
# Helpers # Helpers
@@ -152,7 +151,7 @@ class TestStartAsyncTaskInjection:
"configurable": {"model": "gpt-5", "model_provider": "openai"} "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: try:
from deepagents.middleware import async_subagents as ds_mod from deepagents.middleware import async_subagents as ds_mod
except ImportError: except ImportError:
@@ -176,12 +175,10 @@ class TestStartAsyncTaskInjection:
"EvoScientist.EvoScientist._ensure_config", "EvoScientist.EvoScientist._ensure_config",
return_value=_stub_cfg(model="claude-haiku-4-5", provider="anthropic"), return_value=_stub_cfg(model="claude-haiku-4-5", provider="anthropic"),
): ):
_run( await tool.coroutine(
tool.coroutine( description="hi",
description="hi", subagent_type="writing-agent",
subagent_type="writing-agent", runtime=_runtime_stub(),
runtime=_runtime_stub(),
)
) )
runs_async.create.assert_awaited_once() runs_async.create.assert_awaited_once()
@@ -267,7 +264,7 @@ class TestUpdateAsyncTaskInjection:
"last_updated_at": "2026-05-07T00:00:00Z", "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.""" """The async coroutine path must inject config too."""
try: try:
from deepagents.middleware import async_subagents as ds_mod from deepagents.middleware import async_subagents as ds_mod
@@ -296,12 +293,10 @@ class TestUpdateAsyncTaskInjection:
"EvoScientist.EvoScientist._ensure_config", "EvoScientist.EvoScientist._ensure_config",
return_value=_stub_cfg(model="gpt-5", provider="openai"), return_value=_stub_cfg(model="gpt-5", provider="openai"),
): ):
_run( await tool.coroutine(
tool.coroutine( task_id="thread-001",
task_id="thread-001", message="follow up async",
message="follow up async", runtime=runtime,
runtime=runtime,
)
) )
runs_async.create.assert_awaited_once() runs_async.create.assert_awaited_once()
+4 -6
View File
@@ -2,11 +2,9 @@
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from tests.conftest import run_async as _run
class TestNewCommand: 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.base import CommandContext
from EvoScientist.commands.implementation.session import NewCommand from EvoScientist.commands.implementation.session import NewCommand
@@ -18,7 +16,7 @@ class TestNewCommand:
ui=ui, ui=ui,
workspace_dir="/old/ws", workspace_dir="/old/ws",
) )
_run(NewCommand().execute(ctx, [])) await NewCommand().execute(ctx, [])
ui.start_new_session.assert_awaited_once() ui.start_new_session.assert_awaited_once()
def test_requires_agent_false(self): def test_requires_agent_false(self):
@@ -26,7 +24,7 @@ class TestNewCommand:
assert NewCommand().requires_agent is False 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).""" """Command body must not touch ctx.agent (it's still loading)."""
from EvoScientist.commands.base import CommandContext from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.session import NewCommand from EvoScientist.commands.implementation.session import NewCommand
@@ -35,4 +33,4 @@ class TestNewCommand:
ui.start_new_session = AsyncMock() ui.start_new_session = AsyncMock()
ctx = CommandContext(agent=None, thread_id="tid", ui=ui) ctx = CommandContext(agent=None, thread_id="tid", ui=ui)
# No AttributeError even though ctx.agent is None # 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( async def test_lifecycle_schedules_turn_worker_without_awaiting(tmp_path, monkeypatch):
tmp_path, monkeypatch, run_async
):
memory_dir = tmp_path / "memories" memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace" workspace_dir = tmp_path / "workspace"
calls = [] calls = []
@@ -1682,21 +1680,18 @@ def test_lifecycle_schedules_turn_worker_without_awaiting(
) )
runtime = _runtime("thread-1") runtime = _runtime("thread-1")
async def run(): state: AgentState[object] = {
state: AgentState[object] = { "messages": [
"messages": [ HumanMessage("previous turn"),
HumanMessage("previous turn"), AIMessage("previous answer"),
AIMessage("previous answer"), HumanMessage("hi"),
HumanMessage("hi"), AIMessage("done"),
AIMessage("done"), ]
] }
} await middleware.aafter_agent(
await middleware.aafter_agent( state,
state, runtime,
runtime, )
)
run_async(run())
assert len(calls) == 1 assert len(calls) == 1
request, hooks = calls[0] request, hooks = calls[0]
@@ -2182,10 +2177,9 @@ def test_observation_linker_does_not_launch_when_observations_disabled(
launch_call.assert_not_called() 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, tmp_path,
monkeypatch, monkeypatch,
run_async,
): ):
context = _linker_context( context = _linker_context(
memory_dir=tmp_path / "memories", memory_dir=tmp_path / "memories",
@@ -2200,7 +2194,7 @@ def test_async_observation_linker_does_not_launch_when_observations_disabled(
launch_call = MagicMock() launch_call = MagicMock()
monkeypatch.setattr(memory_launch, "alaunch_background_run", launch_call) 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 assert run is None
launch_call.assert_not_called() 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 assert status.observations_recorded == 0
def test_async_memory_worker_watcher_untracks_without_counting_on_poll_abort( async def test_async_memory_worker_watcher_untracks_without_counting_on_poll_abort(
tmp_path, monkeypatch, run_async tmp_path, monkeypatch
): ):
memory_dir = tmp_path / "memories" memory_dir = tmp_path / "memories"
_mark_worker_started(memory_dir) _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): async def get(self, **_kwargs):
raise RuntimeError("poll failed") raise RuntimeError("poll failed")
run_async( await background_runs.awatch_background_run(
background_runs.awatch_background_run( SimpleNamespace(runs=_Runs()),
SimpleNamespace(runs=_Runs()), thread_id="worker-thread",
thread_id="worker-thread", run_id="run-1",
run_id="run-1", hooks=memory_launch._memory_worker_launch_hooks(memory_dir),
hooks=memory_launch._memory_worker_launch_hooks(memory_dir), watcher_config=_fast_watcher_config(max_poll_failures=1),
watcher_config=_fast_watcher_config(max_poll_failures=1),
)
) )
status = worker_activity.memory_worker_status() status = worker_activity.memory_worker_status()
assert status.is_running is False 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 assert status.observations_recorded == 0
def test_async_memory_worker_watcher_counts_completion_under_blockbuster( async def test_async_memory_worker_watcher_counts_completion_under_blockbuster(
tmp_path, run_async tmp_path,
): ):
memory_dir = tmp_path / "memories" memory_dir = tmp_path / "memories"
_mark_worker_started(memory_dir) _mark_worker_started(memory_dir)
@@ -2376,20 +2368,17 @@ def test_async_memory_worker_watcher_counts_completion_under_blockbuster(
async def get(self, **_kwargs): async def get(self, **_kwargs):
return {"status": "success"} return {"status": "success"}
async def run(): blocker = BlockBuster(scanned_modules=[memory_worker, worker_activity])
blocker = BlockBuster(scanned_modules=[memory_worker, worker_activity]) blocker.activate()
blocker.activate() try:
try: await background_runs.awatch_background_run(
await background_runs.awatch_background_run( SimpleNamespace(runs=_Runs()),
SimpleNamespace(runs=_Runs()), thread_id="worker-thread",
thread_id="worker-thread", run_id="run-1",
run_id="run-1", hooks=memory_launch._memory_worker_launch_hooks(memory_dir),
hooks=memory_launch._memory_worker_launch_hooks(memory_dir), )
) finally:
finally: blocker.deactivate()
blocker.deactivate()
run_async(run())
status = worker_activity.memory_worker_status() status = worker_activity.memory_worker_status()
assert status.is_running is False assert status.is_running is False
assert status.profile_updates == 1 assert status.profile_updates == 1
@@ -2527,7 +2516,7 @@ def test_memory_worker_marks_active_status(tmp_path, monkeypatch):
assert status.observations_recorded == 1 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( monkeypatch.setattr(
background_runs, "default_background_run_url", lambda: "http://x" 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] = [] spawned: list[background_runs.BackgroundRun] = []
async def run(): event_loop_thread = threading.get_ident()
event_loop_thread = threading.get_ident() context = _memory_source_context(
context = _memory_source_context( memory_dir=tmp_path / "memories",
memory_dir=tmp_path / "memories", workspace_dir=tmp_path / "workspace",
workspace_dir=tmp_path / "workspace", trajectory=[{"role": "human", "content": "hi"}],
trajectory=[{"role": "human", "content": "hi"}], )
) request = memory_launch.memory_worker_launch_request(context)
request = memory_launch.memory_worker_launch_request(context) await background_runs.alaunch_background_run(
await background_runs.alaunch_background_run( request,
request, hooks=memory_launch._memory_worker_launch_hooks(tmp_path / "memories"),
hooks=memory_launch._memory_worker_launch_hooks(tmp_path / "memories"), spawn_status_watcher=spawned.append,
spawn_status_watcher=spawned.append, )
)
return event_loop_thread
event_loop_thread = run_async(run())
assert [name for name, _thread_id in call_threads] == ["health", "snapshot"] assert [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 all(thread_id != event_loop_thread for _name, thread_id in call_threads)
assert worker_activity.memory_worker_status().is_running is True 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, discover_ollama_models,
validate_ollama_connection, validate_ollama_connection,
) )
from tests.conftest import run_async as _run
class TestValidateOllamaConnection: class TestValidateOllamaConnection:
@@ -71,17 +70,17 @@ class TestValidateOllamaConnection:
class TestDiscoverOllamaModels: class TestDiscoverOllamaModels:
"""Async probe — contract: never raise, return list[str].""" """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 # 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. # the fact that no mock is set up and the test completes.
names = _run(discover_ollama_models("")) names = await discover_ollama_models("")
assert names == [] assert names == []
def test_none_base_url_returns_empty(self): async def test_none_base_url_returns_empty(self):
names = _run(discover_ollama_models(None)) names = await discover_ollama_models(None)
assert names == [] assert names == []
def test_200_returns_names(self): async def test_200_returns_names(self):
async def fake_get(self, url): async def fake_get(self, url):
resp = MagicMock() resp = MagicMock()
resp.status_code = 200 resp.status_code = 200
@@ -93,10 +92,10 @@ class TestDiscoverOllamaModels:
return resp return resp
with patch.object(httpx.AsyncClient, "get", fake_get): 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"] 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): async def fake_get(self, url):
resp = MagicMock() resp = MagicMock()
resp.status_code = 200 resp.status_code = 200
@@ -112,36 +111,36 @@ class TestDiscoverOllamaModels:
return resp return resp
with patch.object(httpx.AsyncClient, "get", fake_get): 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"] assert names == ["llama3.3"]
def test_timeout_returns_empty(self): async def test_timeout_returns_empty(self):
async def fake_get(self, url): async def fake_get(self, url):
raise httpx.TimeoutException("timed out") raise httpx.TimeoutException("timed out")
with patch.object(httpx.AsyncClient, "get", fake_get): 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 == [] assert names == []
def test_connect_error_returns_empty(self): async def test_connect_error_returns_empty(self):
async def fake_get(self, url): async def fake_get(self, url):
raise httpx.ConnectError("refused") raise httpx.ConnectError("refused")
with patch.object(httpx.AsyncClient, "get", fake_get): 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 == [] assert names == []
def test_non_200_returns_empty(self): async def test_non_200_returns_empty(self):
async def fake_get(self, url): async def fake_get(self, url):
resp = MagicMock() resp = MagicMock()
resp.status_code = 500 resp.status_code = 500
return resp return resp
with patch.object(httpx.AsyncClient, "get", fake_get): 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 == [] assert names == []
def test_malformed_json_returns_empty(self): async def test_malformed_json_returns_empty(self):
async def fake_get(self, url): async def fake_get(self, url):
resp = MagicMock() resp = MagicMock()
resp.status_code = 200 resp.status_code = 200
@@ -149,10 +148,10 @@ class TestDiscoverOllamaModels:
return resp return resp
with patch.object(httpx.AsyncClient, "get", fake_get): 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 == [] 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): async def fake_get(self, url):
resp = MagicMock() resp = MagicMock()
resp.status_code = 200 resp.status_code = 200
@@ -160,10 +159,10 @@ class TestDiscoverOllamaModels:
return resp return resp
with patch.object(httpx.AsyncClient, "get", fake_get): 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 == [] assert names == []
def test_trailing_slash_stripped_from_url(self): async def test_trailing_slash_stripped_from_url(self):
called = {} called = {}
async def fake_get(self, url): async def fake_get(self, url):
@@ -174,7 +173,7 @@ class TestDiscoverOllamaModels:
return resp return resp
with patch.object(httpx.AsyncClient, "get", fake_get): 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" 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 # Test render_progress
# ============================================================================= # =============================================================================
@@ -386,6 +418,109 @@ class TestStepProvider:
_step_provider(config) _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: class TestStepModel:
def test_returns_selected_model(self): def test_returns_selected_model(self):
"""Test that _step_model returns selected model.""" """Test that _step_model returns selected model."""
@@ -1266,6 +1401,7 @@ class TestRunOnboard:
"claude-sonnet-4-6", # Model "claude-sonnet-4-6", # Model
"assemble", # Auxiliary: Assemble "assemble", # Auxiliary: Assemble
"openai", # Auxiliary provider (a different company) "openai", # Auxiliary provider (a different company)
"api_key", # Auxiliary OpenAI auth mode
"gpt-5.5", # Auxiliary model "gpt-5.5", # Auxiliary model
"daemon", # Workspace mode "daemon", # Workspace mode
True, # Show thinking True, # Show thinking
@@ -1289,12 +1425,185 @@ class TestRunOnboard:
final_config = mock_save.call_args_list[-1].args[0] final_config = mock_save.call_args_list[-1].args[0]
assert final_config.auxiliary_provider == "openai" assert final_config.auxiliary_provider == "openai"
assert final_config.auxiliary_model == "gpt-5.5" 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. # The auxiliary provider's key is stored in its per-provider field.
assert final_config.openai_api_key == "sk-aux-openai" assert final_config.openai_api_key == "sk-aux-openai"
# Main agent is untouched. # Main agent is untouched.
assert final_config.provider == "anthropic" assert final_config.provider == "anthropic"
assert final_config.model == "claude-sonnet-4-6" 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): def test_auxiliary_custom_provider_collects_base_url(self):
"""Regression for the custom-provider fix: a custom auxiliary provider """Regression for the custom-provider fix: a custom auxiliary provider
collects its base URL (provider -> base URL -> key -> model order).""" collects its base URL (provider -> base URL -> key -> model order)."""
+4 -6
View File
@@ -79,14 +79,13 @@ class TestPickSkillsInteractive:
class TestInstallSkillsHandlesEmpty: class TestInstallSkillsHandlesEmpty:
"""InstallSkills.execute must distinguish None vs [] from the picker.""" """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" """When picker returns [], user should NOT see "Browse cancelled"
(the picker already printed its own message).""" (the picker already printed its own message)."""
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from EvoScientist.commands.base import CommandContext from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.skills import InstallSkills from EvoScientist.commands.implementation.skills import InstallSkills
from tests.conftest import run_async as _run
ui = MagicMock() ui = MagicMock()
ui.supports_interactive = True ui.supports_interactive = True
@@ -97,18 +96,17 @@ class TestInstallSkillsHandlesEmpty:
"EvoScientist.tools.skills_manager.fetch_remote_skill_index", "EvoScientist.tools.skills_manager.fetch_remote_skill_index",
return_value=_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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert not any("Browse cancelled" in m for m in msgs) 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.""" """When picker returns None (actual cancel), user sees the message."""
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from EvoScientist.commands.base import CommandContext from EvoScientist.commands.base import CommandContext
from EvoScientist.commands.implementation.skills import InstallSkills from EvoScientist.commands.implementation.skills import InstallSkills
from tests.conftest import run_async as _run
ui = MagicMock() ui = MagicMock()
ui.supports_interactive = True ui.supports_interactive = True
@@ -119,7 +117,7 @@ class TestInstallSkillsHandlesEmpty:
"EvoScientist.tools.skills_manager.fetch_remote_skill_index", "EvoScientist.tools.skills_manager.fetch_remote_skill_index",
return_value=_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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Browse cancelled" in m for m in msgs) 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( async def test_profile_memory_async_path_bootstraps_and_injects(tmp_path, monkeypatch):
tmp_path, monkeypatch, run_async
):
memories = tmp_path / "memories" memories = tmp_path / "memories"
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
workspace.mkdir() workspace.mkdir()
@@ -356,7 +354,7 @@ def test_profile_memory_async_path_bootstraps_and_injects(
return request return request
middleware = memory_module.create_memory_middleware(str(memories)) 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() 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 assert soul_path.read_bytes() == original_bytes
def test_profile_memory_async_path_inlines_content_under_blockbuster( async def test_profile_memory_async_path_inlines_content_under_blockbuster(
tmp_path, monkeypatch, run_async tmp_path, monkeypatch
): ):
memories = tmp_path / "memories" memories = tmp_path / "memories"
workspace = tmp_path / "workspace" 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) monkeypatch.setattr(middleware, "_read_profile_memory", tracked_read_profile_memory)
async def run(): event_loop_thread = threading.get_ident()
event_loop_thread = threading.get_ident() blocker = BlockBuster(scanned_modules=memory_module)
blocker = BlockBuster(scanned_modules=memory_module) blocker.activate()
blocker.activate() try:
try: modified = await middleware.amodify_request(_request())
modified = await middleware.amodify_request(_request()) finally:
finally: blocker.deactivate()
blocker.deactivate()
return event_loop_thread, modified
event_loop_thread, modified = run_async(run())
assert call_threads assert call_threads
assert all(thread_id != event_loop_thread for thread_id in 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() ).exists()
def test_profile_memory_resolves_project_id_once_per_middleware( async def test_profile_memory_resolves_project_id_once_per_middleware(
tmp_path, monkeypatch, run_async tmp_path, monkeypatch
): ):
memories = tmp_path / "memories" memories = tmp_path / "memories"
workspace = tmp_path / "workspace" 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 str(memories), workspace_dir=str(workspace), max_inline_profile_chars=10
) )
middleware.modify_request(_request()) middleware.modify_request(_request())
run_async(middleware.amodify_request(_request())) await middleware.amodify_request(_request())
assert calls == [workspace] assert calls == [workspace]
assert middleware.project_id == "P-cached-project" assert middleware.project_id == "P-cached-project"
+35 -38
View File
@@ -8,7 +8,6 @@ from EvoScientist.channels.qq.channel import (
QQConfig, QQConfig,
_build_qq_keyboard, _build_qq_keyboard,
) )
from tests.conftest import run_async as _run
class TestQQChannelSend: class TestQQChannelSend:
@@ -22,7 +21,7 @@ class TestQQChannelSend:
channel._client.api.post_group_message = AsyncMock() channel._client.api.post_group_message = AsyncMock()
return channel 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() channel = self._make_ready_channel()
msg = OutboundMessage( msg = OutboundMessage(
channel="qq", 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() channel._client.api.post_c2c_message.assert_awaited_once()
sent = channel._client.api.post_c2c_message.await_args.kwargs sent = channel._client.api.post_c2c_message.await_args.kwargs
@@ -46,7 +45,7 @@ class TestQQChannelSend:
assert sent["msg_seq"] == 1 assert sent["msg_seq"] == 1
assert "content" not in sent 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 = self._make_ready_channel()
channel._trace_event = MagicMock(side_effect=RuntimeError("trace failed")) channel._trace_event = MagicMock(side_effect=RuntimeError("trace failed"))
channel._client.api.post_c2c_message = AsyncMock( 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 assert channel._client.api.post_c2c_message.await_count == 2
first = channel._client.api.post_c2c_message.await_args_list[0].kwargs first = channel._client.api.post_c2c_message.await_args_list[0].kwargs
@@ -80,7 +79,7 @@ class TestQQChannelSend:
# trigger "duplicate msg_seq". # trigger "duplicate msg_seq".
assert second["msg_seq"] == 2 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() channel = self._make_ready_channel()
async def _send_once(coro_factory, max_retries=3): 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() channel._client.api.post_c2c_message.assert_awaited_once()
sent = channel._client.api.post_c2c_message.await_args.kwargs sent = channel._client.api.post_c2c_message.await_args.kwargs
assert sent["msg_type"] == 2 assert sent["msg_type"] == 2
assert "content" not in sent 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 """A transport-layer error whose message incidentally contains the word
"markdown" must NOT be reclassified as a markdown compatibility failure, "markdown" must NOT be reclassified as a markdown compatibility failure,
otherwise genuine send failures get silently swallowed as plain-text.""" 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() 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) """QQ server-side markdown errors (e.g. 304014 template not configured)
should trigger plain-text fallback with a fresh msg_seq.""" should trigger plain-text fallback with a fresh msg_seq."""
channel = self._make_ready_channel() 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 assert channel._client.api.post_c2c_message.await_count == 2
first = channel._client.api.post_c2c_message.await_args_list[0].kwargs 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() channel._client.api.post_group_message = AsyncMock()
return channel return channel
def test_c2c_send_attaches_keyboard(self): async def test_c2c_send_attaches_keyboard(self):
channel = self._make_channel() channel = self._make_channel()
msg = OutboundMessage( msg = OutboundMessage(
channel="qq", 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 sent = channel._client.api.post_c2c_message.await_args.kwargs
assert sent["msg_type"] == 2 assert sent["msg_type"] == 2
@@ -256,7 +255,7 @@ class TestQQSendWithButtons:
assert rows[0]["buttons"][0]["action"]["data"] == "1" assert rows[0]["buttons"][0]["action"]["data"] == "1"
assert rows[1]["buttons"][0]["action"]["data"] == "2" 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.""" """Group keyboards are out of scope — silently dropped."""
channel = self._make_channel() channel = self._make_channel()
msg = OutboundMessage( msg = OutboundMessage(
@@ -270,11 +269,11 @@ class TestQQSendWithButtons:
"buttons": [{"text": "Approve", "value": "1"}], "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 sent = channel._client.api.post_group_message.await_args.kwargs
assert "keyboard" not in sent 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 """If markdown send fails and we fall back to plain text, the
keyboard is lost — append a textual hint so the user still has keyboard is lost — append a textual hint so the user still has
a way to reply (the values still pass `_parse_approval_reply`). 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 plain_call = channel._client.api.post_c2c_message.await_args_list[1].kwargs
assert plain_call["msg_type"] == 0 assert plain_call["msg_type"] == 0
@@ -311,7 +310,7 @@ class TestQQSendWithButtons:
assert "1=Approve" in plain_call["content"] assert "1=Approve" in plain_call["content"]
assert "2=Reject" 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 """Regression: integer/None button values must not crash the
plain-text fallback (the keyboard builder already coerces them).""" plain-text fallback (the keyboard builder already coerces them)."""
channel = self._make_channel() 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 plain_call = channel._client.api.post_c2c_message.await_args_list[1].kwargs
assert "42=OK" in plain_call["content"] assert "42=OK" in plain_call["content"]
assert "Cancel=Cancel" in plain_call["content"] assert "Cancel=Cancel" in plain_call["content"]
@@ -392,9 +391,9 @@ class TestQQInteractionCallback:
) )
return interaction 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() 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() channel._bus.publish_inbound.assert_awaited_once()
inbound = channel._bus.publish_inbound.await_args[0][0] inbound = channel._bus.publish_inbound.await_args[0][0]
@@ -405,63 +404,61 @@ class TestQQInteractionCallback:
assert inbound.metadata["button_value"] == "1" assert inbound.metadata["button_value"] == "1"
assert inbound.metadata["msg_type"] == "c2c" assert inbound.metadata["msg_type"] == "c2c"
def test_click_acks_interaction(self): async def test_click_acks_interaction(self):
channel = self._make_channel() 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) 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).""" """Click never hits queue_message (debounce buffer)."""
channel = self._make_channel() channel = self._make_channel()
channel.queue_message = AsyncMock() 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.queue_message.assert_not_called()
channel._bus.publish_inbound.assert_awaited_once() 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.""" """No user_openid → group/guild click → don't publish."""
channel = self._make_channel() channel = self._make_channel()
intr = self._make_interaction(user_openid="") intr = self._make_interaction(user_openid="")
intr.group_openid = "group_xxx" intr.group_openid = "group_xxx"
_run(channel._on_interaction(intr)) await channel._on_interaction(intr)
channel._bus.publish_inbound.assert_not_called() channel._bus.publish_inbound.assert_not_called()
# ACK still fires — it runs first, before the group-skip return. # ACK still fires — it runs first, before the group-skip return.
channel._client.api.on_interaction_result.assert_awaited_once_with("intr_1", 0) 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 = self._make_channel()
channel._build_inbound_async = AsyncMock(return_value=None) 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() channel._bus.publish_inbound.assert_not_called()
# ACK still fires (we don't want the user staring at a stuck button) # ACK still fires (we don't want the user staring at a stuck button)
channel._client.api.on_interaction_result.assert_awaited_once() 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() channel = self._make_channel()
_run( await channel._on_interaction(
channel._on_interaction( self._make_interaction(button_data="", button_id="btn_3")
self._make_interaction(button_data="", button_id="btn_3")
)
) )
inbound = channel._bus.publish_inbound.await_args[0][0] inbound = channel._bus.publish_inbound.await_args[0][0]
assert inbound.content == "btn_3" 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 """ACK must run before downstream processing so the QQ button UI
stays responsive even if middleware/bus crashes.""" stays responsive even if middleware/bus crashes."""
channel = self._make_channel() channel = self._make_channel()
channel._build_inbound_async = AsyncMock(side_effect=RuntimeError("boom")) channel._build_inbound_async = AsyncMock(side_effect=RuntimeError("boom"))
# Should not raise — handler swallows downstream errors. # 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) 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).""" """Regression: metadata['button_value'] must be a string (was raw)."""
channel = self._make_channel() channel = self._make_channel()
resolved = MagicMock(button_id="btn_0", button_data=42, message_id="msg_orig") resolved = MagicMock(button_id="btn_0", button_data=42, message_id="msg_orig")
data = MagicMock(type=None, resolved=resolved) data = MagicMock(type=None, resolved=resolved)
intr = MagicMock(id="intr_1", user_openid="u_x", group_openid=None, data=data) 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] inbound = channel._bus.publish_inbound.await_args[0][0]
assert inbound.content == "42" assert inbound.content == "42"
assert inbound.metadata["button_value"] == "42" assert inbound.metadata["button_value"] == "42"
+16 -17
View File
@@ -2,7 +2,6 @@
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -24,7 +23,7 @@ def _ctx(thread_id="current", workspace_dir="/ws", thread_store=None):
class TestResumeCommand: 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 from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx( ctx, ui = _ctx(
@@ -33,23 +32,23 @@ class TestResumeCommand:
metadata={"workspace_dir": "/restored"}, 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") ui.handle_session_resume.assert_awaited_once_with("target-tid", "/restored")
# ctx mutations # ctx mutations
assert ctx.thread_id == "target-tid" assert ctx.thread_id == "target-tid"
assert ctx.workspace_dir == "/restored" 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 from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx() ctx, ui = _ctx()
_run(ResumeCommand().execute(ctx, [])) await ResumeCommand().execute(ctx, [])
msgs = [c.args[0] for c in ui.append_system.call_args_list] 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) assert any("No sessions to resume" in m for m in msgs)
ui.wait_for_thread_pick.assert_not_called() ui.wait_for_thread_pick.assert_not_called()
ui.handle_session_resume.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 from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx() ctx, ui = _ctx()
@@ -60,11 +59,11 @@ class TestResumeCommand:
resolved_thread_id="picked-tid", resolved_thread_id="picked-tid",
) )
ctx.graph_gateway = FakeGraphGateway(thread_store=store) ctx.graph_gateway = FakeGraphGateway(thread_store=store)
_run(ResumeCommand().execute(ctx, [])) await ResumeCommand().execute(ctx, [])
ui.wait_for_thread_pick.assert_awaited_once() ui.wait_for_thread_pick.assert_awaited_once()
ui.handle_session_resume.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 from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx() ctx, ui = _ctx()
@@ -72,28 +71,28 @@ class TestResumeCommand:
threads = [{"thread_id": "t1", "preview": "", "message_count": 0}] threads = [{"thread_id": "t1", "preview": "", "message_count": 0}]
store = FakeThreadStore(threads=threads) store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store) ctx.graph_gateway = FakeGraphGateway(thread_store=store)
_run(ResumeCommand().execute(ctx, [])) await ResumeCommand().execute(ctx, [])
ui.handle_session_resume.assert_not_called() 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 from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx(thread_store=FakeThreadStore(matches=["abc-one", "abc-two"])) 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Ambiguous" in m for m in msgs) assert any("Ambiguous" in m for m in msgs)
ui.handle_session_resume.assert_not_called() 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 from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx() 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("not found" in m for m in msgs) assert any("not found" in m for m in msgs)
ui.handle_session_resume.assert_not_called() 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 from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx( ctx, ui = _ctx(
@@ -102,18 +101,18 @@ class TestResumeCommand:
metadata={"workspace_dir": "/ws1"}, 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") ui.handle_session_resume.assert_awaited_once_with("abc-one", "/ws1")
assert ctx.thread_id == "abc-one" 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 from EvoScientist.commands.implementation.session import ResumeCommand
ctx, ui = _ctx( ctx, ui = _ctx(
workspace_dir="/keep", workspace_dir="/keep",
thread_store=FakeThreadStore(resolved_thread_id="tid", metadata={}), 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 # ResumeCommand only overwrites ctx.workspace_dir if metadata has one
assert ctx.workspace_dir == "/keep" assert ctx.workspace_dir == "/keep"
# Callback still fires with the metadata value (empty string) # 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.console import Console
from rich.table import Table from rich.table import Table
from tests.conftest import run_async as _run
def _make_ui(**kwargs): def _make_ui(**kwargs):
"""Build a RichCLICommandUI backed by a MagicMock console.""" """Build a RichCLICommandUI backed by a MagicMock console."""
@@ -40,9 +38,9 @@ class TestBasicIO:
ui.mount_renderable(table) ui.mount_renderable(table)
console.print.assert_called_once_with(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() ui, console = _make_ui()
_run(ui.flush()) await ui.flush()
# flush should not print anything # flush should not print anything
console.print.assert_not_called() console.print.assert_not_called()
@@ -50,33 +48,29 @@ class TestBasicIO:
class TestWaitForModelPick: class TestWaitForModelPick:
"""CLI model picker fallback: print table + return None.""" """CLI model picker fallback: print table + return None."""
def test_returns_none(self): async def test_returns_none(self):
ui, _ = _make_ui() ui, _ = _make_ui()
entries = [ entries = [
("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic"), ("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic"),
("gpt-4o", "openai/gpt-4o", "openai"), ("gpt-4o", "openai/gpt-4o", "openai"),
] ]
result = _run( result = await ui.wait_for_model_pick(
ui.wait_for_model_pick( entries,
entries, current_model="claude-sonnet-4-6",
current_model="claude-sonnet-4-6", current_provider="anthropic",
current_provider="anthropic",
)
) )
assert result is None 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() ui, console = _make_ui()
entries = [ entries = [
("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic"), ("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic"),
("gpt-4o", "openai/gpt-4o", "openai"), ("gpt-4o", "openai/gpt-4o", "openai"),
] ]
_run( await ui.wait_for_model_pick(
ui.wait_for_model_pick( entries,
entries, current_model="claude-sonnet-4-6",
current_model="claude-sonnet-4-6", current_provider="anthropic",
current_provider="anthropic",
)
) )
# First call renders the Table (Rich renderable), second prints usage. # First call renders the Table (Rich renderable), second prints usage.
assert console.print.call_count == 2 assert console.print.call_count == 2
@@ -87,27 +81,23 @@ class TestWaitForModelPick:
assert "Usage: /model" in usage_arg assert "Usage: /model" in usage_arg
assert "--save" 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() ui, console = _make_ui()
entries = [("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic")] entries = [("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic")]
_run( await ui.wait_for_model_pick(
ui.wait_for_model_pick( entries,
entries, current_model=None,
current_model=None, current_provider=None,
current_provider=None,
)
) )
# Just asserts the coroutine runs without marker-branch issues. # Just asserts the coroutine runs without marker-branch issues.
assert console.print.call_count == 2 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() ui, console = _make_ui()
result = _run( result = await ui.wait_for_model_pick(
ui.wait_for_model_pick( [],
[], current_model=None,
current_model=None, current_provider=None,
current_provider=None,
)
) )
assert result is None assert result is None
# Header table + usage hint should still be printed even with # 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 import EvoScientist.cli.rich_command_ui as mod
ui, _ = _make_ui() ui, _ = _make_ui()
@@ -226,7 +216,7 @@ class TestWaitForThreadPick:
return prompt return prompt
monkeypatch.setattr("questionary.select", fake_select) 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 result == "abc123"
assert called["title"] == "pick:" assert called["title"] == "pick:"
# _build_items prepends a workspace header — choices has headers + # _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. # Table import removed; this test no longer depends on console output.
assert mod.RichCLICommandUI is not None # sanity 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() ui, _ = _make_ui()
prompt = self._fake_prompt(None) prompt = self._fake_prompt(None)
monkeypatch.setattr("questionary.select", lambda *a, **k: prompt) 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 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() ui, _ = _make_ui()
prompt = self._fake_prompt(None) prompt = self._fake_prompt(None)
captured_choices: list = [] captured_choices: list = []
@@ -252,7 +242,7 @@ class TestWaitForThreadPick:
return prompt return prompt
monkeypatch.setattr("questionary.select", fake_select) 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) # At least one Choice title contains "abc123 *" (current marker)
choice_titles = [getattr(c, "title", "") for c in captured_choices] choice_titles = [getattr(c, "title", "") for c in captured_choices]
assert any("abc123 *" in t for t in choice_titles) assert any("abc123 *" in t for t in choice_titles)
@@ -282,38 +272,38 @@ class TestCompactIndicator:
class TestPhaseBMigrated: class TestPhaseBMigrated:
"""Session lifecycle callbacks (start/resume) filled in Phase B.""" """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 from unittest.mock import AsyncMock
cb = AsyncMock() cb = AsyncMock()
ui, _ = _make_ui(on_start_new_session=cb) ui, _ = _make_ui(on_start_new_session=cb)
_run(ui.start_new_session()) await ui.start_new_session()
cb.assert_awaited_once() 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() ui, console = _make_ui()
_run(ui.start_new_session()) await ui.start_new_session()
console.print.assert_not_called() 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 from unittest.mock import AsyncMock
cb = AsyncMock() cb = AsyncMock()
ui, _ = _make_ui(on_handle_session_resume=cb) 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") 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() ui, _ = _make_ui()
# Should not raise # 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 from unittest.mock import AsyncMock
cb = AsyncMock() cb = AsyncMock()
ui, _ = _make_ui(on_handle_session_resume=cb) 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) cb.assert_awaited_once_with("tid-x", None)
@@ -321,7 +311,7 @@ class TestPhaseCMigrated:
"""Skill/MCP browse pickers delegate to questionary helpers via """Skill/MCP browse pickers delegate to questionary helpers via
``asyncio.to_thread`` since questionary blocks the event loop.""" ``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 from unittest.mock import MagicMock
picker = MagicMock(return_value=["skill-a", "skill-b"]) picker = MagicMock(return_value=["skill-a", "skill-b"])
@@ -330,11 +320,11 @@ class TestPhaseCMigrated:
picker, picker,
) )
ui, _ = _make_ui() 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"] assert result == ["skill-a", "skill-b"]
picker.assert_called_once_with([{"name": "a"}], {"installed"}, "core") 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 from unittest.mock import MagicMock
monkeypatch.setattr( monkeypatch.setattr(
@@ -342,10 +332,10 @@ class TestPhaseCMigrated:
MagicMock(return_value=None), MagicMock(return_value=None),
) )
ui, _ = _make_ui() ui, _ = _make_ui()
result = _run(ui.wait_for_skill_browse([], set(), "")) result = await ui.wait_for_skill_browse([], set(), "")
assert result is None 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 from unittest.mock import MagicMock
sentinel_entries = [MagicMock(name="entry1"), MagicMock(name="entry2")] sentinel_entries = [MagicMock(name="entry1"), MagicMock(name="entry2")]
@@ -355,11 +345,11 @@ class TestPhaseCMigrated:
picker, picker,
) )
ui, _ = _make_ui() 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 assert result is sentinel_entries
picker.assert_called_once() 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 from unittest.mock import MagicMock
monkeypatch.setattr( monkeypatch.setattr(
@@ -367,5 +357,5 @@ class TestPhaseCMigrated:
MagicMock(return_value=None), MagicMock(return_value=None),
) )
ui, _ = _make_ui() ui, _ = _make_ui()
result = _run(ui.wait_for_mcp_browse([], set(), "")) result = await ui.wait_for_mcp_browse([], set(), "")
assert result is None assert result is None
+24 -28
View File
@@ -2,8 +2,6 @@
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from tests.conftest import run_async as _run
def _ctx(): def _ctx():
from EvoScientist.commands.base import CommandContext from EvoScientist.commands.base import CommandContext
@@ -12,17 +10,17 @@ def _ctx():
return CommandContext(agent=None, thread_id="tid", ui=ui), ui 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 from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx() ctx, ui = _ctx()
with patch("EvoScientist.cron.schedule.is_available", return_value=False): 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("unavailable" in m.lower() for m in msgs) 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 from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx() ctx, _ui = _ctx()
@@ -33,17 +31,15 @@ def test_add_parses_five_field_cron_and_prompt():
return_value={"cron_id": "c-9"}, return_value={"cron_id": "c-9"},
) as mk, ) as mk,
): ):
_run( await ScheduleCommand().execute(
ScheduleCommand().execute( ctx, ["add", "*/10", "*", "*", "*", "*", "search", "uk", "weather"]
ctx, ["add", "*/10", "*", "*", "*", "*", "search", "uk", "weather"]
)
) )
kw = mk.call_args.kwargs kw = mk.call_args.kwargs
assert kw["schedule"] == "*/10 * * * *" assert kw["schedule"] == "*/10 * * * *"
assert kw["prompt"] == "search uk weather" assert kw["prompt"] == "search uk weather"
def test_list_renders_table(): async def test_list_renders_table():
from EvoScientist.commands.implementation.schedule import ScheduleCommand from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx() 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.is_available", return_value=True),
patch("EvoScientist.cron.schedule.list_schedules", return_value=rows), 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() 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 from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx() ctx, _ui = _ctx()
@@ -75,15 +71,15 @@ def test_add_parses_quoted_cron_and_prompt():
return_value={"cron_id": "c-9"}, return_value={"cron_id": "c-9"},
) as mk, ) as mk,
): ):
_run( await ScheduleCommand().execute(
ScheduleCommand().execute(ctx, ["add", "*/10 * * * *", "search uk weather"]) ctx, ["add", "*/10 * * * *", "search uk weather"]
) )
kw = mk.call_args.kwargs kw = mk.call_args.kwargs
assert kw["schedule"] == "*/10 * * * *" assert kw["schedule"] == "*/10 * * * *"
assert kw["prompt"] == "search uk weather" 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 from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, _ui = _ctx() ctx, _ui = _ctx()
@@ -96,11 +92,11 @@ def test_run_with_matching_prefix_fires_matched_prompt():
return_value={"run_id": "r-1"}, return_value={"run_id": "r-1"},
) as rn, ) as rn,
): ):
_run(ScheduleCommand().execute(ctx, ["run", "c-123"])) await ScheduleCommand().execute(ctx, ["run", "c-123"])
rn.assert_called_once_with("do the thing") 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 from EvoScientist.commands.implementation.schedule import ScheduleCommand
ctx, ui = _ctx() 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.list_schedules", return_value=[]),
patch("EvoScientist.cron.schedule.run_now") as rn, patch("EvoScientist.cron.schedule.run_now") as rn,
): ):
_run(ScheduleCommand().execute(ctx, ["run", "nope"])) await ScheduleCommand().execute(ctx, ["run", "nope"])
rn.assert_not_called() rn.assert_not_called()
msgs = [c.args[0] for c in ui.append_system.call_args_list] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("No schedule matching" in m for m in msgs) 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 from EvoScientist.commands.implementation.schedule import ScheduleCommand
rows = [{"cron_id": "c-abcdef", "metadata": {"name": "t"}}] 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.list_schedules", return_value=rows),
patch("EvoScientist.cron.schedule.set_enabled") as se, 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) 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.""" """B1: list_schedules raising after is_available() shows a red error, not a traceback."""
from EvoScientist.commands.implementation.schedule import ScheduleCommand 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"), 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] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Error:" in m for m in msgs) assert any("Error:" in m for m in msgs)
# Verify no exception escaped (test would have raised above otherwise) # 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.""" """B2: two crons sharing a prefix → ambiguity message, delete NOT called."""
from EvoScientist.commands.implementation.schedule import ScheduleCommand 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.list_schedules", return_value=rows),
patch("EvoScientist.cron.schedule.delete_schedule") as mk, patch("EvoScientist.cron.schedule.delete_schedule") as mk,
): ):
_run(ScheduleCommand().execute(ctx, ["remove", "abc"])) await ScheduleCommand().execute(ctx, ["remove", "abc"])
mk.assert_not_called() mk.assert_not_called()
msgs = [c.args[0] for c in ui.append_system.call_args_list] msgs = [c.args[0] for c in ui.append_system.call_args_list]
assert any("Multiple" in m for m in msgs) 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'.""" """FIX 1: list_schedules() crashing in _resolve → red 'Error:' message, not 'No schedule matching'."""
from EvoScientist.commands.implementation.schedule import ScheduleCommand 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, patch("EvoScientist.cron.schedule.delete_schedule") as mk,
): ):
_run(ScheduleCommand().execute(ctx, ["remove", "abc"])) await ScheduleCommand().execute(ctx, ["remove", "abc"])
mk.assert_not_called() mk.assert_not_called()
msgs = [c.args[0] for c in ui.append_system.call_args_list] 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}" 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.""" """B3: prompt with newline / slashes / special chars → clean kebab-case name."""
import re import re
@@ -225,7 +221,7 @@ def test_add_name_sanitized_from_nasty_prompt():
return_value={"cron_id": "c-x"}, return_value={"cron_id": "c-x"},
) as mk, ) as mk,
): ):
_run(ScheduleCommand().execute(ctx, ["add", "*/5 * * * *", nasty_prompt])) await ScheduleCommand().execute(ctx, ["add", "*/5 * * * *", nasty_prompt])
name = mk.call_args.kwargs["name"] name = mk.call_args.kwargs["name"]
# Must be non-empty, no spaces, no newlines, no slashes # Must be non-empty, no spaces, no newlines, no slashes
assert name assert name
+35 -36
View File
@@ -27,7 +27,6 @@ from EvoScientist.cli.commands import (
from EvoScientist.commands.base import ChannelRuntime from EvoScientist.commands.base import ChannelRuntime
from EvoScientist.config import EvoScientistConfig from EvoScientist.config import EvoScientistConfig
from EvoScientist.gateway import RuntimeGateways, ThreadStore from EvoScientist.gateway import RuntimeGateways, ThreadStore
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore 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 """``/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 push that handle into the shared runtime state so the outer poll loop sees
it on the next message.""" it on the next message."""
@@ -86,12 +85,12 @@ def test_hook_updates_runtime_state_on_agent_swap():
cmd = MagicMock() cmd = MagicMock()
cmd.name = "/model" cmd.name = "/model"
_run(hook(ctx, original_agent, cmd)) await hook(ctx, original_agent, cmd)
assert state.agent is new_agent 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 """Other readers (the bus) look at ``ChannelRuntime.agent``; the
hook keeps the runtime in sync with the runtime state update.""" hook keeps the runtime in sync with the runtime state update."""
original_agent = _agent("original-agent") original_agent = _agent("original-agent")
@@ -109,13 +108,13 @@ def test_hook_syncs_channel_runtime():
cmd = MagicMock() cmd = MagicMock()
cmd.name = "/model" cmd.name = "/model"
_run(hook(ctx, original_agent, cmd)) await hook(ctx, original_agent, cmd)
assert runtime.agent is new_agent assert runtime.agent is new_agent
assert runtime.thread_id == "t" 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 """Commands like ``/evoskills`` don't touch ``ctx.agent`` — the
runtime state must stay put.""" runtime state must stay put."""
original_agent = _agent("original-agent") original_agent = _agent("original-agent")
@@ -128,12 +127,12 @@ def test_hook_noop_when_agent_unchanged():
cmd = MagicMock() cmd = MagicMock()
cmd.name = "/evoskills" cmd.name = "/evoskills"
_run(hook(ctx, original_agent, cmd)) await hook(ctx, original_agent, cmd)
assert state.agent is original_agent 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`` — """Guard against commands that reset ``ctx.agent`` to ``None`` —
we never want to write ``None`` into runtime state.""" we never want to write ``None`` into runtime state."""
original_agent = _agent("original-agent") original_agent = _agent("original-agent")
@@ -146,12 +145,12 @@ def test_hook_noop_when_ctx_agent_is_none():
cmd = MagicMock() cmd = MagicMock()
cmd.name = "/whatever" cmd.name = "/whatever"
_run(hook(ctx, original_agent, cmd)) await hook(ctx, original_agent, cmd)
assert state.agent is original_agent 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 """``/resume`` mutates ``ctx.thread_id`` — the hook must push the
new id into runtime state so the outer poll loop runs subsequent new id into runtime state so the outer poll loop runs subsequent
messages on the resumed thread.""" messages on the resumed thread."""
@@ -166,12 +165,12 @@ def test_hook_updates_thread_id_on_resume():
cmd = MagicMock() cmd = MagicMock()
cmd.name = "/resume" cmd.name = "/resume"
_run(hook(ctx, agent, cmd)) await hook(ctx, agent, cmd)
assert state.thread_id == "new-tid" 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.""" """`/resume` can restore a different workspace; serve must reload for it."""
cfg = _config() cfg = _config()
old_agent = _agent("old-agent") old_agent = _agent("old-agent")
@@ -201,7 +200,7 @@ def test_hook_updates_workspace_dir_on_resume():
return_value=reloaded_agent, return_value=reloaded_agent,
) as load_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") sync_server.assert_awaited_once_with(cfg, workspace_dir="/restored-ws")
load_agent.assert_called_once_with(workspace_dir="/restored-ws", config=cfg) 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 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 """The bus reads ``ChannelRuntime.thread_id``; hook must sync it
alongside the runtime state update.""" alongside the runtime state update."""
agent = _agent("a") agent = _agent("a")
@@ -224,12 +223,12 @@ def test_hook_syncs_channel_runtime_thread_id():
cmd = MagicMock() cmd = MagicMock()
cmd.name = "/resume" cmd.name = "/resume"
_run(hook(ctx, agent, cmd)) await hook(ctx, agent, cmd)
assert runtime.thread_id == "new-tid" 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.""" """Most commands don't touch thread_id — runtime state stays put."""
agent = _agent("a") agent = _agent("a")
state = _runtime_state(agent=agent, thread_id="same-tid") state = _runtime_state(agent=agent, thread_id="same-tid")
@@ -241,12 +240,12 @@ def test_hook_noop_when_thread_id_unchanged():
cmd = MagicMock() cmd = MagicMock()
cmd.name = "/evoskills" cmd.name = "/evoskills"
_run(hook(ctx, agent, cmd)) await hook(ctx, agent, cmd)
assert state.thread_id == "same-tid" 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 """Bare ``/resume`` with no argument prints usage but leaves
``ctx.thread_id`` unchanged — the in-memory-state warning must NOT ``ctx.thread_id`` unchanged — the in-memory-state warning must NOT
fire because no resume actually happened.""" fire because no resume actually happened."""
@@ -261,13 +260,13 @@ def test_hook_skips_resume_warning_when_thread_unchanged():
cmd = MagicMock() cmd = MagicMock()
cmd.name = "/resume" cmd.name = "/resume"
_run(hook(ctx, agent, cmd)) await hook(ctx, agent, cmd)
ctx.ui.append_system.assert_not_called() ctx.ui.append_system.assert_not_called()
ctx.ui.flush.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 """``/resume <tid>`` that actually changes thread_id must surface
the in-memory-state warning via ``ctx.ui``.""" the in-memory-state warning via ``ctx.ui``."""
agent = _agent("a") agent = _agent("a")
@@ -283,7 +282,7 @@ def test_hook_emits_resume_warning_when_thread_changed():
cmd = MagicMock() cmd = MagicMock()
cmd.name = "/resume" cmd.name = "/resume"
_run(hook(ctx, agent, cmd)) await hook(ctx, agent, cmd)
ctx.ui.append_system.assert_called_once() ctx.ui.append_system.assert_called_once()
warn_text, warn_kwargs = ( warn_text, warn_kwargs = (
@@ -296,7 +295,7 @@ def test_hook_emits_resume_warning_when_thread_changed():
ctx.ui.flush.assert_awaited_once() 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 """``/new`` via channel calls this callback — must generate a new
thread id, push into runtime state, and sync the channel runtime.""" thread id, push into runtime state, and sync the channel runtime."""
agent = _agent("a") agent = _agent("a")
@@ -311,13 +310,13 @@ def test_start_new_session_cb_rotates_thread_id():
state, state,
runtime, runtime,
) )
_run(cb()) await cb()
assert state.thread_id == "freshly-generated-tid" assert state.thread_id == "freshly-generated-tid"
assert runtime.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 """``/new`` rotates thread only — agent handle must stay put
(serve's agent is a single pre-loaded instance, not per-thread).""" (serve's agent is a single pre-loaded instance, not per-thread)."""
agent = _agent("a") agent = _agent("a")
@@ -328,12 +327,12 @@ def test_start_new_session_cb_leaves_agent_alone():
) )
cb = _make_serve_start_new_session_cb(state) cb = _make_serve_start_new_session_cb(state)
_run(cb()) await cb()
assert state.agent is agent 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() cfg = _config()
old_agent = _agent("old-agent") old_agent = _agent("old-agent")
reloaded_agent = _agent("reloaded-agent") reloaded_agent = _agent("reloaded-agent")
@@ -364,7 +363,7 @@ def test_serve_resume_callback_syncs_reloads_and_adopts_workspace():
side_effect=_load_agent, side_effect=_load_agent,
) as 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") sync_server.assert_awaited_once_with(cfg, workspace_dir="/new-ws")
load_agent.assert_called_once_with(workspace_dir="/new-ws", config=cfg) 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 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() cfg = _config()
old_agent = _agent("old-agent") old_agent = _agent("old-agent")
reloaded_agent = _agent("reloaded-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, 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) hook = _make_serve_cmd_completed_hook(state, runtime, config=cfg)
ctx = MagicMock() ctx = MagicMock()
@@ -410,14 +409,14 @@ def test_hook_emits_resume_warning_after_resume_callback_adopts_thread():
cmd = MagicMock() cmd = MagicMock()
cmd.name = "/resume" cmd.name = "/resume"
_run(hook(ctx, reloaded_agent, cmd)) await hook(ctx, reloaded_agent, cmd)
ctx.ui.append_system.assert_called_once() ctx.ui.append_system.assert_called_once()
assert "in-memory state" in ctx.ui.append_system.call_args.args[0] assert "in-memory state" in ctx.ui.append_system.call_args.args[0]
ctx.ui.flush.assert_awaited_once() 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() cfg = _config()
old_agent = _agent("old-agent") old_agent = _agent("old-agent")
loaded_but_not_adopted = _agent("loaded-but-not-adopted") 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, patch("EvoScientist.cli.commands.set_active_workspace") as set_active,
pytest.raises(RuntimeError, match="workspace conflict"), 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) load_agent.assert_called_once_with(workspace_dir="/new-ws", config=cfg)
set_active.assert_called_once_with("/old-ws") 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" 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() cfg = _config()
old_agent = _agent("old-agent") old_agent = _agent("old-agent")
state = _runtime_state( state = _runtime_state(
@@ -479,7 +478,7 @@ def test_serve_resume_callback_load_failure_does_not_sync_or_adopt():
) as sync_server, ) as sync_server,
pytest.raises(RuntimeError, match="load failed"), 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) load_agent.assert_called_once_with(workspace_dir="/new-ws", config=cfg)
set_active.assert_called_once_with("/old-ws") 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" 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 """Edge case: a command that changes both (hypothetical). Both
updates must land in runtime state.""" updates must land in runtime state."""
old_agent = _agent("old-agent") old_agent = _agent("old-agent")
@@ -506,7 +505,7 @@ def test_hook_handles_both_agent_and_thread_swap():
ctx.thread_id = "new-tid" ctx.thread_id = "new-tid"
cmd = MagicMock() cmd = MagicMock()
_run(hook(ctx, old_agent, cmd)) await hook(ctx, old_agent, cmd)
assert state.agent is new_agent assert state.agent is new_agent
assert state.thread_id == "new-tid" 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.base import ChannelError
from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig
from tests.conftest import run_async as _run
class TestSlackConfig: class TestSlackConfig:
@@ -38,24 +37,24 @@ class TestSlackChannel:
assert channel.config is config assert channel.config is config
assert channel._running is False 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") config = SlackConfig(bot_token="", app_token="xapp-test")
channel = SlackChannel(config) channel = SlackChannel(config)
with pytest.raises(ChannelError, match="bot token"): 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="") config = SlackConfig(bot_token="xoxb-test", app_token="")
channel = SlackChannel(config) channel = SlackChannel(config)
with pytest.raises(ChannelError, match="app token"): 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") config = SlackConfig(bot_token="xoxb-test", app_token="xapp-test")
channel = SlackChannel(config) 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 from EvoScientist.channels.base import OutboundMessage
config = SlackConfig(bot_token="xoxb-test", app_token="xapp-test") config = SlackConfig(bot_token="xoxb-test", app_token="xapp-test")
@@ -66,7 +65,7 @@ class TestSlackChannel:
content="hello", content="hello",
metadata={"chat_id": "C123"}, metadata={"chat_id": "C123"},
) )
result = _run(channel.send(msg)) result = await channel.send(msg)
assert result is False assert result is False
+7 -12
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import ClassVar from typing import ClassVar
@@ -243,7 +242,7 @@ def test_build_status_text_uses_rich_styles():
assert text.spans 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: class _FakeModel:
model_name: ClassVar[str] = "provider/demo-model" model_name: ClassVar[str] = "provider/demo-model"
profile: ClassVar[dict[str, object]] = {} profile: ClassVar[dict[str, object]] = {}
@@ -262,16 +261,12 @@ def test_build_session_status_snapshot_uses_fallback_window(monkeypatch):
_fake_count, _fake_count,
) )
snapshot = asyncio.run( snapshot = await build_session_status_snapshot(
build_session_status_snapshot( "thread-1",
"thread-1", pending_user_text="pending",
pending_user_text="pending", graph_gateway=FakeGraphGateway(
graph_gateway=FakeGraphGateway( thread_store=FakeThreadStore(messages=[HumanMessage(content="existing")])
thread_store=FakeThreadStore( ),
messages=[HumanMessage(content="existing")]
)
),
)
) )
assert snapshot.model_full == "provider/demo-model" 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_command_tool_content,
_extract_tool_content, _extract_tool_content,
) )
from tests.conftest import run_async
from tests.stream_v3_fakes import ( from tests.stream_v3_fakes import (
ErroringV3Agent, ErroringV3Agent,
FakeSubagent, FakeSubagent,
@@ -144,10 +143,10 @@ class TestExtractToolContent:
class TestV3ProtocolStreaming: class TestV3ProtocolStreaming:
"""Test stream_agent_events against v3 protocol events.""" """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.""" """v3 content-block text deltas are processed."""
agent = FakeV3Agent([message_delta("hello world")]) 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"] text_events = [e for e in events if e.get("type") == "text"]
assert len(text_events) == 1 assert len(text_events) == 1
assert text_events[0]["content"] == "hello world" assert text_events[0]["content"] == "hello world"
@@ -156,7 +155,7 @@ class TestV3ProtocolStreaming:
assert "stream_mode" not in kwargs assert "stream_mode" not in kwargs
assert "subgraphs" 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.""" """Normal JSON answers are not swallowed by selector JSON buffering."""
agent = FakeV3Agent( agent = FakeV3Agent(
[ [
@@ -165,13 +164,13 @@ class TestV3ProtocolStreaming:
message_delta(": 1}"), message_delta(": 1}"),
] ]
) )
events = collect_events(agent) events = await collect_events(agent)
text_events = [e for e in events if e.get("type") == "text"] text_events = [e for e in events if e.get("type") == "text"]
assert "".join(e["content"] for e in text_events) == '{"answer": 1}' assert "".join(e["content"] for e in text_events) == '{"answer": 1}'
assert events[-1]["type"] == "done" assert events[-1]["type"] == "done"
assert events[-1]["response"] == '{"answer": 1}' 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.""" """Buffered non-selector text is not lost if the message ends mid-object."""
agent = FakeV3Agent( agent = FakeV3Agent(
[ [
@@ -180,40 +179,42 @@ class TestV3ProtocolStreaming:
message_finish(), message_finish(),
] ]
) )
events = collect_events(agent) events = await collect_events(agent)
text_events = [e for e in events if e.get("type") == "text"] text_events = [e for e in events if e.get("type") == "text"]
assert "".join(e["content"] for e in text_events) == '{"answer":' assert "".join(e["content"] for e in text_events) == '{"answer":'
assert events[-1]["response"] == '{"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.""" """Normal answers may legitimately contain a top-level tools key."""
agent = FakeV3Agent( agent = FakeV3Agent(
[ [
message_delta('{"tools":["hammer"],"answer":"use safely"}'), 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"] text_events = [e for e in events if e.get("type") == "text"]
assert len(text_events) == 1 assert len(text_events) == 1
assert text_events[0]["content"] == '{"tools":["hammer"],"answer":"use safely"}' assert text_events[0]["content"] == '{"tools":["hammer"],"answer":"use safely"}'
assert events[-1]["response"] == '{"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.""" """Legacy <thinking> tags are still removed on the v3 text path."""
agent = FakeV3Agent( agent = FakeV3Agent(
[message_delta("<thinking>some reasoning</thinking>The answer is 42.")] [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"] text_events = [e for e in events if e.get("type") == "text"]
assert len(text_events) == 1 assert len(text_events) == 1
assert text_events[0]["content"] == "The answer is 42." 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>")]) 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"] == [] 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.""" """Non-summary updates are skipped without error."""
agent = FakeV3Agent( agent = FakeV3Agent(
[ [
@@ -221,12 +222,14 @@ class TestV3ProtocolStreaming:
message_delta("should appear"), 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"] text_events = [e for e in events if e.get("type") == "text"]
assert len(text_events) == 1 assert len(text_events) == 1
assert text_events[0]["content"] == "should appear" 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 = [] calls = []
monkeypatch.setattr( monkeypatch.setattr(
"EvoScientist.stream.events.clear_completed_memory_activity_counts", "EvoScientist.stream.events.clear_completed_memory_activity_counts",
@@ -234,11 +237,13 @@ class TestV3ProtocolStreaming:
) )
agent = FakeV3Agent([]) agent = FakeV3Agent([])
collect_events(agent, message="new user turn") await collect_events(agent, message="new user turn")
assert calls == [True] 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 = [] calls = []
monkeypatch.setattr( monkeypatch.setattr(
"EvoScientist.stream.events.clear_completed_memory_activity_counts", "EvoScientist.stream.events.clear_completed_memory_activity_counts",
@@ -247,12 +252,12 @@ class TestV3ProtocolStreaming:
agent = FakeV3Agent([]) agent = FakeV3Agent([])
resume_command = Command(resume={"decisions": [{"type": "approve"}]}) resume_command = Command(resume={"decisions": [{"type": "approve"}]})
collect_events(agent, message=resume_command) await collect_events(agent, message=resume_command)
assert calls == [True] assert calls == [True]
assert agent.astream_events.call_args.args[0] is resume_command 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.""" """v3 messages with lc_source=summarization emit summarization events."""
agent = FakeV3Agent( agent = FakeV3Agent(
[ [
@@ -260,7 +265,7 @@ class TestV3ProtocolStreaming:
message_delta("real content"), message_delta("real content"),
] ]
) )
events = collect_events(agent) events = await collect_events(agent)
summary_start_events = [ summary_start_events = [
e for e in events if e.get("type") == "summarization_start" e for e in events if e.get("type") == "summarization_start"
] ]
@@ -272,7 +277,7 @@ class TestV3ProtocolStreaming:
assert len(text_events) == 1 assert len(text_events) == 1
assert text_events[0]["content"] == "real content" 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.""" """_summarization_event updates should emit a summarization event."""
summary_message = HumanMessage( summary_message = HumanMessage(
content="Here is a summary of the conversation to date:\n\nKey facts", content="Here is a summary of the conversation to date:\n\nKey facts",
@@ -294,7 +299,7 @@ class TestV3ProtocolStreaming:
message_delta("real content"), message_delta("real content"),
] ]
) )
events = collect_events(agent) events = await collect_events(agent)
summary_start_events = [ summary_start_events = [
e for e in events if e.get("type") == "summarization_start" e for e in events if e.get("type") == "summarization_start"
] ]
@@ -303,7 +308,7 @@ class TestV3ProtocolStreaming:
assert len(summary_events) == 1 assert len(summary_events) == 1
assert summary_events[0]["content"] == "Key facts" 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.""" """If streamed summarization already emitted, updates fallback should not duplicate it."""
summary_message = HumanMessage( summary_message = HumanMessage(
content="Here is a summary of the conversation to date:\n\nKey facts" content="Here is a summary of the conversation to date:\n\nKey facts"
@@ -324,7 +329,7 @@ class TestV3ProtocolStreaming:
message_delta("real content"), message_delta("real content"),
] ]
) )
events = collect_events(agent) events = await collect_events(agent)
summary_start_events = [ summary_start_events = [
e for e in events if e.get("type") == "summarization_start" e for e in events if e.get("type") == "summarization_start"
] ]
@@ -333,7 +338,7 @@ class TestV3ProtocolStreaming:
assert len(summary_events) == 1 assert len(summary_events) == 1
assert summary_events[0]["content"] == "synthetic summary" 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.""" """Persisted _summarization_event from a prior turn should not be replayed."""
summary_message = HumanMessage( summary_message = HumanMessage(
content="Here is a summary of the conversation to date:\n\nKey facts", content="Here is a summary of the conversation to date:\n\nKey facts",
@@ -352,7 +357,7 @@ class TestV3ProtocolStreaming:
], ],
state_values=summary_event, state_values=summary_event,
) )
events = collect_events(agent) events = await collect_events(agent)
summary_start_events = [ summary_start_events = [
e for e in events if e.get("type") == "summarization_start" 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"] summary_events = [e for e in events if e.get("type") == "summarization"]
assert summary_events == [] 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.""" """Public stream_agent_events() suppresses persisted summary replays."""
summary_message = HumanMessage( summary_message = HumanMessage(
content="Here is a summary of the conversation to date:\n\nKey facts", content="Here is a summary of the conversation to date:\n\nKey facts",
@@ -380,13 +385,9 @@ class TestV3ProtocolStreaming:
state_values=summary_event, state_values=summary_event,
) )
async def _collect(): events = []
events = [] async for event in stream_agent_events(agent, "hi", "t1"):
async for event in stream_agent_events(agent, "hi", "t1"): events.append(event)
events.append(event)
return events
events = run_async(_collect())
summary_start_events = [ summary_start_events = [
e for e in events if e.get("type") == "summarization_start" 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"] summary_events = [e for e in events if e.get("type") == "summarization"]
assert summary_events == [] 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.""" """Providers can expose the same reasoning in kwargs and content blocks."""
message = AIMessage( message = AIMessage(
additional_kwargs={"reasoning_content": "Think once."}, additional_kwargs={"reasoning_content": "Think once."},
content=[{"type": "reasoning", "reasoning": "Think once."}], content=[{"type": "reasoning", "reasoning": "Think once."}],
) )
agent = FakeV3Agent([protocol_event("messages", (message, {}))]) 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"] thinking_events = [e for e in events if e.get("type") == "thinking"]
assert len(thinking_events) == 1 assert len(thinking_events) == 1
assert thinking_events[0]["content"] == "Think once." 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.""" """Selector reasoning must not appear as main-agent thinking."""
import EvoScientist.middleware.tool_selector as selector_mod import EvoScientist.middleware.tool_selector as selector_mod
@@ -432,7 +433,7 @@ class TestV3ProtocolStreaming:
) )
] ]
) )
events = collect_events(agent) events = await collect_events(agent)
finally: finally:
selector_mod._selector_active = original_active selector_mod._selector_active = original_active
@@ -441,7 +442,7 @@ class TestV3ProtocolStreaming:
for e in events 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.""" """Selector reasoning in whole-message payloads is also hidden."""
import EvoScientist.middleware.tool_selector as selector_mod import EvoScientist.middleware.tool_selector as selector_mod
@@ -453,7 +454,7 @@ class TestV3ProtocolStreaming:
content="", content="",
) )
agent = FakeV3Agent([protocol_event("messages", (message, {}))]) agent = FakeV3Agent([protocol_event("messages", (message, {}))])
events = collect_events(agent) events = await collect_events(agent)
finally: finally:
selector_mod._selector_active = original_active selector_mod._selector_active = original_active
@@ -462,7 +463,7 @@ class TestV3ProtocolStreaming:
for e in events 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.""" """v3 tool projection events become UI tool call/result events."""
output = ToolMessage( output = ToolMessage(
name="read_file", name="read_file",
@@ -475,7 +476,7 @@ class TestV3ProtocolStreaming:
tool_finished(output), 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_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") tool_result = next(e for e in events if e.get("type") == "tool_result")
assert tool_call["name"] == "read_file" assert tool_call["name"] == "read_file"
@@ -489,7 +490,7 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings( @pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental" "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.""" """DeepAgents v3 emits tool_call_id on started and finished tool events."""
@tool @tool
@@ -518,17 +519,14 @@ class TestV3ProtocolStreaming:
system_prompt="Use tools when requested.", system_prompt="Use tools when requested.",
) )
async def _collect_events(): events = [
return [ event
event async for event in stream_agent_events(
async for event in stream_agent_events( agent,
agent, "run probe",
"run probe", "live-deepagents-tool-id",
"live-deepagents-tool-id", )
) ]
]
events = run_async(_collect_events())
tool_call = next(e for e in events if e.get("type") == "tool_call") 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") tool_result = next(e for e in events if e.get("type") == "tool_result")
@@ -551,7 +549,7 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings( @pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental" "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.""" """Live HITL streams the model tool call once before one interrupt."""
@tool @tool
@@ -581,17 +579,14 @@ class TestV3ProtocolStreaming:
checkpointer=InMemorySaver(), checkpointer=InMemorySaver(),
) )
async def _collect_events(): events = [
return [ event
event async for event in stream_agent_events(
async for event in stream_agent_events( agent,
agent, "run echo",
"run echo", "live-deepagents-hitl",
"live-deepagents-hitl", )
) ]
]
events = run_async(_collect_events())
tool_calls = [e for e in events if e.get("type") == "tool_call"] tool_calls = [e for e in events if e.get("type") == "tool_call"]
interrupts = [e for e in events if e.get("type") == "interrupt"] interrupts = [e for e in events if e.get("type") == "interrupt"]
@@ -611,7 +606,7 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings( @pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental" "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.""" """ask_user pause markers are not displayed as failed tool results."""
model = _ToolCallingFakeModel( 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] first_types = [event.get("type") for event in first_events]
assert first_types == ["tool_call", "ask_user", "done"] assert first_types == ["tool_call", "ask_user", "done"]
ask_event = next(e for e in first_events if e.get("type") == "ask_user") 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["tool_call_id"] == "call_ask_1"
assert ask_event["questions"] == [{"question": "What dataset?", "type": "text"}] assert ask_event["questions"] == [{"question": "What dataset?", "type": "text"}]
resumed_events = run_async( resumed_events = await _collect(
_collect(Command(resume={"answers": ["CIFAR-10"], "status": "answered"})) Command(resume={"answers": ["CIFAR-10"], "status": "answered"})
) )
tool_result = next(e for e in resumed_events if e.get("type") == "tool_result") tool_result = next(e for e in resumed_events if e.get("type") == "tool_result")
assert tool_result == { assert tool_result == {
@@ -678,7 +673,9 @@ class TestV3ProtocolStreaming:
@pytest.mark.filterwarnings( @pytest.mark.filterwarnings(
"ignore:The v3 streaming protocol on Pregel is experimental" "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.""" """Live task results should display the subagent ToolMessage content."""
root_model = _ToolCallingFakeModel( root_model = _ToolCallingFakeModel(
@@ -717,17 +714,14 @@ class TestV3ProtocolStreaming:
], ],
) )
async def _collect_events(): events = [
return [ event
event async for event in stream_agent_events(
async for event in stream_agent_events( agent,
agent, "delegate",
"delegate", "live-deepagents-subagent",
"live-deepagents-subagent", )
) ]
]
events = run_async(_collect_events())
subagent_start = next(e for e in events if e.get("type") == "subagent_start") 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") 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 task_result["content"] == "subagent final"
assert "Command(" not in task_result["content"] 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.""" """Model-declared tool calls remain visible before execution starts."""
agent = FakeV3Agent( 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] event_types = [e["type"] for e in events]
assert event_types.index("tool_call") < event_types.index("interrupt") assert event_types.index("tool_call") < event_types.index("interrupt")
tool_call = next(e for e in events if e.get("type") == "tool_call") tool_call = next(e for e in events if e.get("type") == "tool_call")
assert tool_call["id"] == "tc-msg" assert tool_call["id"] == "tc-msg"
assert tool_call["args"] == {"command": "ls"} 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.""" """Selector UI event is emitted even when selection is followed only by a tool."""
import EvoScientist.middleware.tool_selector as selector_mod import EvoScientist.middleware.tool_selector as selector_mod
@@ -806,7 +800,7 @@ class TestV3ProtocolStreaming:
tool_finished(output), tool_finished(output),
] ]
) )
events = collect_events(agent) events = await collect_events(agent)
finally: finally:
selector_mod._current_selected_tools = original_selected selector_mod._current_selected_tools = original_selected
selector_mod._total_tools_count = original_total 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") selection = next(e for e in events if e.get("type") == "tool_selection")
assert selection["tools"] == ["read_file"] 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.""" """DeepAgents subagent projection supplies identity for namespaced events."""
namespace = ("task", "abc") namespace = ("task", "abc")
output = ToolMessage( output = ToolMessage(
@@ -838,7 +832,7 @@ class TestV3ProtocolStreaming:
], ],
subagents=[FakeSubagent(namespace, "research-agent")], 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_start" for e in events)
assert any(e.get("type") == "subagent_end" 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") 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.""" """Subagent events are not dropped if protocol events arrive first."""
namespace = ("task", "late") namespace = ("task", "late")
@@ -900,7 +894,7 @@ class TestV3ProtocolStreaming:
return Snapshot() return Snapshot()
events = collect_events(Agent()) events = await collect_events(Agent())
event_types = [e["type"] for e in events] event_types = [e["type"] for e in events]
text = next(e for e in events if e.get("type") == "subagent_text") 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 text["instance_id"] == "task:late"
assert event_types.index("subagent_start") < event_types.index("subagent_text") 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.""" """Tool call/result events can arrive on namespace suffixes for one subagent."""
subagent_path = ("task", "abc") subagent_path = ("task", "abc")
call_namespace = (*subagent_path, "agent") call_namespace = (*subagent_path, "agent")
@@ -936,7 +930,7 @@ class TestV3ProtocolStreaming:
], ],
subagents=[FakeSubagent(subagent_path, "research-agent")], 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"] 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"] 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]["instance_id"] == "task:abc"
assert results[0]["id"] == "sa-tc" 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.""" """Finished subagents stop showing as active while root streaming continues."""
output_returned = asyncio.Event() output_returned = asyncio.Event()
@@ -996,19 +990,19 @@ class TestV3ProtocolStreaming:
return Snapshot() return Snapshot()
events = collect_events(Agent()) events = await collect_events(Agent())
event_types = [e["type"] for e in events] event_types = [e["type"] for e in events]
assert event_types.index("subagent_end") < event_types.index("text") 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.""" """Subagent handles are not dropped by lazy projection subscription."""
namespace = ("task", "early") namespace = ("task", "early")
agent = SubscriptionSensitiveV3Agent( agent = SubscriptionSensitiveV3Agent(
[message_delta("Sub-agent finding.", namespace=namespace)], [message_delta("Sub-agent finding.", namespace=namespace)],
[FakeSubagent(namespace, "research-agent")], [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_start" for e in events)
assert any(e.get("type") == "subagent_end" 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"] == [] 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["content"] == "Sub-agent finding."
assert text["instance_id"] == "task:early" 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.""" """Lifecycle and tool events distinguish same-name parallel subagents."""
ns1 = ("task", "one") ns1 = ("task", "one")
ns2 = ("task", "two") ns2 = ("task", "two")
@@ -1048,7 +1042,7 @@ class TestV3ProtocolStreaming:
FakeSubagent(ns2, "research-agent"), 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"] 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"] 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 results} == {"task:one", "task:two"}
assert {e["instance_id"] for e in ends} == {"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.""" """astream_events construction failures preserve the UI error event contract."""
events = [] events = []
@@ -1073,10 +1067,10 @@ class TestV3ProtocolStreaming:
events.append(ev) events.append(ev)
with pytest.raises(RuntimeError, match="boom"): with pytest.raises(RuntimeError, match="boom"):
run_async(collect()) await collect()
assert events == [{"type": "error", "message": "boom"}] 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.""" """Early consumer exit should abort the caller-driven v3 run."""
async def consume_one_and_close(): async def consume_one_and_close():
@@ -1090,7 +1084,7 @@ class TestV3ProtocolStreaming:
await stream.aclose() await stream.aclose()
return first, agent.aborted 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["type"] == "text"
assert first["content"] == "hi" assert first["content"] == "hi"
assert aborted is True assert aborted is True
@@ -1099,7 +1093,7 @@ class TestV3ProtocolStreaming:
class TestUsageStatsExtraction: class TestUsageStatsExtraction:
"""Test token usage extraction from v3 message-finish events.""" """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.""" """v3 message-finish usage emits usage_stats event."""
agent = FakeV3Agent( 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"] usage_events = [e for e in events if e.get("type") == "usage_stats"]
assert len(usage_events) == 1 assert len(usage_events) == 1
assert usage_events[0]["input_tokens"] == 100 assert usage_events[0]["input_tokens"] == 100
assert usage_events[0]["output_tokens"] == 50 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.""" """message-finish without usage does not emit usage_stats."""
agent = FakeV3Agent([message_delta("hi"), message_finish()]) 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"] usage_events = [e for e in events if e.get("type") == "usage_stats"]
assert len(usage_events) == 0 assert len(usage_events) == 0
@@ -1158,7 +1152,7 @@ class TestSummarizationHelpers:
assert isinstance(summary_message, HumanMessage) assert isinstance(summary_message, HumanMessage)
assert summary_message.content == "Summary body" 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.""" """Zero input and output tokens should not emit usage_stats."""
agent = FakeV3Agent( 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"] usage_events = [e for e in events if e.get("type") == "usage_stats"]
assert len(usage_events) == 0 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 langgraph.types import interrupt
from EvoScientist.stream.events import _clear_interrupted_graph_state from EvoScientist.stream.events import _clear_interrupted_graph_state
from tests.conftest import run_async as _run
class _S(TypedDict): class _S(TypedDict):
@@ -58,7 +57,7 @@ def _interrupting_app():
return g.compile(checkpointer=InMemorySaver()) 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() app = _crashing_app()
cfg = {"configurable": {"thread_id": "t1"}} cfg = {"configurable": {"thread_id": "t1"}}
try: try:
@@ -68,7 +67,7 @@ def test_recovery_clears_stuck_state_after_crash():
# The crash left the graph frozen at node 'b'. # The crash left the graph frozen at node 'b'.
assert app.get_state(cfg).next == ("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) snap = app.get_state(cfg)
assert snap.next == () # stuck state actually cleared 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 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() app = _interrupting_app()
cfg = {"configurable": {"thread_id": "t1"}} cfg = {"configurable": {"thread_id": "t1"}}
app.invoke({"x": 0}, cfg) # parks at interrupt() 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.next == ("ask",)
assert before.interrupts assert before.interrupts
_run(_clear_interrupted_graph_state(app, cfg)) await _clear_interrupted_graph_state(app, cfg)
after = app.get_state(cfg) after = app.get_state(cfg)
assert after.next == ("ask",) # interrupt left intact, still resumable 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 import pytest
from EvoScientist.stt import STT_MODELS, is_audio_file, transcribe_file from EvoScientist.stt import STT_MODELS, is_audio_file, transcribe_file
from tests.conftest import run_async
# ── is_audio_file ───────────────────────────────────────────────────── # ── is_audio_file ─────────────────────────────────────────────────────
@@ -50,8 +49,8 @@ def test_stt_models_keys():
# ── transcribe_file: non-audio path ────────────────────────────────── # ── transcribe_file: non-audio path ──────────────────────────────────
def test_transcribe_non_audio_returns_none(): async def test_transcribe_non_audio_returns_none():
result = run_async(transcribe_file("photo.jpg", language="auto")) result = await transcribe_file("photo.jpg", language="auto")
assert result is None 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 import EvoScientist.stt as stt_mod
stt_mod._engine = None stt_mod._engine = None
with _patch_whisper(_make_whisper_mock("Hello world")): 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 stt_mod._engine = None
assert result == "Hello world" assert result == "Hello world"
def test_transcribe_auto_uses_whisper(): async def test_transcribe_auto_uses_whisper():
import EvoScientist.stt as stt_mod import EvoScientist.stt as stt_mod
stt_mod._engine = None stt_mod._engine = None
with _patch_whisper(_make_whisper_mock("Bonjour monde")): 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 stt_mod._engine = None
assert result == "Bonjour monde" assert result == "Bonjour monde"
def test_transcribe_zh_uses_whisper(): async def test_transcribe_zh_uses_whisper():
import EvoScientist.stt as stt_mod import EvoScientist.stt as stt_mod
stt_mod._engine = None stt_mod._engine = None
with _patch_whisper(_make_whisper_mock("你好世界")): 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 stt_mod._engine = None
assert result == "你好世界" assert result == "你好世界"
def test_transcribe_custom_model_override(): async def test_transcribe_custom_model_override():
"""stt_model config overrides the default model mapping.""" """stt_model config overrides the default model mapping."""
import EvoScientist.stt as stt_mod import EvoScientist.stt as stt_mod
@@ -121,10 +120,8 @@ def test_transcribe_custom_model_override():
self._model = _make_whisper_mock("test") self._model = _make_whisper_mock("test")
with patch.object(stt_mod._WhisperEngine, "__init__", patched_init): with patch.object(stt_mod._WhisperEngine, "__init__", patched_init):
run_async( await transcribe_file(
transcribe_file( "voice.ogg", language="auto", model="openai/whisper-large-v3"
"voice.ogg", language="auto", model="openai/whisper-large-v3"
)
) )
stt_mod._engine = None stt_mod._engine = None
assert captured_model_id == ["openai/whisper-large-v3"] assert captured_model_id == ["openai/whisper-large-v3"]
@@ -133,7 +130,7 @@ def test_transcribe_custom_model_override():
# ── transcribe_file: missing dependency ────────────────────────────── # ── transcribe_file: missing dependency ──────────────────────────────
def test_transcribe_missing_dep_returns_none(): async def test_transcribe_missing_dep_returns_none():
import sys import sys
import EvoScientist.stt as stt_mod import EvoScientist.stt as stt_mod
@@ -142,7 +139,7 @@ def test_transcribe_missing_dep_returns_none():
saved = sys.modules.pop("faster_whisper", None) saved = sys.modules.pop("faster_whisper", None)
try: try:
with patch.dict("sys.modules", {"faster_whisper": None}): 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: finally:
if saved is not None: if saved is not None:
sys.modules["faster_whisper"] = saved sys.modules["faster_whisper"] = saved
@@ -169,7 +166,7 @@ def _make_channel():
return ch, captured 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.""" """_enqueue_raw prepends STT transcript to raw.text when stt_enabled."""
from EvoScientist.channels.base import RawIncoming from EvoScientist.channels.base import RawIncoming
@@ -189,22 +186,18 @@ def test_enqueue_raw_stt_prepends_transcript():
timestamp=datetime.now(), timestamp=datetime.now(),
) )
async def _run(): with (
with ( patch("EvoScientist.stt.transcribe_file", new=AsyncMock(return_value="你好")),
patch( patch("EvoScientist.stt.is_audio_file", return_value=True),
"EvoScientist.stt.transcribe_file", new=AsyncMock(return_value="你好") ):
), await ch._enqueue_raw(raw)
patch("EvoScientist.stt.is_audio_file", return_value=True),
):
await ch._enqueue_raw(raw)
run_async(_run())
assert captured[0].text == "你好" assert captured[0].text == "你好"
# annotation should be removed after transcription # annotation should be removed after transcription
assert captured[0].content_annotations == [] 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.""" """When stt_enabled=False, transcription is not called."""
from EvoScientist.channels.base import RawIncoming from EvoScientist.channels.base import RawIncoming
@@ -221,16 +214,14 @@ def test_enqueue_raw_stt_disabled_skips_transcription():
mock_transcribe = AsyncMock() mock_transcribe = AsyncMock()
async def _run(): with patch("EvoScientist.stt.transcribe_file", mock_transcribe):
with patch("EvoScientist.stt.transcribe_file", mock_transcribe): await ch._enqueue_raw(raw)
await ch._enqueue_raw(raw)
run_async(_run())
mock_transcribe.assert_not_called() mock_transcribe.assert_not_called()
assert captured[0].text == "" 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.""" """Transcript is prepended before any existing caption text."""
from EvoScientist.channels.base import RawIncoming from EvoScientist.channels.base import RawIncoming
@@ -249,17 +240,15 @@ def test_enqueue_raw_stt_appends_to_existing_text():
timestamp=datetime.now(), timestamp=datetime.now(),
) )
async def _run(): with (
with ( patch(
patch( "EvoScientist.stt.transcribe_file",
"EvoScientist.stt.transcribe_file", new=AsyncMock(return_value="hello world"),
new=AsyncMock(return_value="hello world"), ),
), patch("EvoScientist.stt.is_audio_file", return_value=True),
patch("EvoScientist.stt.is_audio_file", return_value=True), ):
): await ch._enqueue_raw(raw)
await ch._enqueue_raw(raw)
run_async(_run())
assert captured[0].text == "hello world\ncaption text" 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.channel_manager import ChannelManager
from EvoScientist.channels.consumer import InboundConsumer, _join_subagent_text from EvoScientist.channels.consumer import InboundConsumer, _join_subagent_text
from EvoScientist.stream.emitter import StreamEvent, StreamEventEmitter from EvoScientist.stream.emitter import StreamEvent, StreamEventEmitter
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway from tests.fakes import FakeGraphGateway
from tests.fakes import StubChannel as _StubChannel from tests.fakes import StubChannel as _StubChannel
from tests.stream_v3_fakes import ( from tests.stream_v3_fakes import (
@@ -82,7 +81,7 @@ class TestSubagentTextEmitter:
class TestStreamAgentEventsSubagentText: class TestStreamAgentEventsSubagentText:
"""Verify sub-agent text chunks yield subagent_text events.""" """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.""" """When a sub-agent produces text, subagent_text events should appear."""
namespace = ("sub", "research") namespace = ("sub", "research")
agent = FakeV3Agent( agent = FakeV3Agent(
@@ -93,23 +92,23 @@ class TestStreamAgentEventsSubagentText:
], ],
subagents=[FakeSubagent(namespace, "research-agent")], 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"] sa_text = [e for e in events if e.get("type") == "subagent_text"]
assert len(sa_text) == 1 assert len(sa_text) == 1
assert "Sub-agent finding" in sa_text[0]["content"] assert "Sub-agent finding" in sa_text[0]["content"]
# instance_id must be present and non-empty # instance_id must be present and non-empty
assert sa_text[0].get("instance_id"), "instance_id must be a non-empty string" 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'.""" """Main agent text should produce 'text' events, not 'subagent_text'."""
agent = FakeV3Agent([message_delta("Main agent reply.")]) 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"] 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"] text_events = [e for e in events if e.get("type") == "text"]
assert len(sa_text) == 0 assert len(sa_text) == 0
assert len(text_events) == 1 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.""" """Multiple text chunks from a sub-agent all yield subagent_text events."""
namespace = ("sub", "a") namespace = ("sub", "a")
agent = FakeV3Agent( agent = FakeV3Agent(
@@ -120,7 +119,7 @@ class TestStreamAgentEventsSubagentText:
], ],
subagents=[FakeSubagent(namespace, "research-agent")], 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"] sa_text = [e for e in events if e.get("type") == "subagent_text"]
assert len(sa_text) == 3 assert len(sa_text) == 3
combined = "".join(e["content"] for e in sa_text) 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 len(ids) == 1, f"Expected 1 unique instance_id, got {ids}"
assert all(e.get("instance_id") for e in sa_text) 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 """Two sub-agents with the same display name but different namespaces
produce subagent_text events with different instance_id values. produce subagent_text events with different instance_id values.
@@ -154,7 +153,7 @@ class TestStreamAgentEventsSubagentText:
FakeSubagent(ns2, "research-agent"), 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"] sa_text = [e for e in events if e.get("type") == "subagent_text"]
assert len(sa_text) == 3 assert len(sa_text) == 3
@@ -211,7 +210,7 @@ def _make_consumer(stream_events: list[dict], **kw):
class TestConsumerSubagentTextFallback: class TestConsumerSubagentTextFallback:
"""InboundConsumer should use sub-agent text as fallback when main agent is silent.""" """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.""" """When the main agent produces no text, sub-agent text becomes the response."""
events = [ events = [
{ {
@@ -230,27 +229,24 @@ class TestConsumerSubagentTextFallback:
] ]
consumer, bus = _make_consumer(events) consumer, bus = _make_consumer(events)
async def _test(): msg = BusInbound(
msg = BusInbound( channel="stub",
channel="stub", sender_id="u1",
sender_id="u1", chat_id="c1",
chat_id="c1", content="analyze papers",
content="analyze papers", )
) await bus.publish_inbound(msg)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run()) task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0) 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.content == "Found 3 relevant papers. Key insight: X is Y."
assert outbound.channel == "stub" assert outbound.channel == "stub"
await consumer.stop() await consumer.stop()
await task await task
_run(_test()) async def test_final_content_takes_priority_over_subagent_text(self):
def test_final_content_takes_priority_over_subagent_text(self):
"""When the main agent produces text, sub-agent text is ignored.""" """When the main agent produces text, sub-agent text is ignored."""
events = [ events = [
{ {
@@ -264,26 +260,23 @@ class TestConsumerSubagentTextFallback:
] ]
consumer, bus = _make_consumer(events) consumer, bus = _make_consumer(events)
async def _test(): msg = BusInbound(
msg = BusInbound( channel="stub",
channel="stub", sender_id="u1",
sender_id="u1", chat_id="c1",
chat_id="c1", content="test",
content="test", )
) await bus.publish_inbound(msg)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run()) task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0) 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 consumer.stop()
await task await task
_run(_test()) async def test_duplicate_thinking_not_relayed_across_resume_rounds(self):
def test_duplicate_thinking_not_relayed_across_resume_rounds(self):
"""Repeated thinking from resumed rounds should only be sent once.""" """Repeated thinking from resumed rounds should only be sent once."""
bus = MessageBus() bus = MessageBus()
mgr = ChannelManager(bus) mgr = ChannelManager(bus)
@@ -329,30 +322,27 @@ class TestConsumerSubagentTextFallback:
return_value={"answers": ["yes"], "status": "answered"} return_value={"answers": ["yes"], "status": "answered"}
) )
async def _test(): await bus.publish_inbound(
await bus.publish_inbound( BusInbound(
BusInbound( channel="stub",
channel="stub", sender_id="u1",
sender_id="u1", chat_id="c1",
chat_id="c1", content="analyze papers",
content="analyze papers",
)
) )
)
task = asyncio.create_task(consumer.run()) task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0) outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert outbound.content == "final answer" assert outbound.content == "final answer"
assert channel.send_thinking_message.await_count == 1 assert channel.send_thinking_message.await_count == 1
call = channel.send_thinking_message.await_args_list[0] call = channel.send_thinking_message.await_args_list[0]
assert call.args[1] == thinking.rstrip() assert call.args[1] == thinking.rstrip()
await consumer.stop() await consumer.stop()
await task await task
_run(_test()) async def test_new_thinking_relayed_after_resume(self):
def test_new_thinking_relayed_after_resume(self):
"""Genuinely different thinking in round 2 should be sent.""" """Genuinely different thinking in round 2 should be sent."""
bus = MessageBus() bus = MessageBus()
mgr = ChannelManager(bus) mgr = ChannelManager(bus)
@@ -399,58 +389,52 @@ class TestConsumerSubagentTextFallback:
return_value={"answers": ["yes"], "status": "answered"} return_value={"answers": ["yes"], "status": "answered"}
) )
async def _test(): await bus.publish_inbound(
await bus.publish_inbound( BusInbound(
BusInbound( channel="stub",
channel="stub", sender_id="u1",
sender_id="u1", chat_id="c1",
chat_id="c1", content="analyze papers",
content="analyze papers",
)
) )
)
task = asyncio.create_task(consumer.run()) task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0) outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert outbound.content == "final answer" assert outbound.content == "final answer"
assert channel.send_thinking_message.await_count == 2 assert channel.send_thinking_message.await_count == 2
call1 = channel.send_thinking_message.await_args_list[0] call1 = channel.send_thinking_message.await_args_list[0]
call2 = channel.send_thinking_message.await_args_list[1] call2 = channel.send_thinking_message.await_args_list[1]
assert call1.args[1] == thinking_r1.rstrip() assert call1.args[1] == thinking_r1.rstrip()
assert call2.args[1] == thinking_r2.rstrip() assert call2.args[1] == thinking_r2.rstrip()
await consumer.stop() await consumer.stop()
await task await task
_run(_test()) async def test_no_response_fallback_when_both_empty(self):
def test_no_response_fallback_when_both_empty(self):
"""When both final_content and subagent_text are empty, 'No response' is used.""" """When both final_content and subagent_text are empty, 'No response' is used."""
events = [ events = [
{"type": "done", "content": ""}, {"type": "done", "content": ""},
] ]
consumer, bus = _make_consumer(events) consumer, bus = _make_consumer(events)
async def _test(): msg = BusInbound(
msg = BusInbound( channel="stub",
channel="stub", sender_id="u1",
sender_id="u1", chat_id="c1",
chat_id="c1", content="test",
content="test", )
) await bus.publish_inbound(msg)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run()) task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0) 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 consumer.stop()
await task await task
_run(_test()) async def test_done_content_overrides_subagent_text(self):
def test_done_content_overrides_subagent_text(self):
"""Done event with content takes priority over sub-agent text buffer.""" """Done event with content takes priority over sub-agent text buffer."""
events = [ events = [
{ {
@@ -463,24 +447,21 @@ class TestConsumerSubagentTextFallback:
] ]
consumer, bus = _make_consumer(events) consumer, bus = _make_consumer(events)
async def _test(): msg = BusInbound(
msg = BusInbound( channel="stub",
channel="stub", sender_id="u1",
sender_id="u1", chat_id="c1",
chat_id="c1", content="test",
content="test", )
) await bus.publish_inbound(msg)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run()) task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0) 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 consumer.stop()
await task await task
_run(_test())
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
@@ -560,7 +541,7 @@ class TestJoinSubagentText:
class TestConsumerParallelSubagentFallback: class TestConsumerParallelSubagentFallback:
"""Consumer should group parallel sub-agent text by agent name.""" """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.""" """Multiple sub-agents produce grouped, attributed output."""
events = [ events = [
{ {
@@ -585,27 +566,24 @@ class TestConsumerParallelSubagentFallback:
] ]
consumer, bus = _make_consumer(events) consumer, bus = _make_consumer(events)
async def _test(): msg = BusInbound(
msg = BusInbound( channel="stub",
channel="stub", sender_id="u1",
sender_id="u1", chat_id="c1",
chat_id="c1", content="test",
content="test", )
) await bus.publish_inbound(msg)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run()) task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0) outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert "[research]: Found papers. Key insight." in outbound.content assert "[research]: Found papers. Key insight." in outbound.content
assert "[analysis]: Metric is high." in outbound.content assert "[analysis]: Metric is high." in outbound.content
await consumer.stop() await consumer.stop()
await task await task
_run(_test()) async def test_single_agent_no_attribution_prefix(self):
def test_single_agent_no_attribution_prefix(self):
"""Single sub-agent fallback has no [name]: prefix.""" """Single sub-agent fallback has no [name]: prefix."""
events = [ events = [
{ {
@@ -618,31 +596,28 @@ class TestConsumerParallelSubagentFallback:
] ]
consumer, bus = _make_consumer(events) consumer, bus = _make_consumer(events)
async def _test(): msg = BusInbound(
msg = BusInbound( channel="stub",
channel="stub", sender_id="u1",
sender_id="u1", chat_id="c1",
chat_id="c1", content="test",
content="test", )
) await bus.publish_inbound(msg)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run()) task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0) outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert outbound.content == "Only agent." assert outbound.content == "Only agent."
assert "[research]" not in outbound.content assert "[research]" not in outbound.content
await consumer.stop() await consumer.stop()
await task await task
_run(_test())
class TestConsumerSameNameInterleaved: class TestConsumerSameNameInterleaved:
"""Two instances of the same agent type with interleaved chunks.""" """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. """Two research-agent instances with different instance_ids are properly separated.
With the instance_id fix, chunks are keyed by instance_id so With the instance_id fix, chunks are keyed by instance_id so
@@ -678,32 +653,29 @@ class TestConsumerSameNameInterleaved:
] ]
consumer, bus = _make_consumer(events) consumer, bus = _make_consumer(events)
async def _test(): msg = BusInbound(
msg = BusInbound( channel="stub",
channel="stub", sender_id="u1",
sender_id="u1", chat_id="c1",
chat_id="c1", content="test",
content="test", )
) await bus.publish_inbound(msg)
await bus.publish_inbound(msg)
task = asyncio.create_task(consumer.run()) task = asyncio.create_task(consumer.run())
outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0) outbound = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
# Fixed: instances are now properly separated with numbered labels # Fixed: instances are now properly separated with numbered labels
assert ( assert (
"[research-agent #1]: Instance-1 sentence A. Instance-1 sentence B." "[research-agent #1]: Instance-1 sentence A. Instance-1 sentence B."
in outbound.content in outbound.content
) )
assert ( assert (
"[research-agent #2]: Instance-2 sentence X. Instance-2 sentence Y." "[research-agent #2]: Instance-2 sentence X. Instance-2 sentence Y."
in outbound.content in outbound.content
) )
await consumer.stop() await consumer.stop()
await task await task
_run(_test())
class TestDelegationPromptSummarize: class TestDelegationPromptSummarize:
+6 -7
View File
@@ -4,7 +4,6 @@ import pytest
from EvoScientist.channels.base import ChannelError from EvoScientist.channels.base import ChannelError
from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
from tests.conftest import run_async as _run
class TestTelegramConfig: class TestTelegramConfig:
@@ -32,18 +31,18 @@ class TestTelegramChannel:
assert channel.config is config assert channel.config is config
assert channel._running is False assert channel._running is False
def test_start_raises_without_token(self): async def test_start_raises_without_token(self):
config = TelegramConfig(bot_token="") config = TelegramConfig(bot_token="")
channel = TelegramChannel(config) channel = TelegramChannel(config)
with pytest.raises(ChannelError, match="bot token"): 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") config = TelegramConfig(bot_token="test")
channel = TelegramChannel(config) 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 from EvoScientist.channels.base import OutboundMessage
config = TelegramConfig(bot_token="test") config = TelegramConfig(bot_token="test")
@@ -54,5 +53,5 @@ class TestTelegramChannel:
content="hello", content="hello",
metadata={"chat_id": "123"}, metadata={"chat_id": "123"},
) )
result = _run(channel.send(msg)) result = await channel.send(msg)
assert result is False assert result is False
+8 -9
View File
@@ -4,7 +4,6 @@ from unittest.mock import MagicMock
from rich.table import Table from rich.table import Table
from tests.conftest import run_async as _run
from tests.fakes import FakeGraphGateway, FakeThreadStore from tests.fakes import FakeGraphGateway, FakeThreadStore
@@ -24,15 +23,15 @@ def _ctx(**overrides):
class TestThreadsCommand: class TestThreadsCommand:
def test_empty_list_prints_message(self): async def test_empty_list_prints_message(self):
from EvoScientist.commands.implementation.session import ThreadsCommand from EvoScientist.commands.implementation.session import ThreadsCommand
ctx, ui = _ctx() ctx, ui = _ctx()
_run(ThreadsCommand().execute(ctx, [])) await ThreadsCommand().execute(ctx, [])
ui.append_system.assert_called_once() ui.append_system.assert_called_once()
assert "No saved sessions" in ui.append_system.call_args.args[0] 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 from EvoScientist.commands.implementation.session import ThreadsCommand
ctx, ui = _ctx(thread_id="current") ctx, ui = _ctx(thread_id="current")
@@ -54,7 +53,7 @@ class TestThreadsCommand:
] ]
store = FakeThreadStore(threads=threads) store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store) ctx.graph_gateway = FakeGraphGateway(thread_store=store)
_run(ThreadsCommand().execute(ctx, [])) await ThreadsCommand().execute(ctx, [])
ui.mount_renderable.assert_called_once() ui.mount_renderable.assert_called_once()
table = ui.mount_renderable.call_args.args[0] table = ui.mount_renderable.call_args.args[0]
assert isinstance(table, Table) assert isinstance(table, Table)
@@ -64,7 +63,7 @@ class TestThreadsCommand:
assert "/delete" in footer assert "/delete" in footer
assert "/new" 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.""" """Channels don't get the footer — keeps outbound text short."""
from EvoScientist.commands.implementation.session import ThreadsCommand from EvoScientist.commands.implementation.session import ThreadsCommand
@@ -80,10 +79,10 @@ class TestThreadsCommand:
] ]
store = FakeThreadStore(threads=threads) store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store) ctx.graph_gateway = FakeGraphGateway(thread_store=store)
_run(ThreadsCommand().execute(ctx, [])) await ThreadsCommand().execute(ctx, [])
ui.append_system.assert_not_called() 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.""" """Non-interactive (channel) UIs get a narrower table."""
from EvoScientist.commands.implementation.session import ThreadsCommand from EvoScientist.commands.implementation.session import ThreadsCommand
@@ -99,7 +98,7 @@ class TestThreadsCommand:
] ]
store = FakeThreadStore(threads=threads) store = FakeThreadStore(threads=threads)
ctx.graph_gateway = FakeGraphGateway(thread_store=store) 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. # Channel mode: no Model column. 4 columns: ID, Preview, Msgs, Last Used.
table = ui.mount_renderable.call_args.args[0] table = ui.mount_renderable.call_args.args[0]
column_headers = [col.header for col in table.columns] column_headers = [col.header for col in table.columns]
+10 -16
View File
@@ -149,40 +149,34 @@ class TestWrapToolCallAsync:
def setup_method(self): def setup_method(self):
self.mw = ToolErrorHandlerMiddleware() self.mw = ToolErrorHandlerMiddleware()
@staticmethod async def test_success_passes_through(self):
def _run(coro):
from tests.conftest import run_async
return run_async(coro)
def test_success_passes_through(self):
expected = ToolMessage(content="ok", tool_call_id="tc_001", name="t") expected = ToolMessage(content="ok", tool_call_id="tc_001", name="t")
async def handler(req): async def handler(req):
return expected return expected
req = _make_request() 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 assert result is expected
def test_command_passes_through(self): async def test_command_passes_through(self):
cmd = Command(update={"messages": []}) cmd = Command(update={"messages": []})
async def handler(req): async def handler(req):
return cmd return cmd
req = _make_request() 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 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): async def handler(req):
raise RuntimeError("MCP server timed out") raise RuntimeError("MCP server timed out")
req = _make_request("slow_tool", "tc_async") 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 isinstance(result, ToolMessage)
assert result.status == "error" assert result.status == "error"
@@ -190,21 +184,21 @@ class TestWrapToolCallAsync:
assert result.name == "slow_tool" assert result.name == "slow_tool"
assert "MCP server timed out" in result.content 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): async def handler(req):
raise ConnectionError("connection lost") raise ConnectionError("connection lost")
req = _make_request() 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) assert isinstance(result, ToolMessage)
def test_keyboard_interrupt_propagates(self): async def test_keyboard_interrupt_propagates(self):
async def handler(req): async def handler(req):
raise KeyboardInterrupt() raise KeyboardInterrupt()
req = _make_request() req = _make_request()
with pytest.raises(KeyboardInterrupt): 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( async def test_clear_chat_resets_scroll_after_long_anchored_conversation(
monkeypatch, run_async monkeypatch,
): ):
"""Repro of issue #301: clear after a long anchored stream → banner on top. """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. the now-empty bottom of the previous content.
""" """
async def scenario(): from textual.containers import VerticalScroll
from textual.containers import VerticalScroll from textual.widgets import Static
from textual.widgets import Static
app = _capture_app(monkeypatch) app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause() await pilot.pause()
chat = app.query_one("#chat", VerticalScroll) chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static) welcome = app.query_one("#welcome", Static)
for i in range(80): for i in range(80):
await chat.mount(Static(f"prior message {i}\n" * 2)) await chat.mount(Static(f"prior message {i}\n" * 2))
await pilot.pause() await pilot.pause()
chat.scroll_end(animate=False) chat.scroll_end(animate=False)
await pilot.pause() await pilot.pause()
chat.anchor() chat.anchor()
await pilot.pause() 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.clear_chat()
app._append_system("New session: tid", style="green") app._append_system("New session: tid", style="green")
await pilot.pause() await pilot.pause()
await pilot.pause() await pilot.pause()
_assert_banner_at_top( _assert_banner_at_top(chat, welcome, label="after /new on long anchored convo")
chat, welcome, label="after /new on long anchored convo" assert len(chat.children) == 2
)
assert len(chat.children) == 2
run_async(scenario())
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.""" """User scrolled up (anchor released) before /new → still lands at top."""
async def scenario(): from textual.containers import VerticalScroll
from textual.containers import VerticalScroll from textual.widgets import Static
from textual.widgets import Static
app = _capture_app(monkeypatch) app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause() await pilot.pause()
chat = app.query_one("#chat", VerticalScroll) chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static) welcome = app.query_one("#welcome", Static)
for i in range(80): for i in range(80):
await chat.mount(Static(f"msg {i}\n" * 2)) await chat.mount(Static(f"msg {i}\n" * 2))
await pilot.pause() await pilot.pause()
chat.anchor() chat.anchor()
chat.scroll_to(y=80, animate=False) chat.scroll_to(y=80, animate=False)
await pilot.pause() await pilot.pause()
app.clear_chat() app.clear_chat()
app._append_system("New session: tid", style="green") app._append_system("New session: tid", style="green")
await pilot.pause() await pilot.pause()
await pilot.pause() await pilot.pause()
_assert_banner_at_top( _assert_banner_at_top(chat, welcome, label="after /new with released anchor")
chat, welcome, label="after /new with released anchor" assert len(chat.children) == 2
)
assert len(chat.children) == 2
run_async(scenario())
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.""" """Even with a short conversation, anchor + clear should not push banner down."""
async def scenario(): from textual.containers import VerticalScroll
from textual.containers import VerticalScroll from textual.widgets import Static
from textual.widgets import Static
app = _capture_app(monkeypatch) app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause() await pilot.pause()
chat = app.query_one("#chat", VerticalScroll) chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static) welcome = app.query_one("#welcome", Static)
# Just enough content to overflow the viewport. # Just enough content to overflow the viewport.
for i in range(30): for i in range(30):
await chat.mount(Static(f"short msg {i}\n" * 2)) await chat.mount(Static(f"short msg {i}\n" * 2))
await pilot.pause() await pilot.pause()
chat.scroll_end(animate=False) chat.scroll_end(animate=False)
chat.anchor() chat.anchor()
await pilot.pause() await pilot.pause()
app.clear_chat() app.clear_chat()
app._append_system("New session: tid", style="green") app._append_system("New session: tid", style="green")
await pilot.pause() await pilot.pause()
await pilot.pause() await pilot.pause()
_assert_banner_at_top(chat, welcome, label="after /new on short convo") _assert_banner_at_top(chat, welcome, label="after /new on short convo")
run_async(scenario())
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 → """Repro of the user-reported scenario: clear → mount welcome banner →
mount new-session → mount user message → mount assistant reply, in a mount new-session → mount user message → mount assistant reply, in a
normal-sized terminal where the resulting content fits in the viewport. 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. wipe even when more widgets are mounted afterwards.
""" """
async def scenario(): from textual.containers import VerticalScroll
from textual.containers import VerticalScroll from textual.widgets import Static
from textual.widgets import Static
app = _capture_app(monkeypatch) app = _capture_app(monkeypatch)
# Tall-ish terminal: welcome + a few messages must fit in the # Tall-ish terminal: welcome + a few messages must fit in the
# viewport, mirroring the user's manual-test setup. # viewport, mirroring the user's manual-test setup.
async with app.run_test(size=(80, 40)) as pilot: async with app.run_test(size=(80, 40)) as pilot:
await pilot.pause() await pilot.pause()
chat = app.query_one("#chat", VerticalScroll) chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static) welcome = app.query_one("#welcome", Static)
# Long conversation, then /new. # Long conversation, then /new.
for i in range(80): for i in range(80):
await chat.mount(Static(f"prior message {i}\n" * 2)) await chat.mount(Static(f"prior message {i}\n" * 2))
await pilot.pause() await pilot.pause()
chat.scroll_end(animate=False) chat.scroll_end(animate=False)
chat.anchor() chat.anchor()
await pilot.pause() await pilot.pause()
app.clear_chat() app.clear_chat()
# Render the actual banner (not the empty placeholder) and add # Render the actual banner (not the empty placeholder) and add
# the /new system message — this is exactly what # the /new system message — this is exactly what
# ``start_new_session`` does after clearing. # ``start_new_session`` does after clearing.
app._render_welcome() app._render_welcome()
app._append_system("New session: tid", style="green") app._append_system("New session: tid", style="green")
await pilot.pause() await pilot.pause()
await pilot.pause() await pilot.pause()
# User types "hello" — _run_turn mounts UserMessage then calls # User types "hello" — _run_turn mounts UserMessage then calls
# ``container.scroll_end(animate=False)`` (line 1305 in the # ``container.scroll_end(animate=False)`` (line 1305 in the
# real code). In a tall viewport this still lands at scroll_y # real code). In a tall viewport this still lands at scroll_y
# == 0 because content fits. # == 0 because content fits.
from EvoScientist.cli.widgets.assistant_message import AssistantMessage from EvoScientist.cli.widgets.assistant_message import AssistantMessage
from EvoScientist.cli.widgets.user_message import UserMessage from EvoScientist.cli.widgets.user_message import UserMessage
await chat.mount(UserMessage("hello")) await chat.mount(UserMessage("hello"))
chat.scroll_end(animate=False) chat.scroll_end(animate=False)
await pilot.pause() await pilot.pause()
await chat.mount( await chat.mount(
AssistantMessage( AssistantMessage("Hello. What research problem are we working on today?")
"Hello. What research problem are we working on today?" )
) await pilot.pause()
) await pilot.pause()
await pilot.pause()
await pilot.pause()
_assert_banner_at_top( _assert_banner_at_top(
chat, chat,
welcome, welcome,
label=( label=(
f"after full /new → user msg → reply " f"after full /new → user msg → reply "
f"(max={chat.max_scroll_y}, " f"(max={chat.max_scroll_y}, "
f"viewport={chat.scrollable_content_region.height}, " f"viewport={chat.scrollable_content_region.height}, "
f"content={chat.content_size.height})" f"content={chat.content_size.height})"
), ),
) )
run_async(scenario())
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 """Regression for the second symptom of issue #301: after a short
user/assistant turn that fits in the viewport, end-of-stream user/assistant turn that fits in the viewport, end-of-stream
``_anchor_chat`` must NOT leave the chat anchored. ``_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``). overflows (``max_scroll_y > 0``).
""" """
async def scenario(): from textual.containers import VerticalScroll
from textual.containers import VerticalScroll from textual.widgets import Static
from textual.widgets import Static
from EvoScientist.cli.widgets.assistant_message import AssistantMessage from EvoScientist.cli.widgets.assistant_message import AssistantMessage
from EvoScientist.cli.widgets.user_message import UserMessage from EvoScientist.cli.widgets.user_message import UserMessage
app = _capture_app(monkeypatch) app = _capture_app(monkeypatch)
# Tall terminal: welcome + a short exchange fits with room to spare, # Tall terminal: welcome + a short exchange fits with room to spare,
# which is exactly the bug condition (content < viewport). # which is exactly the bug condition (content < viewport).
async with app.run_test(size=(80, 40)) as pilot: async with app.run_test(size=(80, 40)) as pilot:
await pilot.pause() await pilot.pause()
chat = app.query_one("#chat", VerticalScroll) chat = app.query_one("#chat", VerticalScroll)
welcome = app.query_one("#welcome", Static) welcome = app.query_one("#welcome", Static)
await chat.mount(UserMessage("hi")) await chat.mount(UserMessage("hi"))
await pilot.pause() await pilot.pause()
await chat.mount( await chat.mount(AssistantMessage("Hi. What are you looking to work on today?"))
AssistantMessage("Hi. What are you looking to work on today?") await pilot.pause()
)
await pilot.pause()
# End-of-stream re-anchor (matches _stream_with_widgets). # End-of-stream re-anchor (matches _stream_with_widgets).
app._anchor_chat(chat) app._anchor_chat(chat)
await pilot.pause() await pilot.pause()
# Any subsequent mount triggers a layout refresh — this is when # Any subsequent mount triggers a layout refresh — this is when
# the compositor would push scroll_y negative without the fix. # the compositor would push scroll_y negative without the fix.
# In production this happens via Markdown re-renders, status-bar # In production this happens via Markdown re-renders, status-bar
# updates, the system "usage" line, etc. # updates, the system "usage" line, etc.
await chat.mount(Static("trailing line\n")) await chat.mount(Static("trailing line\n"))
await pilot.pause() await pilot.pause()
await pilot.pause() await pilot.pause()
_assert_banner_at_top( _assert_banner_at_top(chat, welcome, label="after short turn + trailing mount")
chat, welcome, label="after short turn + trailing mount"
)
run_async(scenario())
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 """When the conversation overflows, ``_anchor_chat`` must still engage
the anchor so streaming output remains visible. The issue #301 fix the anchor so streaming output remains visible. The issue #301 fix
only suppresses anchoring when content fits — long content must only suppresses anchoring when content fits — long content must
continue to behave as before. continue to behave as before.
""" """
async def scenario(): from textual.containers import VerticalScroll
from textual.containers import VerticalScroll from textual.widgets import Static
from textual.widgets import Static
app = _capture_app(monkeypatch) app = _capture_app(monkeypatch)
async with app.run_test(size=(80, 24)) as pilot: async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause() await pilot.pause()
chat = app.query_one("#chat", VerticalScroll) chat = app.query_one("#chat", VerticalScroll)
for i in range(50): for i in range(50):
await chat.mount(Static(f"prior message {i}\n" * 2)) await chat.mount(Static(f"prior message {i}\n" * 2))
await pilot.pause() await pilot.pause()
app._anchor_chat(chat) app._anchor_chat(chat)
await pilot.pause() await pilot.pause()
assert chat.scroll_y == chat.max_scroll_y, ( assert chat.scroll_y == chat.max_scroll_y, (
"long content must anchor to bottom after _anchor_chat" "long content must anchor to bottom after _anchor_chat"
) )
# Trailing mount must keep the viewport pinned to the new bottom. # Trailing mount must keep the viewport pinned to the new bottom.
await chat.mount(Static("trailing line\n")) await chat.mount(Static("trailing line\n"))
await pilot.pause() await pilot.pause()
await pilot.pause() await pilot.pause()
assert chat.scroll_y == chat.max_scroll_y, ( assert chat.scroll_y == chat.max_scroll_y, (
"anchored viewport must follow new bottom after trailing mount" "anchored viewport must follow new bottom after trailing mount"
) )
assert chat.scroll_y > 0, "long content must have positive scroll_y" assert chat.scroll_y > 0, "long content must have positive scroll_y"
run_async(scenario())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+8 -7
View File
@@ -5,7 +5,6 @@ from types import SimpleNamespace
import pytest import pytest
from EvoScientist.commands.base import ChannelRuntime, CommandContext from EvoScientist.commands.base import ChannelRuntime, CommandContext
from tests.conftest import run_async as _run
pytest.importorskip("textual") pytest.importorskip("textual")
@@ -43,7 +42,7 @@ class _StubApp:
self.refresh_calls.append(reset_streaming_text) 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 import EvoScientist.cli.tui_interactive as tui_mod
from EvoScientist import EvoScientist as evosci_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) monkeypatch.setattr(tui_mod, "_channels_is_running", lambda: True)
app._channel_runtime.bind("old-agent", "old-thread") 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._agent_loader.adopt_calls == ["new-agent"]
assert app.model_updates == [("gpt-5.5", "openai")] 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" 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 import EvoScientist.cli.tui_interactive as tui_mod
app = _StubApp() 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) 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._agent_loader.adopt_calls == []
assert app.model_updates == [] assert app.model_updates == []
assert app.refresh_calls == [True] 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`` """Regression: ``/new`` and ``/resume`` rotate ``app._conversation_tid``
without swapping the agent. The runtime must still pick up the new without swapping the agent. The runtime must still pick up the new
thread id so the bus contract stays consistent with serve mode.""" 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) 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.agent == "same-agent"
assert app._channel_runtime.thread_id == "rotated-thread" assert app._channel_runtime.thread_id == "rotated-thread"
+6 -6
View File
@@ -10,14 +10,16 @@ import importlib
import unittest import unittest
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Textual might not be installed — skip entire module if missing # Textual might not be installed — skip entire module if missing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_has_textual = importlib.util.find_spec("textual") is not None _has_textual = importlib.util.find_spec("textual") is not None
@unittest.skipUnless(_has_textual, "textual not installed") @pytest.mark.skipif(not _has_textual, reason="textual not installed")
class TestLoadingWidget(unittest.TestCase): class TestLoadingWidget:
"""LoadingWidget construction and attributes.""" """LoadingWidget construction and attributes."""
def test_construction(self): def test_construction(self):
@@ -43,7 +45,7 @@ class TestLoadingWidget(unittest.TestCase):
assert w._frame == 1 assert w._frame == 1
assert w._elapsed == 0.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 from EvoScientist.cli.widgets.loading_widget import LoadingWidget
class _Timer: class _Timer:
@@ -58,9 +60,7 @@ class TestLoadingWidget(unittest.TestCase):
w._timer_handle = timer w._timer_handle = timer
w.remove = AsyncMock() w.remove = AsyncMock()
from tests.conftest import run_async await w.cleanup()
run_async(w.cleanup())
assert timer.stopped is True assert timer.stopped is True
assert w._timer_handle is None assert w._timer_handle is None
+109 -131
View File
@@ -20,7 +20,6 @@ from EvoScientist.channels.wechat.crypto import (
_pkcs7_unpad, _pkcs7_unpad,
parse_xml, parse_xml,
) )
from tests.conftest import run_async as _run
# ── Config tests ────────────────────────────────────────────────── # ── Config tests ──────────────────────────────────────────────────
@@ -83,36 +82,36 @@ class TestWeChatChannelInit:
channel = WeChatChannel(config, backend="wechatmp") channel = WeChatChannel(config, backend="wechatmp")
assert channel._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") config = WeComConfig(corp_id="", agent_id="1", secret="s")
channel = WeChatChannel(config, backend="wecom") channel = WeChatChannel(config, backend="wecom")
with pytest.raises(ChannelError, match="corp_id"): 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="") config = WeComConfig(corp_id="corp", agent_id="1", secret="")
channel = WeChatChannel(config, backend="wecom") channel = WeChatChannel(config, backend="wecom")
with pytest.raises(ChannelError, match="secret"): 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") config = WeComConfig(corp_id="corp", agent_id="", secret="s")
channel = WeChatChannel(config, backend="wecom") channel = WeChatChannel(config, backend="wecom")
with pytest.raises(ChannelError, match="agent_id"): 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") config = WeChatMPConfig(app_id="", app_secret="s")
channel = WeChatChannel(config, backend="wechatmp") channel = WeChatChannel(config, backend="wechatmp")
with pytest.raises(ChannelError, match="app_id"): 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") config = WeComConfig(corp_id="c", agent_id="1", secret="s")
channel = WeChatChannel(config, backend="wecom") 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 from EvoScientist.channels.base import OutboundMessage
config = WeComConfig(corp_id="c", agent_id="1", secret="s") config = WeComConfig(corp_id="c", agent_id="1", secret="s")
@@ -123,7 +122,7 @@ class TestWeChatChannelInit:
content="hello", content="hello",
metadata={"chat_id": "user1"}, metadata={"chat_id": "user1"},
) )
result = _run(channel.send(msg)) result = await channel.send(msg)
assert result is False assert result is False
@@ -324,144 +323,123 @@ class TestMessageProcessing:
) )
return WeChatChannel(config, backend="wecom") return WeChatChannel(config, backend="wecom")
def test_text_message_queued(self): async def test_text_message_queued(self):
channel = self._make_channel() channel = self._make_channel()
async def _test(): await channel._process_message(
await channel._process_message( {
{ "MsgType": "text",
"MsgType": "text", "Content": "Hello!",
"Content": "Hello!", "FromUserName": "user1",
"FromUserName": "user1", "ToUserName": "bot",
"ToUserName": "bot", "MsgId": "100",
"MsgId": "100", "CreateTime": str(int(time.time())),
"CreateTime": str(int(time.time())), }
} )
) # Check message was enqueued
# Check message was enqueued assert not channel._queue.empty()
assert not channel._queue.empty() msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0) assert msg.content == "Hello!"
assert msg.content == "Hello!" assert msg.sender_id == "user1"
assert msg.sender_id == "user1" assert msg.channel == "wechat"
assert msg.channel == "wechat"
_run(_test()) async def test_location_message(self):
def test_location_message(self):
channel = self._make_channel() channel = self._make_channel()
async def _test(): await channel._process_message(
await channel._process_message( {
{ "MsgType": "location",
"MsgType": "location", "Location_X": "39.9",
"Location_X": "39.9", "Location_Y": "116.4",
"Location_Y": "116.4", "Label": "Beijing",
"Label": "Beijing", "FromUserName": "user1",
"FromUserName": "user1", "ToUserName": "bot",
"ToUserName": "bot", "MsgId": "101",
"MsgId": "101", "CreateTime": str(int(time.time())),
"CreateTime": str(int(time.time())), }
} )
) msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0) assert "Beijing" in msg.content
assert "Beijing" in msg.content assert "39.9" in msg.content
assert "39.9" in msg.content
_run(_test()) async def test_voice_recognition(self):
def test_voice_recognition(self):
channel = self._make_channel() channel = self._make_channel()
async def _test(): await channel._process_message(
await channel._process_message( {
{ "MsgType": "voice",
"MsgType": "voice", "Recognition": "你好世界",
"Recognition": "你好世界", "FromUserName": "user1",
"FromUserName": "user1", "ToUserName": "bot",
"ToUserName": "bot", "MsgId": "102",
"MsgId": "102", "CreateTime": str(int(time.time())),
"CreateTime": str(int(time.time())), }
} )
) msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0) assert "你好世界" in msg.content
assert "你好世界" in msg.content
_run(_test()) async def test_link_message(self):
def test_link_message(self):
channel = self._make_channel() channel = self._make_channel()
async def _test(): await channel._process_message(
await channel._process_message( {
{ "MsgType": "link",
"MsgType": "link", "Title": "Test Link",
"Title": "Test Link", "Description": "A description",
"Description": "A description", "Url": "https://example.com",
"Url": "https://example.com", "FromUserName": "user1",
"FromUserName": "user1", "ToUserName": "bot",
"ToUserName": "bot", "MsgId": "103",
"MsgId": "103", "CreateTime": str(int(time.time())),
"CreateTime": str(int(time.time())), }
} )
) msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0) assert "Test Link" in msg.content
assert "Test Link" in msg.content assert "https://example.com" in msg.content
assert "https://example.com" in msg.content
_run(_test()) async def test_subscribe_event(self):
def test_subscribe_event(self):
channel = self._make_channel() channel = self._make_channel()
async def _test(): await channel._process_message(
await channel._process_message( {
{ "MsgType": "event",
"MsgType": "event", "Event": "subscribe",
"Event": "subscribe", "FromUserName": "user1",
"FromUserName": "user1", "ToUserName": "bot",
"ToUserName": "bot", "MsgId": "",
"MsgId": "", "CreateTime": str(int(time.time())),
"CreateTime": str(int(time.time())), }
} )
) msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0) assert "关注" in msg.content
assert "关注" in msg.content
_run(_test()) async def test_unsubscribe_ignored(self):
def test_unsubscribe_ignored(self):
channel = self._make_channel() channel = self._make_channel()
async def _test(): await channel._process_message(
await channel._process_message( {
{ "MsgType": "event",
"MsgType": "event", "Event": "unsubscribe",
"Event": "unsubscribe", "FromUserName": "user1",
"FromUserName": "user1", "ToUserName": "bot",
"ToUserName": "bot", "MsgId": "",
"MsgId": "", "CreateTime": str(int(time.time())),
"CreateTime": str(int(time.time())), }
} )
) assert channel._queue.empty()
assert channel._queue.empty()
_run(_test()) async def test_empty_message_ignored(self):
def test_empty_message_ignored(self):
channel = self._make_channel() channel = self._make_channel()
async def _test(): await channel._process_message(
await channel._process_message( {
{ "MsgType": "text",
"MsgType": "text", "Content": "",
"Content": "", "FromUserName": "",
"FromUserName": "", "ToUserName": "bot",
"ToUserName": "bot", }
} )
) assert channel._queue.empty()
assert channel._queue.empty()
_run(_test())
# ── Registration test ───────────────────────────────────────────── # ── Registration test ─────────────────────────────────────────────
Generated
+21 -4
View File
@@ -944,7 +944,7 @@ wheels = [
[[package]] [[package]]
name = "evoscientist" name = "evoscientist"
version = "0.2.0" version = "0.2.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "deepagents", extra = ["quickjs"] }, { name = "deepagents", extra = ["quickjs"] },
@@ -992,6 +992,7 @@ dev = [
{ name = "build" }, { name = "build" },
{ name = "pre-commit" }, { name = "pre-commit" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "pytest-timeout" }, { name = "pytest-timeout" },
{ name = "ruff" }, { name = "ruff" },
@@ -1032,6 +1033,7 @@ dev = [
{ name = "build" }, { name = "build" },
{ name = "pre-commit" }, { name = "pre-commit" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "pytest-timeout" }, { name = "pytest-timeout" },
{ name = "ruff" }, { name = "ruff" },
@@ -1075,6 +1077,7 @@ requires-dist = [
{ name = "pycryptodome", marker = "extra == 'all-channels'", specifier = ">=3.20" }, { name = "pycryptodome", marker = "extra == 'all-channels'", specifier = ">=3.20" },
{ name = "pycryptodome", marker = "extra == 'wechat'", specifier = ">=3.20" }, { name = "pycryptodome", marker = "extra == 'wechat'", specifier = ">=3.20" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { 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-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
{ name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.4" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.4" },
{ name = "python-dotenv", specifier = ">=1.0" }, { name = "python-dotenv", specifier = ">=1.0" },
@@ -1103,6 +1106,7 @@ dev = [
{ name = "build", specifier = ">=1.0" }, { name = "build", specifier = ">=1.0" },
{ name = "pre-commit", specifier = ">=3.5.0" }, { name = "pre-commit", specifier = ">=3.5.0" },
{ name = "pytest", specifier = ">=8.0" }, { name = "pytest", specifier = ">=8.0" },
{ name = "pytest-asyncio", specifier = ">=1.0" },
{ name = "pytest-cov", specifier = ">=5.0" }, { name = "pytest-cov", specifier = ">=5.0" },
{ name = "pytest-timeout", specifier = ">=2.4" }, { name = "pytest-timeout", specifier = ">=2.4" },
{ name = "ruff", specifier = ">=0.5" }, { 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" }, { 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]] [[package]]
name = "pytest-cov" name = "pytest-cov"
version = "7.1.0" version = "7.1.0"
@@ -4178,11 +4195,11 @@ wheels = [
[[package]] [[package]]
name = "soupsieve" name = "soupsieve"
version = "2.8.3" version = "2.8.4"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]