206 lines
7.6 KiB
Python
206 lines
7.6 KiB
Python
"""Tests for ``_SessionsChatReplAdapter._ensure_session`` model handoff.
|
|
|
|
The user can type ``/model foo`` before sending the first message —
|
|
which is before the session row exists. The adapter caches the value
|
|
locally, then PATCHes it onto the session immediately after
|
|
``POST /v1/sessions`` returns so the first event's workflow already
|
|
sees ``conv.model_override``. These tests pin that handoff.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from omnigent.repl._repl import _SessionsChatReplAdapter
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
@dataclass
|
|
class _StubSession:
|
|
"""Minimal Session-shaped dataclass for snapshot returns."""
|
|
|
|
id: str
|
|
agent_id: str
|
|
runner_id: str | None
|
|
reasoning_effort: str | None
|
|
model_override: str | None
|
|
# None mirrors an old server that omits agent_name — hydrate keeps
|
|
# the adapter's launch-time name (these tests don't exercise the
|
|
# agent-switch rename path).
|
|
agent_name: str | None = None
|
|
llm_model: str | None = None
|
|
context_window: int | None = None
|
|
last_total_tokens: int | None = None
|
|
harness: str | None = None
|
|
|
|
|
|
def _build_adapter(create_returns: _StubSession) -> _SessionsChatReplAdapter:
|
|
"""
|
|
Build an adapter wired to mock client + bundle, ready for ensure_session.
|
|
|
|
:param create_returns: The Session the mocked ``sessions.create``
|
|
returns. Use a populated ``model_override`` to simulate a
|
|
resumed session, ``None`` to simulate a fresh one.
|
|
:returns: An adapter with mocked client + stubbed bind/recovery so
|
|
``_ensure_session`` can run end-to-end without HTTP.
|
|
"""
|
|
client = MagicMock()
|
|
client.sessions.create = AsyncMock(return_value=create_returns)
|
|
# set_model_override returns the canonical session post-PATCH;
|
|
# mirror the input value so the test can also assert the local
|
|
# cache picks up the server-canonical form.
|
|
# ``silent`` ignored by the stub (server-side responsibility) but
|
|
# accepted as a kwarg so the adapter's call signature is honoured.
|
|
client.sessions.set_model_override = AsyncMock(
|
|
side_effect=lambda session_id, *, model_override, silent=False: _StubSession(
|
|
id=session_id,
|
|
agent_id=create_returns.agent_id,
|
|
runner_id=create_returns.runner_id,
|
|
reasoning_effort=create_returns.reasoning_effort,
|
|
model_override=model_override,
|
|
)
|
|
)
|
|
|
|
adapter = _SessionsChatReplAdapter(
|
|
client=client,
|
|
agent_name="test-agent",
|
|
session_bundle=b"fake-bundle",
|
|
session_bundle_filename="agent.tar.gz",
|
|
)
|
|
# Short-circuit the runner-bind and stream-pump steps so
|
|
# _ensure_session reaches the model-override branch without
|
|
# needing live HTTP / asyncio plumbing.
|
|
adapter._bind_runner_if_needed = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
adapter._recover_runner_if_needed = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
adapter._stream_pump = AsyncMock(return_value=None) # type: ignore[method-assign]
|
|
return adapter
|
|
|
|
|
|
async def test_pending_model_override_patched_after_create() -> None:
|
|
"""``/model`` typed before the first send PATCHes after create."""
|
|
fresh = _StubSession(
|
|
id="conv_new",
|
|
agent_id="ag_x",
|
|
runner_id=None,
|
|
reasoning_effort=None,
|
|
model_override=None,
|
|
)
|
|
adapter = _build_adapter(create_returns=fresh)
|
|
|
|
# Simulate ``/model claude-opus-4-7`` typed before any send.
|
|
await adapter.set_model_override("claude-opus-4-7")
|
|
assert adapter.model_override == "claude-opus-4-7"
|
|
|
|
await adapter._ensure_session()
|
|
|
|
# The create POST does not carry model_override (no metadata
|
|
# field), so without the post-create PATCH the first event would
|
|
# silently fall back to the spec default. ``silent=True`` skips
|
|
# the tmux ``/model`` forward; without it a fresh session would
|
|
# render a leading "Command model X" item before any user input.
|
|
adapter._client.sessions.set_model_override.assert_awaited_once_with( # type: ignore[attr-defined]
|
|
"conv_new",
|
|
model_override="claude-opus-4-7",
|
|
silent=True,
|
|
)
|
|
# Local cache reflects the server-canonical value.
|
|
assert adapter.model_override == "claude-opus-4-7"
|
|
|
|
|
|
async def test_no_patch_when_server_already_has_override() -> None:
|
|
"""Resume path: server returned an override, no double-PATCH."""
|
|
resumed = _StubSession(
|
|
id="conv_resume",
|
|
agent_id="ag_x",
|
|
runner_id=None,
|
|
reasoning_effort=None,
|
|
model_override="claude-sonnet-4-6",
|
|
)
|
|
adapter = _build_adapter(create_returns=resumed)
|
|
# User has no pending pick — nothing to hand off.
|
|
assert adapter.model_override is None
|
|
|
|
await adapter._ensure_session()
|
|
|
|
adapter._client.sessions.set_model_override.assert_not_awaited() # type: ignore[attr-defined]
|
|
# Hydration picked up the server-side override.
|
|
assert adapter.model_override == "claude-sonnet-4-6"
|
|
|
|
|
|
async def test_no_patch_when_no_pending_override() -> None:
|
|
"""Fresh session with no /model typed → no PATCH."""
|
|
fresh = _StubSession(
|
|
id="conv_blank",
|
|
agent_id="ag_x",
|
|
runner_id=None,
|
|
reasoning_effort=None,
|
|
model_override=None,
|
|
)
|
|
adapter = _build_adapter(create_returns=fresh)
|
|
|
|
await adapter._ensure_session()
|
|
|
|
adapter._client.sessions.set_model_override.assert_not_awaited() # type: ignore[attr-defined]
|
|
assert adapter.model_override is None
|
|
|
|
|
|
async def test_patch_failure_logs_and_clears_local_cache() -> None:
|
|
"""A server-rejected pending override fails safe.
|
|
|
|
Without the try/except guard, a PATCH failure would propagate out
|
|
of ``_ensure_session`` and surface as an opaque error on the
|
|
user's send. The guard logs + clears the local cache so the next
|
|
send goes through with the spec default instead.
|
|
"""
|
|
fresh = _StubSession(
|
|
id="conv_reject",
|
|
agent_id="ag_x",
|
|
runner_id=None,
|
|
reasoning_effort=None,
|
|
model_override=None,
|
|
)
|
|
adapter = _build_adapter(create_returns=fresh)
|
|
adapter._client.sessions.set_model_override = AsyncMock( # type: ignore[attr-defined]
|
|
side_effect=RuntimeError("validator rejected model name"),
|
|
)
|
|
await adapter.set_model_override("not-a-real-model")
|
|
|
|
# Should NOT raise; failure is handled internally.
|
|
await adapter._ensure_session()
|
|
|
|
# Local cache cleared so the user's next send doesn't keep trying
|
|
# the rejected value.
|
|
assert adapter.model_override is None
|
|
|
|
|
|
async def test_resume_path_skips_patch_even_when_pending_local_value() -> None:
|
|
"""If both server AND local have a value, server wins, no PATCH.
|
|
|
|
Resume scenario: the user previously persisted a model override
|
|
on this session; locally the picker remembers the same (or a
|
|
different) value. The adapter's hydrate clobbers the local cache
|
|
with the server snapshot, and the post-create PATCH branch is
|
|
gated on ``session.model_override is None`` so it does not fire.
|
|
"""
|
|
resumed = _StubSession(
|
|
id="conv_both",
|
|
agent_id="ag_x",
|
|
runner_id=None,
|
|
reasoning_effort=None,
|
|
model_override="claude-sonnet-4-6",
|
|
)
|
|
adapter = _build_adapter(create_returns=resumed)
|
|
await adapter.set_model_override("claude-opus-4-7")
|
|
assert adapter.model_override == "claude-opus-4-7"
|
|
|
|
await adapter._ensure_session()
|
|
|
|
adapter._client.sessions.set_model_override.assert_not_awaited() # type: ignore[attr-defined]
|
|
# Hydration replaced the local pick with the server's
|
|
# authoritative value.
|
|
assert adapter.model_override == "claude-sonnet-4-6"
|