Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

668 lines
28 KiB
Python

"""PartnerRunner: chat-loop event mapping, tool config, session persistence."""
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from deeptutor.core.stream import StreamEvent, StreamEventType
from deeptutor.partners.bus.events import InboundMessage
from deeptutor.partners.bus.queue import MessageBus
from deeptutor.services.partners.manager import PartnerConfig
from deeptutor.services.partners.runtime import PartnerRunner
from deeptutor.services.partners.sessions import PartnerSessionStore
def _event(
event_type: StreamEventType,
*,
content: str = "",
source: str = "chat",
metadata: dict[str, Any] | None = None,
) -> StreamEvent:
return StreamEvent(type=event_type, source=source, content=content, metadata=metadata or {})
def _narration_round(call_id: str, text: str) -> list[StreamEvent]:
return [
_event(StreamEventType.CONTENT, content=text, metadata={"call_id": call_id}),
_event(
StreamEventType.PROGRESS,
metadata={
"trace_kind": "call_status",
"call_state": "complete",
"call_role": "narration",
"call_id": call_id,
},
),
]
def _finish(text: str) -> list[StreamEvent]:
return [
_event(StreamEventType.CONTENT, content=text, metadata={"call_id": "c-finish"}),
_event(StreamEventType.RESULT, metadata={"response": text}),
_event(StreamEventType.DONE),
]
class _FakeOrchestrator:
"""Yields a scripted event sequence instead of running the chat loop."""
script: list[StreamEvent] = []
# Optional queue of per-turn scripts; when non-empty, each handle() call
# pops the next one (lets tests model a failed turn + a backup retry).
scripts: list[list[StreamEvent]] = []
seen_contexts: list[Any] = []
activated_selections: list[Any] = []
# The memory root in effect while the turn runs — proves the partner reads
# the owner's (admin) memory via memory_path_service_override, not its own.
seen_memory_roots: list[Any] = []
def __init__(self) -> None:
pass
async def handle(self, context):
from deeptutor.services.memory.paths import memory_root
type(self).seen_contexts.append(context)
type(self).seen_memory_roots.append(memory_root())
script = type(self).scripts.pop(0) if type(self).scripts else type(self).script
for event in script:
yield event
@pytest.fixture
def fake_orchestrator(monkeypatch):
import deeptutor.runtime.orchestrator as orch_mod
from deeptutor.services.model_selection import runtime as selection_runtime
_FakeOrchestrator.script = []
_FakeOrchestrator.scripts = []
_FakeOrchestrator.seen_contexts = []
_FakeOrchestrator.activated_selections = []
_FakeOrchestrator.seen_memory_roots = []
monkeypatch.setattr(orch_mod, "ChatOrchestrator", _FakeOrchestrator)
def _record_activate(selection):
_FakeOrchestrator.activated_selections.append(selection)
return (None, None)
monkeypatch.setattr(selection_runtime, "activate_llm_selection", _record_activate)
monkeypatch.setattr(selection_runtime, "reset_llm_selection", lambda token: None)
return _FakeOrchestrator
def _runner(partners_root, config: PartnerConfig | None = None) -> PartnerRunner:
from deeptutor.partners.config.paths import get_partner_sessions_dir
config = config or PartnerConfig(name="Ada")
bus = MessageBus()
store = PartnerSessionStore(get_partner_sessions_dir("ada"))
return PartnerRunner("ada", config, bus, store)
def _msg(content: str = "hello", channel: str = "telegram") -> InboundMessage:
return InboundMessage(channel=channel, sender_id="42", chat_id="42", content=content)
class TestTurnExecution:
@pytest.mark.asyncio
async def test_returns_finish_text_and_persists_session(self, partners_root, fake_orchestrator):
fake_orchestrator.script = _narration_round("c1", "let me check") + _finish(
"The answer is 4."
)
runner = _runner(partners_root)
final = await runner.process_message(_msg("what is 2+2?"))
assert final == "The answer is 4."
history = runner.store.conversation_history("telegram:42")
assert history == [
{"role": "user", "content": "what is 2+2?"},
{"role": "assistant", "content": "The answer is 4."},
]
@pytest.mark.asyncio
async def test_narration_streams_as_progress_outbound(self, partners_root, fake_orchestrator):
fake_orchestrator.script = _narration_round("c1", "exploring…") + _finish("done")
runner = _runner(partners_root)
await runner.process_message(_msg())
progress = await runner.bus.outbound.get()
assert progress.content == "exploring…"
assert progress.metadata["_progress"] is True
assert progress.metadata["_tool_hint"] is False
@pytest.mark.asyncio
async def test_tool_calls_stream_as_hints_by_default(self, partners_root, fake_orchestrator):
fake_orchestrator.script = [
_event(
StreamEventType.TOOL_CALL,
content="rag",
metadata={"args": {"query": "hello world", "_internal": "x"}},
),
*_finish("done"),
]
runner = _runner(partners_root)
await runner.process_message(_msg())
hint = await runner.bus.outbound.get()
assert hint.metadata["_tool_hint"] is True
assert hint.content.startswith("⚙ rag(")
assert "hello world" in hint.content
assert "_internal" not in hint.content
@pytest.mark.asyncio
async def test_send_progress_flag_off_suppresses_narration(
self, partners_root, fake_orchestrator
):
fake_orchestrator.script = _narration_round("c1", "exploring…") + _finish("done")
config = PartnerConfig(name="Ada", channels={"telegram": {"send_progress": False}})
runner = _runner(partners_root, config)
await runner.process_message(_msg())
assert runner.bus.outbound.empty()
@pytest.mark.asyncio
async def test_web_channel_never_emits_progress_outbound(
self, partners_root, fake_orchestrator
):
fake_orchestrator.script = _narration_round("c1", "exploring…") + _finish("done")
runner = _runner(partners_root)
await runner.process_message(_msg(channel="web"))
assert runner.bus.outbound.empty()
@pytest.mark.asyncio
async def test_unresolved_ask_user_question_becomes_reply(
self, partners_root, fake_orchestrator
):
# An unresolved ask_user pause emits the question as a final-response
# CONTENT event while RESULT carries an empty response.
fake_orchestrator.script = [
_event(
StreamEventType.CONTENT,
content="Which topic do you mean?",
metadata={"call_id": "f1", "call_kind": "llm_final_response"},
),
_event(StreamEventType.RESULT, metadata={"response": ""}),
_event(StreamEventType.DONE),
]
runner = _runner(partners_root)
final = await runner.process_message(_msg())
assert final == "Which topic do you mean?"
@pytest.mark.asyncio
async def test_backup_model_retries_failed_turn(self, partners_root, fake_orchestrator):
primary = {"profile_id": "p1", "model_id": "m1"}
backup = {"profile_id": "p2", "model_id": "m2"}
fake_orchestrator.scripts = [
# Turn 1 (primary): hard failure, no answer.
[
_event(StreamEventType.ERROR, content="rate limited"),
_event(StreamEventType.RESULT, metadata={"response": ""}),
_event(StreamEventType.DONE),
],
# Turn 2 (backup): succeeds.
_finish("backup answer"),
]
config = PartnerConfig(name="Ada", llm_selection=primary, backup_llm_selection=backup)
runner = _runner(partners_root, config)
final = await runner.process_message(_msg())
assert final == "backup answer"
assert fake_orchestrator.activated_selections == [primary, backup]
@pytest.mark.asyncio
async def test_no_backup_returns_error_text(self, partners_root, fake_orchestrator):
fake_orchestrator.script = [
_event(StreamEventType.ERROR, content="rate limited"),
_event(StreamEventType.RESULT, metadata={"response": ""}),
_event(StreamEventType.DONE),
]
runner = _runner(partners_root)
final = await runner.process_message(_msg())
assert "rate limited" in final
assert len(fake_orchestrator.seen_contexts) == 1
@pytest.mark.asyncio
async def test_llm_config_error_folds_into_graceful_reply(
self, partners_root, fake_orchestrator, monkeypatch
):
# A setup failure with no resolvable LLM model (LLMConfigError) must
# fold into the turn's error path — an apology carrying the real reason
# — instead of propagating as an opaque crash / bare "Internal error".
from deeptutor.services.llm.exceptions import LLMConfigError
from deeptutor.services.model_selection import runtime as selection_runtime
def _raise(selection):
raise LLMConfigError("No active LLM model is configured.")
monkeypatch.setattr(selection_runtime, "activate_llm_selection", _raise)
runner = _runner(partners_root)
final = await runner.process_message(_msg("hi"))
assert "No active LLM model is configured." in final
# The orchestrator is never reached when LLM-selection resolution fails.
assert fake_orchestrator.seen_contexts == []
@pytest.mark.asyncio
async def test_backup_retried_when_primary_selection_unresolvable(
self, partners_root, fake_orchestrator, monkeypatch
):
# Selection resolution now runs inside the turn's try, so a primary
# model that no longer resolves falls back to the backup model instead
# of crashing the turn outright.
from deeptutor.services.llm.exceptions import LLMConfigError
from deeptutor.services.model_selection import runtime as selection_runtime
primary = {"profile_id": "p1", "model_id": "m1"}
backup = {"profile_id": "p2", "model_id": "m2"}
attempted: list[Any] = []
def _activate(selection):
attempted.append(selection)
if selection == primary:
raise LLMConfigError("primary profile is gone")
return (None, None)
monkeypatch.setattr(selection_runtime, "activate_llm_selection", _activate)
fake_orchestrator.script = _finish("backup answer")
config = PartnerConfig(name="Ada", llm_selection=primary, backup_llm_selection=backup)
runner = _runner(partners_root, config)
final = await runner.process_message(_msg())
assert final == "backup answer"
assert attempted == [primary, backup]
@pytest.mark.asyncio
async def test_successful_turn_never_touches_backup(self, partners_root, fake_orchestrator):
backup = {"profile_id": "p2", "model_id": "m2"}
fake_orchestrator.script = _finish("first try works")
config = PartnerConfig(name="Ada", backup_llm_selection=backup)
runner = _runner(partners_root, config)
final = await runner.process_message(_msg())
assert final == "first try works"
assert fake_orchestrator.activated_selections == [None]
@pytest.mark.asyncio
async def test_inbound_handler_publishes_reply_outbound(self, partners_root, fake_orchestrator):
fake_orchestrator.script = _finish("reply text")
runner = _runner(partners_root)
await runner._handle_inbound(_msg())
out = await runner.bus.outbound.get()
assert out.channel == "telegram"
assert out.chat_id == "42"
assert out.content == "reply text"
class TestContextAssembly:
@pytest.mark.asyncio
async def test_context_carries_soul_tools_and_metadata(self, partners_root, fake_orchestrator):
from deeptutor.services.partners.workspace import write_soul
write_soul("ada", "# Soul\nBe kind.")
fake_orchestrator.script = _finish("ok")
config = PartnerConfig(
name="Ada",
language="zh",
enabled_tools=["web_search"],
mcp_tools=["mcp_github_search"],
)
runner = _runner(partners_root, config)
await runner.process_message(
InboundMessage(
channel="telegram",
sender_id="42",
chat_id="42",
content="hello",
metadata={
"message_id": "m-1",
"thread_ts": "111.222",
"_cron_job_id": "cron-1",
"_wants_stream": True,
},
)
)
context = fake_orchestrator.seen_contexts[0]
assert context.persona_context == "# Soul\nBe kind."
assert context.enabled_tools == ["web_search"]
assert context.metadata["mcp_tools_filter"] == ["mcp_github_search"]
assert context.metadata["channel_metadata"] == {
"message_id": "m-1",
"thread_ts": "111.222",
}
assert context.metadata["cron_job_id"] == "cron-1"
assert context.language == "zh"
assert context.active_capability == "chat"
assert context.metadata["partner_id"] == "ada"
assert context.metadata["agent_identity"]["name"] == "Ada"
assert "wait_for_user_reply" not in context.metadata
@pytest.mark.asyncio
async def test_default_tools_resolve_to_full_toggleable_set(
self, partners_root, fake_orchestrator
):
from deeptutor.agents._shared.tool_composition import default_optional_tools
fake_orchestrator.script = _finish("ok")
runner = _runner(partners_root) # enabled_tools=None
await runner.process_message(_msg())
context = fake_orchestrator.seen_contexts[0]
assert context.enabled_tools == default_optional_tools()
assert "mcp_tools_filter" not in context.metadata
@pytest.mark.asyncio
async def test_history_feeds_next_turn(self, partners_root, fake_orchestrator):
fake_orchestrator.script = _finish("first reply")
runner = _runner(partners_root)
await runner.process_message(_msg("first question"))
fake_orchestrator.script = _finish("second reply")
await runner.process_message(_msg("second question"))
context = fake_orchestrator.seen_contexts[-1]
assert {"role": "user", "content": "first question"} in context.conversation_history
assert {
"role": "assistant",
"content": "first reply",
} in context.conversation_history
@pytest.mark.asyncio
async def test_image_media_becomes_context_attachment_and_session_record(
self, partners_root, fake_orchestrator
):
image_path = partners_root / "image.png"
image_path.parent.mkdir(parents=True, exist_ok=True)
image_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
fake_orchestrator.script = _finish("saw it")
runner = _runner(partners_root)
msg = _msg("what is in this image?")
msg.media = [str(image_path)]
await runner.process_message(msg)
context = fake_orchestrator.seen_contexts[-1]
assert len(context.attachments) == 1
assert context.attachments[0].type == "image"
assert context.attachments[0].filename == "image.png"
records = runner.store.messages("telegram:42")
assert records[0]["attachments"][0]["type"] == "image"
assert records[0]["attachments"][0]["filename"] == "image.png"
@pytest.mark.asyncio
async def test_document_media_becomes_attached_source(self, partners_root, fake_orchestrator):
doc_path = partners_root / "notes.txt"
doc_path.parent.mkdir(parents=True, exist_ok=True)
doc_path.write_text("Gradient descent uses a learning rate.", encoding="utf-8")
fake_orchestrator.script = _finish("noted")
runner = _runner(partners_root)
msg = _msg("summarize this")
msg.media = [str(doc_path)]
await runner.process_message(msg)
context = fake_orchestrator.seen_contexts[-1]
assert "notes.txt" in context.source_manifest
source_index = context.metadata["source_index"]
assert len(source_index) == 1
assert "Gradient descent" in next(iter(source_index.values()))
records = runner.store.messages("telegram:42")
attachment = records[0]["attachments"][0]
assert attachment["filename"] == "notes.txt"
assert "Gradient descent" in attachment["extracted_text"]
class TestBuiltinToolsAndMemory:
@pytest.mark.asyncio
async def test_builtin_tools_default_to_no_gating(self, partners_root, fake_orchestrator):
fake_orchestrator.script = _finish("ok")
runner = _runner(partners_root) # builtin_tools=None
await runner.process_message(_msg())
context = fake_orchestrator.seen_contexts[0]
# None = no gating: every built-in mounts under its usual condition.
assert context.allowed_builtin_tools is None
@pytest.mark.asyncio
async def test_builtin_tools_whitelist_flows_to_context(self, partners_root, fake_orchestrator):
fake_orchestrator.script = _finish("ok")
config = PartnerConfig(name="Ada", builtin_tools=["rag", "web_fetch"])
runner = _runner(partners_root, config)
await runner.process_message(_msg())
context = fake_orchestrator.seen_contexts[0]
assert context.allowed_builtin_tools == ["rag", "web_fetch"]
@pytest.mark.asyncio
async def test_turn_runs_against_partner_memory(self, partners_root, fake_orchestrator):
"""The turn resolves memory to the partner's OWN synthetic workspace, not
the owner's. The partner_* tools (force-mounted) own the split-memory
model: partner_read folds in the owner's shared L3 on top, while
partner_memorize writes only the partner's own scope."""
from deeptutor.partners.config.paths import get_partner_workspace
fake_orchestrator.script = _finish("ok")
runner = _runner(partners_root)
await runner.process_message(_msg())
partner_memory = (get_partner_workspace("ada") / "memory").resolve()
seen = fake_orchestrator.seen_memory_roots[0].resolve()
assert seen == partner_memory
assert "partners" in seen.parts # the partner's own scope, NOT admin
@pytest.mark.asyncio
async def test_memory_override_is_reset_after_turn(self, partners_root, fake_orchestrator):
from deeptutor.services.memory.paths import memory_root
fake_orchestrator.script = _finish("ok")
runner = _runner(partners_root)
before = memory_root()
await runner.process_message(_msg())
# The ContextVar override must not leak past the turn.
assert memory_root() == before
@pytest.mark.asyncio
async def test_turn_trace_persisted_for_rehydration(self, partners_root, fake_orchestrator):
fake_orchestrator.script = _narration_round("c1", "let me check") + _finish("4.")
runner = _runner(partners_root)
await runner.process_message(_msg("what is 2+2?"))
records = runner.store.messages("telegram:42")
assistant = next(r for r in records if r["role"] == "assistant")
events = assistant.get("events")
assert events, "assistant turn must persist its trace events"
# done/session are excluded; the narration + finish content survive.
assert all(e.get("type") != "done" for e in events)
assert any(e.get("type") == "content" for e in events)
@pytest.mark.asyncio
async def test_session_title_is_first_user_message(self, partners_root, fake_orchestrator):
fake_orchestrator.script = _finish("the answer is 4")
runner = _runner(partners_root)
await runner.process_message(_msg("what is two plus two?"))
session = runner.store.list_sessions()[0]
assert session["title"] == "what is two plus two?"
class TestSessionStoreOps:
def test_archive_flag_is_soft_and_reversible(self, partners_root):
store = PartnerSessionStore(_runner(partners_root).store._dir)
store.append("web-a", "user", "hi")
assert store.is_archived("web-a") is False
store.set_archived("web-a", True)
assert store.is_archived("web-a") is True
# File is untouched (still resumable) and excluded from the merged view.
assert store._path("web-a").exists()
assert store.merged_messages() == []
store.set_archived("web-a", False)
assert store.is_archived("web-a") is False
def test_branch_copies_history_and_archives_source(self, partners_root):
store = PartnerSessionStore(_runner(partners_root).store._dir)
store.append("web-a", "user", "q1")
store.append("web-a", "assistant", "a1", events=[{"type": "content"}])
summary = store.branch("web-a", "web-b")
assert summary is not None and summary["message_count"] == 2
assert store.is_archived("web-a") is True
assert [m["content"] for m in store.messages("web-b")] == ["q1", "a1"]
# Events ride along so the branched copy rehydrates its trace too.
assert store.messages("web-b")[1].get("events")
def test_delete_removes_file_and_index(self, partners_root):
store = PartnerSessionStore(_runner(partners_root).store._dir)
store.append("web-a", "user", "hi")
store.set_archived("web-a", True)
assert store.delete_session("web-a") is True
assert store.delete_session("web-a") is False
assert store.list_sessions() == []
class TestLiveTurn:
def test_buffer_replays_for_late_subscriber(self):
from deeptutor.services.partners.manager import LiveTurn
turn = LiveTurn(user_content="q")
turn.emit({"type": "stream_event", "event": {"i": 1}})
turn.emit({"type": "stream_event", "event": {"i": 2}})
# A client that reconnects mid-turn replays the whole backlog...
late = turn.subscribe()
assert [late.get_nowait()["event"]["i"] for _ in range(2)] == [1, 2]
# ...and keeps receiving new frames after subscribing.
turn.emit({"type": "stream_event", "event": {"i": 3}})
assert late.get_nowait()["event"]["i"] == 3
def test_finish_pushes_terminal_and_marks_done(self):
from deeptutor.services.partners.manager import LiveTurn
turn = LiveTurn()
q = turn.subscribe()
turn.finish([{"type": "content", "content": "hi"}, {"type": "done"}])
assert turn.done is True
assert q.get_nowait()["type"] == "content"
assert q.get_nowait()["type"] == "done"
# A subscriber arriving after completion still replays the full turn.
post = turn.subscribe()
kinds = [post.get_nowait()["type"] for _ in range(post.qsize())]
assert kinds == ["content", "done"]
@pytest.mark.asyncio
async def test_web_turn_runs_on_instance_and_survives_resubscribe(
self, partners_root, fake_orchestrator
):
from deeptutor.services.partners.manager import PartnerManager
fake_orchestrator.script = _narration_round("c1", "working") + _finish("done!")
mgr = PartnerManager()
mgr.save_config("ada", PartnerConfig(name="Ada"), auto_start=True)
await mgr.start_partner("ada")
try:
turn = mgr.start_web_turn("ada", "web-x", "hello", [])
queue = turn.subscribe()
frames: list[dict] = []
while True:
frame = await asyncio.wait_for(queue.get(), timeout=5)
frames.append(frame)
if frame["type"] in {"done", "stopped"}:
break
assert any(f["type"] == "content" and f["content"] == "done!" for f in frames)
assert turn.done is True
# Reconnect after completion → no live turn to attach to (history
# serves it); a still-running turn would return the LiveTurn.
assert mgr.subscribe_web_turn("ada", "web-x") is None
# The completed turn persisted to the session store.
assert mgr.session_store("ada").messages("web-x")[-1]["content"] == "done!"
finally:
await mgr.stop_partner("ada")
class TestPartnerCommands:
@pytest.mark.asyncio
async def test_sessions_resume_delete_commands(self, partners_root, fake_orchestrator):
from deeptutor.services.partners.commands import PartnerCommandHandler
runner = _runner(partners_root)
fake_orchestrator.script = _finish("ok")
await runner.process_message(_msg("hello")) # creates telegram:42
handler = PartnerCommandHandler(partner_id="ada", config=runner.config, store=runner.store)
listed = handler.dispatch(_msg("/sessions"))
assert listed is not None and "telegram_42" in listed.content
# /delete an existing key, /resume clears an archived flag.
runner.store.set_archived("telegram:42", True)
resumed = handler.dispatch(_msg("/resume telegram:42"))
assert resumed is not None and not runner.store.is_archived("telegram:42")
deleted = handler.dispatch(_msg("/delete telegram:42"))
assert deleted is not None and "Deleted" in deleted.content
assert handler.dispatch(_msg("/delete telegram:42")).content.startswith("No conversation")
@pytest.mark.asyncio
async def test_stop_command_is_a_noop_on_im(self, partners_root, fake_orchestrator):
from deeptutor.services.partners.commands import PartnerCommandHandler
runner = _runner(partners_root)
handler = PartnerCommandHandler(partner_id="ada", config=runner.config, store=runner.store)
result = handler.dispatch(_msg("/stop"))
assert result is not None and "nothing" in result.content.lower()
@pytest.mark.asyncio
async def test_new_archives_current_session_without_calling_orchestrator(
self, partners_root, fake_orchestrator
):
fake_orchestrator.script = _finish("first reply")
runner = _runner(partners_root)
await runner.process_message(_msg("first question"))
assert len(fake_orchestrator.seen_contexts) == 1
reply = await runner.process_message(_msg("/new"))
assert "Started a new conversation" in reply
assert len(fake_orchestrator.seen_contexts) == 1
assert runner.store.conversation_history("telegram:42") == []
archived = [session for session in runner.store.list_sessions() if session["archived"]]
assert len(archived) == 1
assert archived[0]["message_count"] == 2
assert archived[0]["session_key"].startswith("_archived_")
@pytest.mark.asyncio
async def test_archived_session_does_not_feed_next_turn(self, partners_root, fake_orchestrator):
runner = _runner(partners_root)
fake_orchestrator.script = _finish("old reply")
await runner.process_message(_msg("old question"))
await runner.process_message(_msg("/new"))
fake_orchestrator.script = _finish("fresh reply")
await runner.process_message(_msg("fresh question"))
context = fake_orchestrator.seen_contexts[-1]
assert context.conversation_history == []
@pytest.mark.asyncio
async def test_telegram_bot_command_suffix_is_supported(self, partners_root, fake_orchestrator):
fake_orchestrator.script = _finish("first reply")
runner = _runner(partners_root)
await runner.process_message(_msg("first question"))
reply = await runner.process_message(_msg("/new@DeepTutorBot"))
assert "Started a new conversation" in reply
assert len(fake_orchestrator.seen_contexts) == 1