209 lines
6.5 KiB
Python
209 lines
6.5 KiB
Python
"""
|
|
Fixture harness for executor-adapter tests.
|
|
|
|
Constructs a :class:`ExecutorAdapter` wrapped around a
|
|
:class:`MockExecutor` whose script is selected by the
|
|
``MOCK_EXECUTOR_SCRIPT`` env var the tests set per case.
|
|
|
|
Four scripts:
|
|
|
|
- ``"text_only"``: a single TurnComplete with response text.
|
|
- ``"tool_call"``: a ToolCallRequest, then a ToolCallComplete
|
|
with a result, then a TurnComplete (no further text).
|
|
- ``"error"``: an ExecutorError event.
|
|
- ``"cancelled"``: a TurnCancelled event.
|
|
- ``"capture_messages"``: writes the received messages list as
|
|
JSON to the path in ``MOCK_EXECUTOR_CAPTURE_PATH``, then
|
|
emits a TurnComplete. Used to verify the full AP→harness→
|
|
inner-executor history pipeline (the regression that broke
|
|
``--resume`` follow-ups).
|
|
|
|
Lives under ``tests/`` so it doesn't ship as production code.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from collections.abc import AsyncIterator, Callable
|
|
|
|
from fastapi import FastAPI
|
|
|
|
from omnigent.inner.executor import (
|
|
Executor,
|
|
ExecutorConfig,
|
|
ExecutorError,
|
|
ExecutorEvent,
|
|
Message,
|
|
MockExecutor,
|
|
ToolCallComplete,
|
|
ToolCallRequest,
|
|
ToolCallStatus,
|
|
ToolSpec,
|
|
TurnCancelled,
|
|
TurnComplete,
|
|
)
|
|
from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter
|
|
|
|
_SCRIPT_ENV_VAR = "MOCK_EXECUTOR_SCRIPT"
|
|
_CAPTURE_PATH_ENV_VAR = "MOCK_EXECUTOR_CAPTURE_PATH"
|
|
|
|
|
|
class _CapturingExecutor(Executor):
|
|
"""
|
|
Inner :class:`Executor` that writes the messages it receives
|
|
to a file the test reads back.
|
|
|
|
Used to verify the harness boundary preserves the full
|
|
conversation history (the bug fixed in the resume-history
|
|
commit). The capture file is the proof: if the file shows
|
|
only the most recent user message, the harness regressed
|
|
to "latest user only" and ``--resume`` follow-ups will
|
|
silently lose context again.
|
|
"""
|
|
|
|
async def run_turn(
|
|
self,
|
|
messages: list[Message],
|
|
tools: list[ToolSpec],
|
|
system_prompt: str,
|
|
config: ExecutorConfig | None = None,
|
|
) -> AsyncIterator[ExecutorEvent]:
|
|
capture_path = os.environ.get(_CAPTURE_PATH_ENV_VAR)
|
|
if capture_path:
|
|
with open(capture_path, "w", encoding="utf-8") as f:
|
|
json.dump(messages, f)
|
|
yield TurnComplete(response="captured")
|
|
|
|
async def close(self) -> None:
|
|
"""No-op — no resources to release in the capture stub."""
|
|
|
|
async def close_session(self, session_key: str) -> None:
|
|
"""No-op — no per-session resources to release."""
|
|
del session_key
|
|
|
|
|
|
def _build_text_only() -> Executor:
|
|
"""
|
|
MockExecutor scripted with a single text-only TurnComplete.
|
|
|
|
:returns: A configured :class:`MockExecutor` instance.
|
|
"""
|
|
executor = MockExecutor()
|
|
executor.enqueue_response("hello from mock")
|
|
return executor
|
|
|
|
|
|
def _build_tool_call() -> Executor:
|
|
"""
|
|
MockExecutor scripted with a tool call observation.
|
|
|
|
Yields a :class:`ToolCallRequest`, a :class:`ToolCallComplete`
|
|
with a string result, then a final :class:`TurnComplete`. The
|
|
adapter should translate request+complete into paired
|
|
function_call + function_call_output items per the v1
|
|
native-tool emission pattern.
|
|
|
|
:returns: A configured :class:`MockExecutor` instance.
|
|
"""
|
|
executor = MockExecutor()
|
|
# Hand-build the events list — MockExecutor's enqueue_tool_call
|
|
# helper splits the tool call across two turns to simulate the
|
|
# external-loop pattern, but we want a single turn that emits
|
|
# request + complete + turn-complete in sequence (which is
|
|
# what handles_tools_internally executors do).
|
|
executor._turns.append(
|
|
[
|
|
ToolCallRequest(
|
|
name="echo_tool",
|
|
args={"x": 1},
|
|
metadata={"call_id": "call_test_1"},
|
|
),
|
|
ToolCallComplete(
|
|
name="echo_tool",
|
|
status=ToolCallStatus.SUCCESS,
|
|
result="tool result",
|
|
# A handles_tools_internally executor (e.g. antigravity) stamps
|
|
# the request's real call_id on the completion too, so the
|
|
# observed function_call and its function_call_output pair
|
|
# downstream (an id-less completion cannot pair and is dropped).
|
|
metadata={"call_id": "call_test_1"},
|
|
),
|
|
TurnComplete(response=None),
|
|
]
|
|
)
|
|
return executor
|
|
|
|
|
|
def _build_error() -> Executor:
|
|
"""
|
|
MockExecutor scripted with an :class:`ExecutorError`.
|
|
|
|
The adapter should re-raise this so the scaffold emits
|
|
``response.failed``.
|
|
|
|
:returns: A configured :class:`MockExecutor` instance.
|
|
"""
|
|
executor = MockExecutor()
|
|
executor._turns.append([ExecutorError(message="mock error")])
|
|
return executor
|
|
|
|
|
|
def _build_cancelled() -> Executor:
|
|
"""
|
|
MockExecutor scripted with a provider-side :class:`TurnCancelled`.
|
|
|
|
The adapter should map this cleanly to ``response.cancelled`` rather than
|
|
falling through to ``response.completed``.
|
|
|
|
:returns: A configured :class:`MockExecutor` instance.
|
|
"""
|
|
executor = MockExecutor()
|
|
executor._turns.append([TurnCancelled(reason="provider_cancelled")])
|
|
return executor
|
|
|
|
|
|
def _build_capture_messages() -> Executor:
|
|
"""
|
|
:class:`_CapturingExecutor` builder.
|
|
|
|
The executor itself reads ``MOCK_EXECUTOR_CAPTURE_PATH``
|
|
inside ``run_turn`` so the test can drop the path in just
|
|
before each request.
|
|
|
|
:returns: A :class:`_CapturingExecutor` instance.
|
|
"""
|
|
return _CapturingExecutor()
|
|
|
|
|
|
_SCRIPTS: dict[str, Callable[[], Executor]] = {
|
|
"text_only": _build_text_only,
|
|
"tool_call": _build_tool_call,
|
|
"error": _build_error,
|
|
"cancelled": _build_cancelled,
|
|
"capture_messages": _build_capture_messages,
|
|
}
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
"""
|
|
Build the fixture FastAPI app for whichever script the
|
|
``MOCK_EXECUTOR_SCRIPT`` env var selects.
|
|
|
|
:returns: The :class:`ExecutorAdapter`'s
|
|
:class:`FastAPI` instance.
|
|
:raises ValueError: If the env var is unset or names an
|
|
unknown script.
|
|
"""
|
|
script_name = os.environ.get(_SCRIPT_ENV_VAR)
|
|
if script_name is None:
|
|
raise ValueError(
|
|
f"{_SCRIPT_ENV_VAR} env var not set; tests must select "
|
|
f"a script before spawning the runner"
|
|
)
|
|
builder = _SCRIPTS.get(script_name)
|
|
if builder is None:
|
|
raise ValueError(f"unknown mock script {script_name!r}; available: {sorted(_SCRIPTS)}")
|
|
adapter = ExecutorAdapter(executor_factory=builder)
|
|
return adapter.build()
|