Files
2026-07-13 13:12:33 +08:00

423 lines
13 KiB
Python

"""Equivalence harness for the engine hook surface.
The harness compares two execution paths for trace emission:
* **Legacy** — call ``TurnRunner._write_trace_event`` directly, exactly as the
inline call sites at ``runtime.py:1465``, ``:1518``, ``:2256``, ``:2365``,
``:2393`` currently do.
* **Hook** — drive the same emission through ``DefaultTraceEmitterHook`` via
the ``TurnHook.on_event`` Protocol.
The two paths must produce identical ``TraceEvent`` records observed at the
sink. The harness also covers the no-op default hooks (``DefaultTranscriptHook``
and ``DefaultMemoryFlushHook``), which reserve future hooks without
yet moving the inline body.
Coverage gate: every hook method on every default hook is exercised at least
once, so the protocols are wired end-to-end before production code moves to
call site.
"""
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from opensquilla.engine.hooks import (
CompactionState,
DefaultMemoryFlushHook,
DefaultTraceEmitterHook,
DefaultTranscriptHook,
NoopCompactionHook,
NoopToolHook,
NoopTurnHook,
ToolHookCall,
ToolHookResult,
TurnEvent,
TurnHookContext,
TurnHookResult,
build_default_turn_hooks,
)
from opensquilla.engine.hooks.types import CompactionHook, ToolHook, TurnHook
from opensquilla.engine.runtime import TurnRunner
from opensquilla.observability.trace import TraceContext, TraceEvent
from opensquilla.tool_boundary import ToolCall, ToolResult
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def trace_context() -> TraceContext:
return TraceContext.new(
trace_id="trace-equiv-test",
session_key="agent:main:test",
session_id="sess-1",
turn_id="turn-1",
agent_id="agent:main",
)
@pytest.fixture
def captured_events(monkeypatch: pytest.MonkeyPatch) -> list[TraceEvent]:
"""Replace ``write_trace_event`` in BOTH callers' modules with a capture.
Both ``opensquilla.engine.runtime`` and ``opensquilla.engine.hooks.defaults``
import the symbol by name, so we patch both bindings to make sure the
legacy and hook paths route through the same sink.
"""
captured: list[TraceEvent] = []
def _capture(event: TraceEvent) -> None:
captured.append(event)
monkeypatch.setattr(
"opensquilla.engine.runtime.write_trace_event",
_capture,
)
monkeypatch.setattr(
"opensquilla.engine.hooks.defaults.write_trace_event",
_capture,
)
return captured
# ---------------------------------------------------------------------------
# Trace-emission equivalence for hook protocol wiring
# ---------------------------------------------------------------------------
def _legacy_emit(
kind: str,
ctx: TraceContext,
*,
seq: int | None,
attrs: dict[str, Any] | None,
payload: dict[str, Any] | None,
) -> None:
"""Direct call to the static legacy emitter — matches the inline path."""
TurnRunner._write_trace_event(
kind,
ctx,
seq=seq,
attrs=attrs,
payload=payload,
)
def _hook_emit(
hook: DefaultTraceEmitterHook,
hook_ctx: TurnHookContext,
event: TurnEvent,
) -> None:
"""Drive the same emission through the hook seam."""
hook.on_event(hook_ctx, event)
@pytest.mark.parametrize(
("kind", "seq", "attrs", "payload"),
[
("turn_start", 1, {"input_mode": "user", "run_kind": "default"},
{"message_chars": 12, "attachment_count": 0}),
("turn_error", 2, None,
{"error_type": "ProviderResolutionError", "error_code": "no_provider", "error_chars": 19}),
("turn_end", 2, {"provider": "openrouter", "model": "gpt-x"},
{"final_text_chars": 5, "segment_count": 0, "artifact_count": 0,
"error": False, "tool_projection_applied": False,
"tool_projection_calls": 0, "tool_projection_tokens_saved": 0}),
("turn_cancelled", 2, None, {"partial_text_chars": 3}),
],
)
def test_trace_emission_legacy_matches_hook(
trace_context: TraceContext,
captured_events: list[TraceEvent],
kind: str,
seq: int,
attrs: dict[str, Any] | None,
payload: dict[str, Any],
) -> None:
"""Legacy emit and hook emit must produce identical TraceEvent payloads."""
_legacy_emit(kind, trace_context, seq=seq, attrs=attrs, payload=payload)
legacy = captured_events.pop()
hook = DefaultTraceEmitterHook()
hook_ctx = TurnHookContext(
session_key="agent:main:test",
agent_id="agent:main",
turn_id="turn-1",
trace_context=trace_context,
)
_hook_emit(hook, hook_ctx, TurnEvent(kind=kind, seq=seq, attrs=attrs or {}, payload=payload))
hooked = captured_events.pop()
assert legacy.kind == hooked.kind
assert legacy.context == hooked.context
assert legacy.privacy == hooked.privacy
assert legacy.seq == hooked.seq
assert legacy.attrs == hooked.attrs
assert legacy.payload == hooked.payload
assert legacy.schema_version == hooked.schema_version
def test_trace_emission_hook_no_trace_context_is_noop(
captured_events: list[TraceEvent],
) -> None:
"""Hook with ``trace_context=None`` must skip emission silently.
Mirrors the inline guard at ``runtime.py:2364`` (``if trace_context is not None``).
"""
hook = DefaultTraceEmitterHook()
hook_ctx = TurnHookContext(
session_key="agent:main:test",
agent_id="agent:main",
trace_context=None,
)
hook.on_event(hook_ctx, TurnEvent(kind="turn_end", seq=2))
assert captured_events == []
# ---------------------------------------------------------------------------
# Defaults satisfy their Protocols
# ---------------------------------------------------------------------------
def test_default_turn_hooks_satisfy_protocol() -> None:
for hook in build_default_turn_hooks():
assert isinstance(hook, TurnHook)
def test_noop_hooks_satisfy_protocols() -> None:
assert isinstance(NoopTurnHook(), TurnHook)
assert isinstance(NoopToolHook(), ToolHook)
assert isinstance(NoopCompactionHook(), CompactionHook)
def test_default_chain_order_is_stable() -> None:
"""Trace emitter must come first so events reach the sink before observers."""
chain = build_default_turn_hooks()
assert chain[0].name == "default_trace_emitter"
assert {h.name for h in chain} == {
"default_trace_emitter",
"default_transcript",
"default_memory_flush",
}
# ---------------------------------------------------------------------------
# Lifecycle no-ops complete without raising
# ---------------------------------------------------------------------------
def _ctx() -> TurnHookContext:
return TurnHookContext(session_key="s", agent_id="a")
def _result() -> TurnHookResult:
return TurnHookResult(final_text="ok")
def test_default_transcript_lifecycle_runs_clean() -> None:
hook = DefaultTranscriptHook()
asyncio.run(hook.before_turn(_ctx()))
asyncio.run(hook.after_turn(_ctx(), _result()))
asyncio.run(hook.on_error(_ctx(), RuntimeError("boom")))
hook.on_event(_ctx(), TurnEvent(kind="turn_end"))
def test_default_memory_flush_lifecycle_runs_clean() -> None:
hook = DefaultMemoryFlushHook()
asyncio.run(hook.before_turn(_ctx()))
asyncio.run(hook.after_turn(_ctx(), _result()))
asyncio.run(hook.on_error(_ctx(), RuntimeError("boom")))
hook.on_event(_ctx(), TurnEvent(kind="turn_end"))
def test_noop_turn_hook_lifecycle_runs_clean() -> None:
hook = NoopTurnHook()
asyncio.run(hook.before_turn(_ctx()))
asyncio.run(hook.after_turn(_ctx(), _result()))
asyncio.run(hook.on_error(_ctx(), RuntimeError("x")))
hook.on_event(_ctx(), TurnEvent(kind="turn_start", seq=1))
def test_noop_tool_hook_runs_clean() -> None:
hook = NoopToolHook()
call = ToolHookCall(
tool_call=ToolCall(tool_use_id="u", tool_name="t", arguments={}),
ctx=None,
)
hook.before_tool(call)
hook.after_tool(
call,
ToolHookResult(
result=ToolResult(
tool_use_id="u",
tool_name="t",
content="",
is_error=False,
)
),
)
hook.after_tool(call, ToolHookResult(exception=RuntimeError("err")))
def test_noop_compaction_hook_runs_clean() -> None:
hook = NoopCompactionHook()
state = CompactionState(session_key="s", agent_id="a", total_tokens=10, threshold_tokens=20)
asyncio.run(hook.before_compact(state))
asyncio.run(hook.after_compact(state, outcome={"summary": ""}))
# ---------------------------------------------------------------------------
# Runtime feature flag: OPENSQUILLA_HOOKS=new vs legacy must match
# ---------------------------------------------------------------------------
def _make_runtime_with_default_hook() -> TurnRunner:
return TurnRunner(provider_selector=None)
def _emit_through_runtime(
runner: TurnRunner,
trace_context: TraceContext,
*,
kind: str,
seq: int | None,
attrs: dict[str, Any] | None,
payload: dict[str, Any] | None,
) -> None:
runner._emit_turn_event(
kind,
trace_context,
session_key="agent:main:test",
agent_id="agent:main",
turn_id="turn-1",
run_kind="default",
input_mode="user",
seq=seq,
attrs=attrs,
payload=payload,
)
@pytest.mark.parametrize(
("kind", "seq", "attrs", "payload"),
[
("turn_start", 1, {"input_mode": "user", "run_kind": "default"},
{"message_chars": 12, "attachment_count": 0}),
("turn_end", 2, {"provider": "p", "model": "m"},
{"final_text_chars": 5}),
("turn_cancelled", 2, None, {"partial_text_chars": 3}),
("turn_error", 2, None,
{"error_type": "RuntimeError", "error_chars": 7}),
],
)
def test_emit_turn_event_legacy_matches_new(
monkeypatch: pytest.MonkeyPatch,
trace_context: TraceContext,
captured_events: list[TraceEvent],
kind: str,
seq: int,
attrs: dict[str, Any] | None,
payload: dict[str, Any],
) -> None:
"""OPENSQUILLA_HOOKS=legacy and =new must produce identical TraceEvents."""
runner = _make_runtime_with_default_hook()
monkeypatch.setenv("OPENSQUILLA_HOOKS", "legacy")
_emit_through_runtime(
runner, trace_context, kind=kind, seq=seq, attrs=attrs, payload=payload
)
legacy = captured_events.pop()
monkeypatch.setenv("OPENSQUILLA_HOOKS", "new")
_emit_through_runtime(
runner, trace_context, kind=kind, seq=seq, attrs=attrs, payload=payload
)
new = captured_events.pop()
assert legacy.kind == new.kind
assert legacy.context == new.context
assert legacy.privacy == new.privacy
assert legacy.seq == new.seq
assert legacy.attrs == new.attrs
assert legacy.payload == new.payload
assert legacy.schema_version == new.schema_version
def test_emit_turn_event_with_none_context_is_noop(
captured_events: list[TraceEvent],
) -> None:
runner = _make_runtime_with_default_hook()
runner._emit_turn_event(
"turn_start",
None,
session_key="s",
agent_id="a",
seq=1,
)
assert captured_events == []
def test_emit_turn_event_unknown_env_falls_back_to_default(
monkeypatch: pytest.MonkeyPatch,
trace_context: TraceContext,
captured_events: list[TraceEvent],
) -> None:
"""An unrecognised env value must resolve to the active default (new)."""
monkeypatch.setenv("OPENSQUILLA_HOOKS", "garbage")
runner = _make_runtime_with_default_hook()
_emit_through_runtime(
runner, trace_context, kind="turn_end", seq=2, attrs={"x": 1}, payload={"y": 2}
)
event = captured_events.pop()
assert event.kind == "turn_end"
assert event.attrs == {"x": 1}
assert event.payload == {"y": 2}
def test_runtime_hook_failure_does_not_break_emission(
monkeypatch: pytest.MonkeyPatch,
trace_context: TraceContext,
captured_events: list[TraceEvent],
) -> None:
"""A misbehaving hook must be logged and skipped, never raised."""
class _RaisingHook:
name = "raising"
async def before_turn(self, ctx): # type: ignore[no-untyped-def]
return None
async def after_turn(self, ctx, result): # type: ignore[no-untyped-def]
return None
async def on_error(self, ctx, exc): # type: ignore[no-untyped-def]
return None
def on_event(self, ctx, event): # type: ignore[no-untyped-def]
raise RuntimeError("boom")
monkeypatch.setenv("OPENSQUILLA_HOOKS", "new")
runner = TurnRunner(
provider_selector=None,
turn_hooks=(_RaisingHook(), DefaultTraceEmitterHook()),
)
_emit_through_runtime(
runner, trace_context, kind="turn_start", seq=1, attrs=None, payload=None
)
# The default emitter still wrote the event despite the prior hook raising.
event = captured_events.pop()
assert event.kind == "turn_start"