bd307f3a11
* 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>
654 lines
20 KiB
Python
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)
|