Files
EvoScientist/tests/test_langgraph_dev_http.py
T
m4 dbb6b7abde feat(model-registry): add delegation-JWT auth and config/snapshot HTTP API
- BFF service token (constant-time, plaintext or SHA-256 hash) plus
  X-Evo-Actor delegation JWT verification (ES256/RS256, iss/aud, <=60s
  lifetime, required claims, thread binding) with atomic jti anti-replay
- Config API: GET/PUT /api/model-registry, credential rotation endpoint,
  GET /api/models selector; PUT runs the section 9.2 save-time checks
  inside the registry write transaction after credential writes
- Snapshot API: create/bind/delete routes delegating to SnapshotService
  with thread/deployment binding checks and 9.5 unified error payloads
- Platform security config loader (config.yaml fields), OpenAPI export
  (scripts/export_model_registry_schema.py -> model_registry/openapi.json)
- Mount new routes in langgraph_dev/http.py; retire the legacy
  GET /api/models and POST /api/runtime-snapshots handlers
- Declare PyJWT>=2.8 (previously transitive); extend the 9.5 error code
  table with the HTTP-layer codes (400/401/403/422/500)
2026-07-21 09:28:55 +08:00

827 lines
27 KiB
Python

"""Smoke tests for the legacy admin routes mounted via langgraph.json's
``http`` field. We test the Starlette app directly — no need to spin up
langgraph dev.
``GET /api/models`` and ``POST /api/runtime-snapshots`` now belong to the
unified model registry API (``EvoScientist.model_registry.http_api``); their
contract tests live in ``tests/test_model_registry_http.py`` and
``tests/test_delegation_auth.py``.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from starlette.testclient import TestClient
from EvoScientist.config import EvoScientistConfig, load_config, save_config
from EvoScientist.config.provider_profiles import (
load_provider_profiles,
)
from EvoScientist.langgraph_dev.http import app
from EvoScientist.llm.provider_operations import (
DiscoveredProviderModel,
ProviderModelTestResult,
ProviderOperationError,
)
client = TestClient(app)
@pytest.fixture(autouse=True)
def isolate_xdg_config(tmp_path, monkeypatch):
"""Keep HTTP tests independent from the developer's saved providers."""
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
def test_provider_profiles_api_requires_admin_token_header(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False)
response = client.get("/api/provider-profiles")
assert response.status_code == 403
def test_provider_profiles_api_round_trip_redacts_secret(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
payload = {
"providers": [
{
"id": "lab-openai",
"name": "Lab OpenAI",
"adapter": "openai-compatible",
"base_url": "https://llm.example.test/v1",
"api_key": "provider-secret",
"enabled": True,
"runtime": {"timeout_seconds": 90, "max_retries": 1},
"models": [
{
"id": "lab-model",
"name": "Lab Model",
"model_id": "vendor/model",
"enabled": True,
"runtime": {
"limit_mode": "combined",
"context_window_tokens": 32_768,
"max_output_tokens": 4_096,
"min_effective_input_tokens": 4_096,
"limits_status": "confirmed",
"limits_source": "provider",
},
}
],
}
]
}
put_response = client.put("/api/provider-profiles", headers=headers, json=payload)
assert put_response.status_code == 200
assert "provider-secret" not in put_response.text
assert put_response.json()["providers"][0]["api_key_configured"] is True
assert "openai" in put_response.json()["reserved_provider_ids"]
get_response = client.get("/api/provider-profiles", headers=headers)
assert get_response.status_code == 200
assert get_response.json()["providers"][0]["models"][0]["id"] == "lab-model"
assert "provider-secret" not in get_response.text
def test_llm_config_api_requires_admin_token_header(monkeypatch):
monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False)
response = client.get("/api/config")
assert response.status_code == 403
def test_llm_config_api_redacts_secrets_and_reports_env_overrides(monkeypatch):
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
monkeypatch.setenv("OPENAI_API_KEY", "environment-secret")
save_config(
EvoScientistConfig(
provider="openai",
model="gpt-5.4",
openai_api_key="file-secret-1234",
default_workdir="/tmp/research",
)
)
response = client.get(
"/api/config",
headers={"X-EvoScientist-Admin-Token": "admin-secret"},
)
assert response.status_code == 200
body = response.json()
assert body["values"]["provider"] == "openai"
assert body["values"]["model"] == "gpt-5.4"
assert "openai_api_key" not in body["values"]
assert body["secrets"]["openai_api_key"] == {
"configured": True,
"hint": "...1234",
}
assert body["env_overrides"]["openai_api_key"] == "OPENAI_API_KEY"
assert "file-secret-1234" not in response.text
assert "environment-secret" not in response.text
assert "default_workdir" not in body["values"]
assert body["model_catalog"] is None
assert body["builtin_model_candidates"]
openai = next(
provider for provider in body["builtin_providers"] if provider["id"] == "openai"
)
assert openai["managed"] is False
assert openai["api_key_configured"] is True
assert "environment-secret" not in response.text
def test_llm_config_api_saves_builtin_registry_without_rewriting_legacy_secret(
monkeypatch,
):
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
save_config(
EvoScientistConfig(
provider="openai",
model="chat-main",
openai_api_key="legacy-secret",
default_workdir="/tmp/research",
)
)
loaded = client.get("/api/config", headers=headers).json()
openai = next(
provider
for provider in loaded["builtin_providers"]
if provider["id"] == "openai"
)
openai.update(
{
"managed": True,
"base_url": "https://proxy.example.test/v1",
"models": [
{
"id": "chat-main",
"name": "Chat Main",
"model_id": "gpt-upstream",
"enabled": True,
}
],
}
)
response = client.patch(
"/api/config",
headers=headers,
json={
"revision": loaded["revision"],
"values": loaded["values"],
"secrets": {},
"clear_secrets": [],
"builtin_providers": [openai],
},
)
assert response.status_code == 200
body = response.json()
assert body["restart_required"] is False
assert "builtin_providers" in body["changed_fields"]
saved_profile = load_provider_profiles().builtins[0]
assert saved_profile.id == "openai"
assert saved_profile.api_key == "legacy-secret"
assert saved_profile.base_url == "https://proxy.example.test/v1"
saved_config = load_config()
assert saved_config.openai_api_key == "legacy-secret"
assert saved_config.default_workdir == "/tmp/research"
assert saved_config.model_catalog is None
def test_first_builtin_registry_save_migrates_legacy_default_model(monkeypatch):
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://environment.example.test")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
save_config(EvoScientistConfig(provider="anthropic", model="claude-sonnet-4-6"))
loaded = client.get("/api/config", headers=headers).json()
ollama = next(
provider
for provider in loaded["builtin_providers"]
if provider["id"] == "ollama"
)
ollama.update(
{
"managed": True,
"base_url": "http://127.0.0.1:11434",
"models": [],
}
)
response = client.patch(
"/api/config",
headers=headers,
json={
"revision": loaded["revision"],
"values": loaded["values"],
"secrets": {},
"clear_secrets": [],
"builtin_providers": [ollama],
},
)
assert response.status_code == 200
builtins = {profile.id: profile for profile in load_provider_profiles().builtins}
assert set(builtins) == {"anthropic", "ollama"}
assert builtins["anthropic"].models[0].id == "claude-sonnet-4-6"
assert builtins["anthropic"].base_url == ""
def test_llm_config_api_persists_model_catalog(monkeypatch):
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
save_config(EvoScientistConfig(provider="openai", model="chat-main"))
revision = client.get("/api/config", headers=headers).json()["revision"]
catalog = [
{
"provider": "openai",
"id": "chat-main",
"name": "Chat Main",
"model_id": "gpt-upstream",
"enabled": True,
}
]
response = client.patch(
"/api/config",
headers=headers,
json={
"revision": revision,
"values": {},
"secrets": {},
"clear_secrets": [],
"model_catalog": catalog,
},
)
assert response.status_code == 200
assert response.json()["model_catalog"] == catalog
assert response.json()["restart_required"] is False
assert load_config().model_catalog == catalog
def test_llm_config_api_rejects_default_outside_model_catalog(monkeypatch):
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
save_config(EvoScientistConfig(provider="openai", model="gpt-default"))
revision = client.get("/api/config", headers=headers).json()["revision"]
response = client.patch(
"/api/config",
headers=headers,
json={
"revision": revision,
"values": {},
"secrets": {},
"clear_secrets": [],
"model_catalog": [
{
"provider": "openai",
"id": "different-model",
"name": "Different",
"model_id": "gpt-different",
"enabled": True,
}
],
},
)
assert response.status_code == 400
assert "Default model" in response.json()["error"]
assert load_config().model_catalog is None
def test_llm_config_api_patch_preserves_unrelated_fields_and_secret_by_default(
monkeypatch,
):
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
save_config(
EvoScientistConfig(
openai_api_key="saved-openai-key",
anthropic_api_key="saved-anthropic-key",
default_workdir="/tmp/research",
)
)
revision = client.get("/api/config", headers=headers).json()["revision"]
response = client.patch(
"/api/config",
headers=headers,
json={
"revision": revision,
"values": {
"provider": "openai",
"model": "gpt-5.4",
"ollama_base_url": "http://127.0.0.1:11434",
},
"secrets": {"openai_api_key": "replacement-openai-key"},
"clear_secrets": ["anthropic_api_key"],
},
)
assert response.status_code == 200
body = response.json()
assert body["restart_required"] is True
assert set(body["changed_fields"]) == {
"anthropic_api_key",
"model",
"ollama_base_url",
"openai_api_key",
"provider",
}
assert "replacement-openai-key" not in response.text
saved = load_config()
assert saved.provider == "openai"
assert saved.model == "gpt-5.4"
assert saved.ollama_base_url == "http://127.0.0.1:11434"
assert saved.openai_api_key == "replacement-openai-key"
assert saved.anthropic_api_key == ""
assert saved.default_workdir == "/tmp/research"
def test_llm_config_api_rejects_stale_revision(monkeypatch):
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
save_config(EvoScientistConfig(model="claude-sonnet-4-6"))
revision = client.get("/api/config", headers=headers).json()["revision"]
save_config(EvoScientistConfig(model="gpt-5.4", provider="openai"))
response = client.patch(
"/api/config",
headers=headers,
json={
"revision": revision,
"values": {"model": "claude-opus-4-8"},
},
)
assert response.status_code == 409
assert "Reload and try again" in response.json()["error"]
assert load_config().model == "gpt-5.4"
@pytest.mark.parametrize(
("payload", "message"),
[
(
{"values": {"openai_auth_mode": "password"}},
"openai_auth_mode must be 'api_key' or 'oauth'",
),
(
{"values": {"default_workdir": "/tmp/other"}},
"Unsupported config fields: default_workdir",
),
(
{"values": {"ollama_base_url": "localhost:11434"}},
"ollama_base_url must use http:// or https://",
),
],
)
def test_llm_config_api_validates_updates(monkeypatch, payload, message):
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
revision = client.get("/api/config", headers=headers).json()["revision"]
response = client.patch(
"/api/config",
headers=headers,
json={"revision": revision, **payload},
)
assert response.status_code == 400
assert message in response.json()["error"]
def test_provider_actions_api_requires_admin_token_header(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False)
response = client.post("/api/provider-actions", json={})
assert response.status_code == 403
def test_provider_actions_lists_models_with_saved_api_key(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
provider = {
"id": "lab-openai",
"name": "Lab OpenAI",
"adapter": "openai-compatible",
"base_url": "https://llm.example.test/v1",
"api_key": "provider-secret",
"enabled": True,
"models": [],
}
assert (
client.put(
"/api/provider-profiles",
headers=headers,
json={"providers": [provider]},
).status_code
== 200
)
provider["api_key"] = ""
async def fake_discover(profile):
assert profile.api_key == "provider-secret"
return [DiscoveredProviderModel("vendor/model", "Vendor Model")]
with patch(
"EvoScientist.langgraph_dev.http.discover_provider_models",
new=fake_discover,
):
response = client.post(
"/api/provider-actions",
headers=headers,
json={"action": "list_models", "provider": provider},
)
assert response.status_code == 200
assert response.json() == {
"models": [{"model_id": "vendor/model", "name": "Vendor Model"}]
}
def test_provider_actions_tests_model(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
async def fake_test(profile, model):
assert profile.adapter == "grok"
assert model.model_id == "grok-4"
return ProviderModelTestResult(latency_ms=123, response="OK")
with patch("EvoScientist.langgraph_dev.http.test_provider_model", new=fake_test):
response = client.post(
"/api/provider-actions",
headers=headers,
json={
"action": "test_model",
"provider": {
"id": "lab-grok",
"name": "Lab Grok",
"adapter": "grok",
"base_url": "",
"api_key": "xai-secret",
"enabled": True,
"models": [],
},
"model": {
"id": "grok",
"name": "Grok",
"model_id": "grok-4",
},
},
)
assert response.status_code == 200
assert response.json() == {"ok": True, "latency_ms": 123, "response": "OK"}
def test_provider_actions_maps_provider_failures_to_bad_gateway(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
async def fake_discover(_profile):
raise ProviderOperationError("Provider returned HTTP 401")
with patch(
"EvoScientist.langgraph_dev.http.discover_provider_models",
new=fake_discover,
):
response = client.post(
"/api/provider-actions",
headers={"X-EvoScientist-Admin-Token": "admin-secret"},
json={
"action": "list_models",
"provider": {
"id": "lab-openai",
"name": "Lab OpenAI",
"adapter": "openai-compatible",
"base_url": "https://llm.example.test/v1",
"api_key": "bad-key",
"enabled": True,
"models": [],
},
},
)
assert response.status_code == 502
assert response.json() == {"error": "Provider returned HTTP 401"}
def test_llm_config_action_discovers_models_with_effective_builtin_secret(
monkeypatch,
):
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
monkeypatch.setenv("OPENAI_API_KEY", "environment-secret")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
async def fake_discover(profile):
assert profile.id == "openai"
assert profile.adapter == "openai"
assert profile.api_key == "environment-secret"
return [DiscoveredProviderModel("gpt-discovered", "GPT Discovered")]
with patch(
"EvoScientist.langgraph_dev.http.discover_provider_models",
new=fake_discover,
):
response = client.post(
"/api/config",
headers=headers,
json={"action": "list_models", "provider": {"id": "openai"}},
)
assert response.status_code == 200
assert response.json() == {
"models": [{"model_id": "gpt-discovered", "name": "GPT Discovered"}]
}
def test_llm_config_action_tests_builtin_model_with_draft_connection(monkeypatch):
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
async def fake_test(profile, model):
assert profile.id == "custom-openai"
assert profile.adapter == "openai-compatible"
assert profile.base_url == "https://proxy.example.test/v1"
assert profile.api_key == "draft-secret"
assert model.model_id == "vendor/model"
return ProviderModelTestResult(latency_ms=42, response="OK")
with patch("EvoScientist.langgraph_dev.http.test_provider_model", new=fake_test):
response = client.post(
"/api/config",
headers=headers,
json={
"action": "test_model",
"provider": {
"id": "custom-openai",
"base_url": "https://proxy.example.test/v1",
"api_key": "draft-secret",
},
"model": {
"id": "chat-main",
"name": "Chat Main",
"model_id": "vendor/model",
},
},
)
assert response.status_code == 200
assert response.json() == {"ok": True, "latency_ms": 42, "response": "OK"}
def test_default_model_api_requires_admin_token_header(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False)
response = client.put(
"/api/default-model",
json={"model": "gpt-5.4", "provider": "openai"},
)
assert response.status_code == 403
def test_default_model_api_persists_pair_and_preserves_config(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
save_config(EvoScientistConfig(default_workdir="/tmp/research"))
response = client.put(
"/api/default-model",
headers={"X-EvoScientist-Admin-Token": "admin-secret"},
json={"model": "gpt-5.4", "provider": "openai"},
)
assert response.status_code == 200
assert response.json() == {"default": {"name": "gpt-5.4", "provider": "openai"}}
saved = load_config()
assert saved.model == "gpt-5.4"
assert saved.provider == "openai"
assert saved.default_workdir == "/tmp/research"
def test_default_model_api_accepts_configured_dynamic_model(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
provider_payload = {
"providers": [
{
"id": "lab-openai",
"name": "Lab OpenAI",
"adapter": "openai-compatible",
"base_url": "https://llm.example.test/v1",
"api_key": "provider-secret",
"enabled": True,
"models": [
{
"id": "lab-model",
"name": "Lab Model",
"model_id": "vendor/model",
"enabled": True,
}
],
}
]
}
assert (
client.put(
"/api/provider-profiles", headers=headers, json=provider_payload
).status_code
== 200
)
response = client.put(
"/api/default-model",
headers=headers,
json={"model": "lab-model", "provider": "lab-openai"},
)
assert response.status_code == 200
saved = load_config()
assert (saved.model, saved.provider) == ("lab-model", "lab-openai")
def test_default_model_api_rejects_unconfigured_pair(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
response = client.put(
"/api/default-model",
headers={"X-EvoScientist-Admin-Token": "admin-secret"},
json={"model": "missing-model", "provider": "missing-provider"},
)
assert response.status_code == 400
assert "is not configured" in response.json()["error"]
def test_default_model_api_rejects_invalid_json(monkeypatch):
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
response = client.put(
"/api/default-model",
headers={
"X-EvoScientist-Admin-Token": "admin-secret",
"Content-Type": "application/json",
},
content="{invalid",
)
assert response.status_code == 400
assert response.json() == {"error": "Request body must be valid JSON."}
def test_final_answer_extracts_latest_ai_text_blocks():
async def fake_metadata(_thread_id):
return {"updated_at": "2026-07-06T14:14:53+00:00"}
async def fake_messages(_thread_id):
return [
HumanMessage(content="question"),
AIMessage(content="old answer"),
AIMessage(
content=[
{"type": "reasoning", "text": "internal"},
{"type": "text", "text": "Part A"},
{"type": "tool_use", "name": "search"},
{"type": "output_text", "text": "Part B"},
]
),
]
async def fake_runtime(_request, _thread_id):
return {
"found": True,
"complete": True,
"completed_at": "2026-07-06T14:15:00+00:00",
}
with (
patch(
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
new=fake_metadata,
),
patch(
"EvoScientist.langgraph_dev.http._get_thread_messages_for_http",
new=fake_messages,
),
patch(
"EvoScientist.langgraph_dev.http._read_thread_runtime_state",
new=fake_runtime,
),
):
resp = client.get("/api/threads/thread-1/final-answer")
assert resp.status_code == 200
assert resp.json() == {
"content": "Part A\n\nPart B",
"completed_at": "2026-07-06T14:15:00+00:00",
"complete": True,
}
def test_final_answer_skips_tool_selection_json_text():
async def fake_metadata(_thread_id):
return {"updated_at": "2026-07-06T14:14:53+00:00"}
async def fake_messages(_thread_id):
return [
HumanMessage(content="question"),
AIMessage(content="stable answer"),
AIMessage(
content=(
'{"tools":["search_papers","get_abstract"]}'
'{"tools":["web_search_exa"]}'
)
),
]
async def fake_runtime(_request, _thread_id):
return {
"found": True,
"complete": True,
"completed_at": "2026-07-06T14:15:00+00:00",
}
with (
patch(
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
new=fake_metadata,
),
patch(
"EvoScientist.langgraph_dev.http._get_thread_messages_for_http",
new=fake_messages,
),
patch(
"EvoScientist.langgraph_dev.http._read_thread_runtime_state",
new=fake_runtime,
),
):
resp = client.get("/api/threads/thread-1/final-answer")
assert resp.status_code == 200
assert resp.json()["content"] == "stable answer"
def test_final_answer_returns_404_for_unknown_thread():
async def fake_metadata(_thread_id):
return None
with patch(
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
new=fake_metadata,
):
resp = client.get("/api/threads/missing/final-answer")
assert resp.status_code == 404
assert resp.json() == {"error": "thread not found"}
def test_final_answer_does_not_mark_complete_when_runtime_state_fails():
async def fake_metadata(_thread_id):
return {"updated_at": "2026-07-06T14:14:53+00:00"}
async def fake_messages(_thread_id):
return [AIMessage(content="checkpoint answer")]
async def fake_runtime(_request, _thread_id):
raise RuntimeError("langgraph runtime unavailable")
with (
patch(
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
new=fake_metadata,
),
patch(
"EvoScientist.langgraph_dev.http._get_thread_messages_for_http",
new=fake_messages,
),
patch(
"EvoScientist.langgraph_dev.http._read_thread_runtime_state",
new=fake_runtime,
),
):
resp = client.get("/api/threads/thread-1/final-answer")
assert resp.status_code == 200
assert resp.json() == {
"content": "checkpoint answer",
"completed_at": None,
"complete": False,
}