Files
dinos bd307f3a11 refactor: LangGraph gateway layer for UI-agnostic graph and thread access (#295)
* feat(gateway): graph gateway protocol

* refactor(cli): wire gateway in cli/tui

* refactor(gateway): centralize runtime gateway init

* chore(gateway): restrict RunRequest message type

* feat(gateway): add langgraph server gateway

* chore(cli): tighten serve runtime state typing

* refactor(cli): route async task state reads through graph gateway

* refactor(gateway): support graph targets in server gateway

* refactor(cli): route session commands through graph gateway

* refactor(cli): fold thread store under graph gateway

* refactor(gateway): route graph state access through gateway

* refactor(channels): wire graph gateway

* refactor(memory): preserve graph threads for cloning

* feat(gateway): add thread cloning

* fix(tui): pass effective workspace for thread creation

* chore(memory): add workspare dir to memory worker metadata

* fix(sessions): filter preloaded UUID registy entries by the current scope

* test(fakes): use https

* refactor(consumer): consolidate imports

* fix(stream): optional summarization event

* fix(gateway): resolve abbreviated thread IDs by search

* fix(gateway): page server thread listings

* fix(gateway): emit pending interrupt events

* style: fmt

* feat(gateway): persist workspace_dir & model in thread metadata

* fix(gateway): page server thread prefix resolution

* fix(gateway): expose server thread list metadata

* refactor: add back type def

* refactor: tighten types

* revert: add back worker thread deletion

The worker thread forking changes are out of scope for now, so to
maintain parity with the existing behavior we'll leave this intact.

* fix(gateway): apply compaction to server thread history

* refactor(stream): restore direct summary replay suppression

* fix(gateway): preserve compaction state and server stream output

* fix(gateway): close local stream generator on cancellation

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-06-22 13:54:14 +00:00

654 lines
20 KiB
Python

"""Shared test doubles for gateway/runtime boundaries."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Iterable
from dataclasses import dataclass
from typing import Any
import httpx
from langgraph_sdk.client import LangGraphClient
from EvoScientist.channels.base import Channel
from EvoScientist.channels.bus.events import InboundMessage, OutboundMessage
from EvoScientist.commands.base import CommandUI
from EvoScientist.gateway import (
GraphEvent,
GraphGateway,
GraphStateValues,
GraphTarget,
RunRequest,
ThreadResolution,
ThreadStore,
)
_DEFAULT_COPY_RESPONSE = object()
class FakeCommandUI(CommandUI):
"""Command UI test double with recorded calls and inert pickers."""
def __init__(self, *, supports_interactive: bool = True) -> None:
self._supports_interactive = supports_interactive
self.system_messages: list[str] = []
self.renderables: list[object] = []
self.started = 0
self.stopped = 0
self.updated_tokens: list[int] = []
self.chat_cleared = False
self.quit_requested = False
self.force_quit_requested = False
self.started_sessions = 0
self.resumed_sessions: list[tuple[str, str | None]] = []
self.flushes = 0
@property
def supports_interactive(self) -> bool:
return self._supports_interactive
def append_system(self, text: str, style: str = "dim") -> None:
self.system_messages.append(text)
def mount_renderable(self, renderable: object) -> None:
self.renderables.append(renderable)
async def wait_for_thread_pick(
self,
threads: list[dict],
current_thread: str,
title: str,
) -> str | None:
return None
async def wait_for_skill_browse(
self,
index: list[dict],
installed_names: set[str],
pre_filter_tag: str,
) -> list[str] | None:
return None
async def wait_for_mcp_browse(
self,
servers: list,
installed_names: set[str],
pre_filter_tag: str,
) -> list | None:
return None
async def wait_for_model_pick(
self,
entries: list[tuple[str, str, str]],
current_model: str | None,
current_provider: str | None,
) -> tuple[str, str] | None:
return None
def clear_chat(self) -> None:
self.chat_cleared = True
def request_quit(self) -> None:
self.quit_requested = True
def force_quit(self) -> None:
self.force_quit_requested = True
async def start_new_session(self) -> None:
self.started_sessions += 1
async def handle_session_resume(
self,
thread_id: str,
workspace_dir: str | None = None,
) -> None:
self.resumed_sessions.append((thread_id, workspace_dir))
async def flush(self) -> None:
self.flushes += 1
async def start_compacting_indicator(self) -> None:
self.started += 1
async def stop_compacting_indicator(self) -> None:
self.stopped += 1
def update_status_after_compact(self, tokens_after: int) -> None:
self.updated_tokens.append(tokens_after)
@dataclass
class FakeChannelConfig:
"""Minimal config surface consumed by channel base tests."""
text_chunk_limit: int = 4096
allowed_senders: list | None = None
allowed_channels: list | None = None
proxy: str | None = None
require_mention: str = "group"
dm_policy: str = "allowlist"
class StubChannel(Channel):
"""Minimal concrete channel for unit tests of channel base behavior."""
name = "stub"
def __init__(self, config: Any | None = None) -> None:
super().__init__(config or FakeChannelConfig())
self._sent_chunks: list[tuple] = []
self._typing_started: list[str] = []
self._typing_stopped: list[str] = []
self._started = False
async def start(self) -> None:
self._started = True
self._running = True
async def _send_chunk(
self,
chat_id: str,
formatted_text: str,
raw_text: str,
reply_to: str | None,
metadata: dict,
) -> None:
self._sent_chunks.append(
(chat_id, formatted_text, raw_text, reply_to, metadata)
)
async def _send_typing_action(self, chat_id: str) -> None:
self._typing_started.append(chat_id)
class QueueFakeChannel(Channel):
"""Concrete channel with queue receive and captured outbound messages."""
name = "fake"
def __init__(self, config: Any | None = None) -> None:
super().__init__(config or FakeChannelConfig())
self._started = False
self._stopped = False
self._sent: list[OutboundMessage] = []
async def start(self) -> None:
self._started = True
async def stop(self) -> None:
self._stopped = True
async def receive(self) -> AsyncIterator[InboundMessage]:
while True:
try:
msg = await asyncio.wait_for(self._queue.get(), timeout=0.5)
yield msg
except TimeoutError:
return
async def send(self, message: OutboundMessage) -> bool:
self._sent.append(message)
return True
async def _send_chunk(
self,
chat_id: str,
formatted_text: str,
raw_text: str,
reply_to: str | None,
metadata: dict,
) -> None:
pass
class FakeThreadStore(ThreadStore):
"""Configurable ``ThreadStore`` test double with call recording."""
def __init__(
self,
*,
generated_thread_id: str = "unused",
threads: list[dict[str, Any]] | None = None,
resolved_thread_id: str | None = None,
matches: list[str] | None = None,
metadata: dict[str, Any] | None = None,
messages: list[Any] | None = None,
exists: bool = False,
deleted: bool = False,
errors: dict[str, BaseException] | None = None,
) -> None:
self.generated_thread_id = generated_thread_id
self.threads = threads or []
self.resolved_thread_id = resolved_thread_id
self.matches = matches or []
self.metadata = metadata
self.messages = messages or []
self.exists = exists
self.deleted = deleted
self.errors = errors or {}
self.calls: list[tuple[str, Any]] = []
def _maybe_raise(self, method: str) -> None:
error = self.errors.get(method)
if error is not None:
raise error
def generate_thread_id(self) -> str:
self.calls.append(("generate_thread_id", None))
self._maybe_raise("generate_thread_id")
return self.generated_thread_id
async def list_threads(
self,
*,
limit: int = 20,
include_message_count: bool = False,
include_preview: bool = False,
) -> list[dict[str, Any]]:
self.calls.append(
(
"list_threads",
{
"limit": limit,
"include_message_count": include_message_count,
"include_preview": include_preview,
},
)
)
self._maybe_raise("list_threads")
return self.threads
async def resolve_thread_id_prefix(
self,
thread_id_or_prefix: str,
) -> tuple[str | None, list[str]]:
self.calls.append(("resolve_thread_id_prefix", thread_id_or_prefix))
self._maybe_raise("resolve_thread_id_prefix")
return self.resolved_thread_id, self.matches
async def get_thread_metadata(self, thread_id: str) -> dict[str, Any] | None:
self.calls.append(("get_thread_metadata", thread_id))
self._maybe_raise("get_thread_metadata")
return self.metadata
async def get_thread_messages(self, thread_id: str) -> list[Any]:
self.calls.append(("get_thread_messages", thread_id))
self._maybe_raise("get_thread_messages")
return self.messages
async def thread_exists(self, thread_id: str) -> bool:
self.calls.append(("thread_exists", thread_id))
self._maybe_raise("thread_exists")
return self.exists
async def delete_thread(self, thread_id: str) -> bool:
self.calls.append(("delete_thread", thread_id))
self._maybe_raise("delete_thread")
return self.deleted
FakeStreamFactory = Callable[[RunRequest], AsyncIterator[GraphEvent]]
class FakeGraphGateway(GraphGateway):
"""Configurable graph gateway test double with request recording."""
def __init__(
self,
events: Iterable[GraphEvent] | None = None,
*,
stream: FakeStreamFactory | None = None,
state_values: GraphStateValues | None = None,
state_error: BaseException | None = None,
generated_thread_ids: Iterable[str] | None = None,
thread_store: ThreadStore | None = None,
) -> None:
self.events = list(events or [])
self.stream = stream
self.state_values = state_values or {}
self.state_error = state_error
self.generated_thread_ids = list(generated_thread_ids or [])
self.thread_store = thread_store or FakeThreadStore()
self.requests: list[RunRequest] = []
self.clone_calls: list[
tuple[str, dict[str, Any] | None, GraphTarget | None]
] = []
self.updated_states: list[tuple[GraphTarget, str, GraphStateValues]] = []
async def create_thread(
self,
target: GraphTarget | None = None,
*,
metadata: dict[str, Any] | None = None,
) -> str:
if self.generated_thread_ids:
return self.generated_thread_ids.pop(0)
return self.thread_store.generate_thread_id()
async def list_threads(
self,
*,
limit: int = 20,
include_message_count: bool = False,
include_preview: bool = False,
target: GraphTarget | None = None,
) -> list[dict[str, Any]]:
return await self.thread_store.list_threads(
limit=limit,
include_message_count=include_message_count,
include_preview=include_preview,
)
async def resolve_thread(
self,
thread_id_or_prefix: str,
target: GraphTarget | None = None,
) -> ThreadResolution:
resolved, matches = await self.thread_store.resolve_thread_id_prefix(
thread_id_or_prefix
)
return ThreadResolution(resolved, tuple(matches))
async def get_thread_metadata(
self,
thread_id: str,
target: GraphTarget | None = None,
) -> dict[str, Any] | None:
return await self.thread_store.get_thread_metadata(thread_id)
async def get_thread_messages(
self,
thread_id: str,
target: GraphTarget | None = None,
) -> list[Any]:
return await self.thread_store.get_thread_messages(thread_id)
async def thread_exists(
self,
thread_id: str,
target: GraphTarget | None = None,
) -> bool:
return await self.thread_store.thread_exists(thread_id)
async def delete_thread(
self,
thread_id: str,
target: GraphTarget | None = None,
) -> bool:
return await self.thread_store.delete_thread(thread_id)
async def clone_thread(
self,
source_thread_id: str,
*,
metadata: dict[str, Any] | None = None,
target: GraphTarget | None = None,
) -> str:
self.clone_calls.append((source_thread_id, metadata, target))
if self.generated_thread_ids:
return self.generated_thread_ids.pop(0)
return f"{source_thread_id}-clone"
def stream_events(self, request: RunRequest) -> AsyncIterator[GraphEvent]:
self.requests.append(request)
if self.stream is not None:
return self.stream(request)
async def _events() -> AsyncIterator[GraphEvent]:
for event in self.events:
yield event
return _events()
async def get_state_values(
self,
target: GraphTarget,
thread_id: str,
) -> GraphStateValues:
if self.state_error is not None:
raise self.state_error
return self.state_values
async def update_state_values(
self,
target: GraphTarget,
thread_id: str,
values: GraphStateValues,
) -> None:
self.updated_states.append((target, thread_id, values))
class FakeLangGraphRunModule:
"""Fake thread-stream run controller for server gateway tests."""
def __init__(self) -> None:
self.starts: list[dict[str, Any]] = []
self.responses: list[dict[str, Any]] = []
async def start(
self,
*,
input: object = None,
config: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
self.starts.append(
{
"input": input,
"config": config,
"metadata": metadata,
}
)
return {"run_id": "run-1"}
async def respond(
self,
response: object,
*,
interrupt_id: str | None = None,
) -> dict[str, Any]:
self.responses.append(
{
"response": response,
"interrupt_id": interrupt_id,
}
)
return {"run_id": "run-1"}
class FakeLangGraphThreadStream:
"""Finite fake of the LangGraph SDK thread stream."""
def __init__(
self,
thread_id: str,
events: Iterable[dict[str, Any]] | None = None,
*,
interrupts: list[dict[str, Any]] | None = None,
interrupted: bool = False,
) -> None:
self.thread_id = thread_id
self.events = list(events or [])
self.interrupts = interrupts or []
self.interrupted = interrupted
self.run = FakeLangGraphRunModule()
self.subscribed_channels: list[list[str]] = []
self.entered = False
self.exited = False
async def __aenter__(self) -> FakeLangGraphThreadStream:
self.entered = True
return self
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
self.exited = True
async def _iter_events(self) -> AsyncIterator[dict[str, Any]]:
for event in self.events:
yield event
def subscribe(self, channels: list[str]) -> AsyncIterator[dict[str, Any]]:
self.subscribed_channels.append(channels)
return self._iter_events()
class FakeLangGraphThreadsClient:
"""Fake LangGraph ``client.threads`` surface."""
def __init__(
self,
*,
threads: list[dict[str, Any]] | None = None,
states: dict[str, dict[str, Any]] | None = None,
streams: dict[str, FakeLangGraphThreadStream] | None = None,
copy_response: object = _DEFAULT_COPY_RESPONSE,
) -> None:
self.threads = threads or []
self.states = states or {}
self.streams = streams or {}
self.copy_response = copy_response
self.created: list[dict[str, Any]] = []
self.copied: list[str] = []
self.metadata_updates: list[tuple[str, dict[str, Any]]] = []
self.deleted: list[str] = []
self.gets: list[str] = []
self.searches: list[dict[str, Any]] = []
self.stream_calls: list[tuple[str, str]] = []
self.state_updates: list[tuple[str, GraphStateValues, str | None]] = []
async def create(
self,
*,
metadata: dict[str, Any] | None = None,
thread_id: str | None = None,
if_exists: str | None = None,
graph_id: str | None = None,
) -> dict[str, Any]:
if thread_id is not None and if_exists == "do_nothing":
for thread in self.threads:
if thread.get("thread_id") == thread_id:
return thread
created = {
"thread_id": thread_id or "server-thread",
"metadata": {
**(metadata or {}),
**({"graph_id": graph_id} if graph_id else {}),
},
}
self.created.append(created)
self.threads.append(created)
return created
async def search(
self,
*,
metadata: dict[str, Any] | None = None,
limit: int = 10,
offset: int = 0,
sort_by: str | None = None,
sort_order: str | None = None,
) -> list[dict[str, Any]]:
self.searches.append(
{
"metadata": metadata,
"limit": limit,
"offset": offset,
"sort_by": sort_by,
"sort_order": sort_order,
}
)
rows = self.threads
if metadata:
rows = [
thread
for thread in rows
if all(
(thread.get("metadata") or {}).get(key) == value
for key, value in metadata.items()
)
]
return rows[offset : offset + limit]
async def get(self, thread_id: str) -> dict[str, Any]:
from langgraph_sdk.errors import NotFoundError
self.gets.append(thread_id)
for thread in self.threads:
if thread.get("thread_id") == thread_id:
return thread
raise NotFoundError("not found", response=_not_found_response(), body=None)
async def copy(self, thread_id: str) -> object:
source = await self.get(thread_id)
self.copied.append(thread_id)
if self.copy_response is not _DEFAULT_COPY_RESPONSE:
return self.copy_response
copied = {
"thread_id": f"{thread_id}-copy",
"metadata": dict(source.get("metadata") or {}),
}
self.threads.append(copied)
return copied
async def update(
self,
thread_id: str,
*,
metadata: dict[str, Any],
) -> dict[str, Any]:
thread = await self.get(thread_id)
existing_metadata = thread.get("metadata")
merged = {
**(existing_metadata if isinstance(existing_metadata, dict) else {}),
**metadata,
}
thread["metadata"] = merged
self.metadata_updates.append((thread_id, metadata))
return thread
async def get_state(self, thread_id: str) -> dict[str, Any]:
from langgraph_sdk.errors import NotFoundError
if thread_id in self.states:
return self.states[thread_id]
raise NotFoundError("not found", response=_not_found_response(), body=None)
async def update_state(
self,
thread_id: str,
values: GraphStateValues,
*,
as_node: str | None = None,
) -> dict[str, Any]:
self.state_updates.append((thread_id, values, as_node))
return {"checkpoint": {"thread_id": thread_id}}
async def delete(self, thread_id: str) -> None:
await self.get(thread_id)
self.deleted.append(thread_id)
self.threads = [
thread for thread in self.threads if thread.get("thread_id") != thread_id
]
def stream(
self,
thread_id: str | None = None,
*,
assistant_id: str,
) -> FakeLangGraphThreadStream:
assert thread_id is not None
self.stream_calls.append((thread_id, assistant_id))
return self.streams[thread_id]
class FakeLangGraphClient(LangGraphClient):
"""Fake LangGraph SDK async client."""
def __init__(self, threads: FakeLangGraphThreadsClient) -> None:
self.threads = threads
def _not_found_response() -> httpx.Response:
request = httpx.Request("GET", "https://test.local/not-found")
return httpx.Response(404, request=request)