Files
hermes-agent/tests/agent/test_opencode_session_affinity.py
ericmaddox 6ca41cbe8b fix(opencode): send ephemeral x-opencode-session header on one-shot requests
OpenCode Go strictly requires an x-opencode-session header on all requests to route requests efficiently and avoid HTTP 400 MissingSessionID. In turn chats and parented auxiliary calls, the header is derived from the conversation context or session ID. For stateless one-shot requests (commit messages, summaries, unparented auxiliary tasks), opencode_session_headers now falls back to generating an ephemeral session ID so one-shot requests to OpenCode succeed.

Closes #105841
2026-09-19 10:06:00 -07:00

164 lines
7.1 KiB
Python

"""x-opencode-session rides on every OpenCode request, on every transport."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
from agent import auxiliary_client as aux
from agent.chat_completion_helpers import build_api_kwargs
from run_agent import AIAgent
_MSGS = [{"role": "user", "content": "hi"}]
def _agent(provider, model, base_url, api_mode=None):
agent = AIAgent(
api_key="test-key",
base_url=base_url,
model=model,
provider=provider,
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
session_id="sess-affinity-1",
)
if api_mode:
agent.api_mode = api_mode
agent._transport = None
agent._anthropic_base_url = base_url
return agent
@pytest.mark.parametrize(
"provider, model, base_url, api_mode",
[
("opencode-go", "glm-5", "https://opencode.ai/zen/go/v1", None), # chat_completions
("opencode-go", "gpt-5.6-luna", "https://opencode.ai/zen/go/v1", None), # codex_responses
("opencode-go", "minimax-m2.7", "https://opencode.ai/zen/go/v1", "anthropic_messages"),
("custom", "glm-5", "https://opencode.ai/zen/go/v1", None), # URL-only detection
],
)
def test_main_turn_sends_stable_session_header_on_every_transport(provider, model, base_url, api_mode):
agent = _agent(provider, model, base_url, api_mode)
first = build_api_kwargs(agent, _MSGS)["extra_headers"]["x-opencode-session"]
second = build_api_kwargs(agent, _MSGS)["extra_headers"]["x-opencode-session"]
assert first == second == "sess-affinity-1"
other = _agent("openrouter", "anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1")
assert "x-opencode-session" not in (build_api_kwargs(other, _MSGS).get("extra_headers") or {})
def test_auxiliary_calls_share_the_main_turn_session_key():
token = aux.set_runtime_main(
"opencode-go", "glm-5", base_url="https://opencode.ai/zen/go/v1", session_id="sess-affinity-1"
)
try:
kwargs = aux._build_call_kwargs("opencode-go", "glm-5", _MSGS, base_url="https://opencode.ai/zen/go/v1")
assert kwargs["extra_headers"]["x-opencode-session"] == "sess-affinity-1"
other = aux._build_call_kwargs("openrouter", "x", _MSGS, base_url="https://openrouter.ai/api/v1")
assert "x-opencode-session" not in (other.get("extra_headers") or {})
finally:
aux._RUNTIME_MAIN_CONTEXT.reset(token)
@pytest.fixture
def out_of_turn():
"""No ambient turn context: runtime binding, conversation root and declared affinity scope all unset.
Earlier AIAgent-driven tests in this process can leave those contextvars set, which would hand the
header to an unfixed tree through ``get_conversation_context()`` instead of the explicit runtime."""
from agent import portal_tags
tokens = (
aux._RUNTIME_MAIN_CONTEXT.set(None),
portal_tags.set_conversation_context(None),
portal_tags.set_affinity_scope(None),
)
try:
yield
finally:
aux._RUNTIME_MAIN_CONTEXT.reset(tokens[0])
portal_tags.reset_conversation_context(tokens[1])
portal_tags.reset_affinity_scope(tokens[2])
_OPENCODE_RUNTIME = {
"provider": "opencode-zen", "model": "glm-5", "base_url": "https://opencode.ai/zen/v1",
"api_key": "test-key", "session_id": "sess-affinity-1",
}
def _route_to_fake_opencode_client(monkeypatch, captured, *, async_mode):
"""Pin the aux resolver on an OpenCode route served by a fake SDK client that records its kwargs."""
if async_mode:
async def create(**kwargs):
captured.update(kwargs)
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))], model="glm-5")
else:
def create(**kwargs):
captured.update(kwargs)
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))], model="glm-5")
client = SimpleNamespace(
chat=SimpleNamespace(completions=SimpleNamespace(create=create)), base_url="https://opencode.ai/zen/v1",
)
monkeypatch.setattr(aux, "_resolve_task_provider_model",
lambda *_a, **_k: ("opencode-zen", "glm-5", "https://opencode.ai/zen/v1", "test-key", None))
monkeypatch.setattr(aux, "_get_cached_client", lambda *_a, **_k: (client, "glm-5"))
def test_sync_out_of_turn_call_binds_the_explicit_main_runtime_session(monkeypatch, out_of_turn):
"""#112717: a background/out-of-turn ``call_llm(main_runtime=...)`` (title, approval, skills hub,
``/btw``) must send the conversation's ``x-opencode-session`` exactly like the main turn does."""
captured = {}
_route_to_fake_opencode_client(monkeypatch, captured, async_mode=False)
aux.call_llm(task="title_generation", main_runtime=_OPENCODE_RUNTIME, messages=_MSGS)
assert captured["extra_headers"]["x-opencode-session"] == "sess-affinity-1"
assert aux._RUNTIME_MAIN_CONTEXT.get() is None # the explicit binding does not leak past the call
def test_async_out_of_turn_call_binds_the_explicit_main_runtime_session(monkeypatch, out_of_turn):
"""Same contract on the async twin (#112717)."""
captured = {}
_route_to_fake_opencode_client(monkeypatch, captured, async_mode=True)
asyncio.run(aux.async_call_llm(task="approval", main_runtime=_OPENCODE_RUNTIME, messages=_MSGS))
assert captured["extra_headers"]["x-opencode-session"] == "sess-affinity-1"
assert aux._RUNTIME_MAIN_CONTEXT.get() is None
def test_tui_gateway_oneshot_runtime_snapshot_carries_the_session(monkeypatch, out_of_turn):
"""The Desktop/TUI-gateway ``llm.oneshot`` path builds its explicit ``main_runtime`` from the live
agent; without ``session_id`` an OpenCode title request sends no ``x-opencode-session`` (#112717)."""
from tui_gateway.server import _main_runtime_from_agent
agent = SimpleNamespace(
provider="opencode-zen", model="glm-5", base_url="https://opencode.ai/zen/v1", api_key="test-key",
api_mode="chat_completions", auth_mode="", session_id="sess-desktop-1",
)
captured = {}
_route_to_fake_opencode_client(monkeypatch, captured, async_mode=False)
aux.call_llm(task="title_generation", main_runtime=_main_runtime_from_agent(agent), messages=_MSGS)
assert captured["extra_headers"]["x-opencode-session"] == "sess-desktop-1"
def test_stateless_oneshot_still_sends_an_opencode_session_header(out_of_turn):
"""A one-shot with no live session (Desktop commit-message generation from the review panel with
no active chat, standalone aux calls) has no conversation identity at all, yet the relay rejects
header-less requests with 400 MissingSessionID (#105841). It must carry an ephemeral key instead
of nothing; non-OpenCode targets stay untouched."""
from agent.opencode_affinity import opencode_session_headers
kwargs = aux._build_call_kwargs("opencode-go", "glm-5", _MSGS, base_url="https://opencode.ai/zen/go/v1")
assert kwargs["extra_headers"]["x-opencode-session"]
assert opencode_session_headers("opencode-go", None, session_id=None).get("x-opencode-session")
assert opencode_session_headers("openrouter", "https://openrouter.ai/api/v1", session_id=None) == {}