Files
hermes-webui/tests/test_webui_gateway_chat_backend.py
T

1766 lines
68 KiB
Python

from collections import OrderedDict
import base64
from email.message import Message
import json
from pathlib import Path
import re
import threading
import time
import urllib.error
import api.gateway_chat as gateway_chat
import api.models as models
import api.streaming as streaming
from api.config import PENDING_GOAL_CONTINUATION, STREAMS, create_stream_channel
from api.models import new_session
from api.gateway_chat import (
_gateway_http_error_event,
_gateway_reasoning_delta,
_gateway_sse_delta,
_gateway_sse_reasoning_delta,
_gateway_stream_usage,
_gateway_tool_progress_event,
_gateway_use_runs_api_enabled,
gateway_chat_config_status,
webui_chat_backend_mode,
webui_gateway_chat_enabled,
)
from api.turn_journal import derive_turn_journal_states, read_turn_journal
def test_gateway_chat_backend_is_default_off_for_truthy_values():
for value in (None, "", "1", "true", "yes", "on", "enabled", "runner-local"):
env = {}
if value is not None:
env["HERMES_WEBUI_CHAT_BACKEND"] = value
assert webui_chat_backend_mode({}, env) == "legacy"
assert webui_gateway_chat_enabled({}, env) is False
def test_gateway_chat_backend_only_accepts_explicit_gateway_aliases():
for value in ("gateway", "api_server", "api-server", " Gateway "):
assert webui_chat_backend_mode({}, {"HERMES_WEBUI_CHAT_BACKEND": value}) == "gateway"
assert webui_gateway_chat_enabled({}, {"HERMES_WEBUI_CHAT_BACKEND": value}) is True
def test_gateway_chat_backend_can_be_enabled_from_config_without_env():
assert webui_chat_backend_mode({"webui_chat_backend": "api_server"}, {}) == "gateway"
def test_gateway_chat_config_status_is_redacted_and_reports_missing_key():
status = gateway_chat_config_status(
{},
{
"HERMES_WEBUI_CHAT_BACKEND": "gateway",
"HERMES_WEBUI_GATEWAY_BASE_URL": "http://gateway.local",
},
)
assert status == {
"enabled": True,
"backend": "gateway",
"base_url_configured": True,
"api_key_configured": False,
}
def test_gateway_chat_config_status_reports_fallback_api_server_key_without_exposing_value():
status = gateway_chat_config_status(
{},
{
"HERMES_WEBUI_CHAT_BACKEND": "gateway",
"API_SERVER_KEY": "secret-token",
},
)
assert status["api_key_configured"] is True
assert "secret-token" not in repr(status)
def test_gateway_chat_backend_env_wins_over_config_and_stays_safe():
assert webui_chat_backend_mode(
{"webui_chat_backend": "gateway"},
{"HERMES_WEBUI_CHAT_BACKEND": "legacy-direct"},
) == "legacy"
def test_gateway_sse_delta_extracts_openai_chat_chunks():
assert _gateway_sse_delta({"choices": [{"delta": {"content": "hel"}}]}) == "hel"
assert _gateway_sse_delta({"choices": [{"message": {"content": "done"}}]}) == "done"
assert _gateway_sse_delta({"choices": [{"delta": {}}]}) == ""
def test_gateway_stream_usage_normalizes_token_names():
assert _gateway_stream_usage({"usage": {"prompt_tokens": 7, "completion_tokens": 3}}) == {
"input_tokens": 7,
"output_tokens": 3,
"estimated_cost": 0,
}
assert _gateway_stream_usage({"usage": {"input_tokens": 5, "output_tokens": 2, "estimated_cost_usd": 0.01}}) == {
"input_tokens": 5,
"output_tokens": 2,
"estimated_cost": 0.01,
}
assert _gateway_stream_usage({}) == {}
def test_gateway_tool_progress_event_translates_gateway_lifecycle_payloads():
assert _gateway_tool_progress_event(
{
"tool": "terminal",
"label": "terminal: pytest",
"toolCallId": "call-1",
"status": "running",
}
) == (
"tool",
{
"event_type": "tool.started",
"name": "terminal",
"preview": "terminal: pytest",
"args": {},
"is_error": False,
"tid": "call-1",
},
)
assert _gateway_tool_progress_event(
{"tool": "terminal", "toolCallId": "call-1", "status": "completed"}
) == (
"tool_complete",
{
"event_type": "tool.completed",
"name": "terminal",
"preview": None,
"args": {},
"is_error": False,
"tid": "call-1",
},
)
assert _gateway_tool_progress_event(
{"tool": "_thinking", "status": "running", "preview": "Thinking..."}
) == (
"reasoning",
{
"text": "Thinking...",
},
)
assert _gateway_tool_progress_event(
{"tool": "_thinking", "status": "running", "text": "Thinking from text..."}
) == (
"reasoning",
{
"text": "Thinking from text...",
},
)
assert _gateway_tool_progress_event({"tool": "_thinking", "status": "running"}) is None
def test_gateway_tool_progress_event_bounds_pathological_args():
long_command = "python -c " + repr("print('x')\n" * 24)
event_name, event_payload = _gateway_tool_progress_event(
{
"tool": "terminal",
"toolCallId": "call-huge",
"status": "running",
"args": {
"command": long_command,
"items": [{"index": i, "payload": "x" * 100} for i in range(50_000)],
},
}
)
assert event_name == "tool"
assert event_payload["args"]["command"] == long_command
assert len(event_payload["args"]["items"]) <= 64
assert len(json.dumps(event_payload["args"], sort_keys=True)) < 100_000
def test_gateway_reasoning_delta_keeps_string_deltas_and_ignores_structured_payloads():
assert _gateway_reasoning_delta({"text": " Let me"}) == " Let me"
assert _gateway_reasoning_delta({"text": " ", "preview": " think"}) == " think"
assert _gateway_reasoning_delta({"content": {"text": "safe", "debug": {"note": "x"}}}) == ""
assert _gateway_reasoning_delta({"text": ["safe"], "preview": " more"}) == " more"
def test_gateway_sse_reasoning_delta_extracts_reasoning_content_chunks():
assert _gateway_sse_reasoning_delta({"choices": [{"delta": {"reasoning_content": "Let me"}}]}) == "Let me"
assert _gateway_sse_reasoning_delta({"choices": [{"message": {"reasoning_content": "Done thinking"}}]}) == "Done thinking"
assert _gateway_sse_reasoning_delta({"choices": [{"delta": {"reasoning_content": " "}}]}) == ""
def test_gateway_http_401_reports_gateway_auth_not_provider_key():
exc = urllib.error.HTTPError(
"http://gateway.local/v1/chat/completions",
401,
"Unauthorized",
hdrs=Message(),
fp=None,
)
event = _gateway_http_error_event(
exc,
'{"error":{"message":"Invalid API key","code":"invalid_api_key"}}',
api_key_configured=False,
)
assert event["label"] == "Gateway authentication failed"
assert event["type"] == "gateway_auth_error"
assert "HTTP 401" in event["message"]
assert "HERMES_WEBUI_GATEWAY_API_KEY" in event["hint"]
assert "API_SERVER_KEY" in event["hint"]
assert "Invalid API key" not in event["hint"]
def test_gateway_http_401_with_key_suggests_key_mismatch():
exc = urllib.error.HTTPError(
"http://gateway.local/v1/chat/completions",
401,
"Unauthorized",
hdrs=Message(),
fp=None,
)
event = _gateway_http_error_event(exc, "", api_key_configured=True)
assert event["type"] == "gateway_auth_error"
assert event["hint"] == "Check that HERMES_WEBUI_GATEWAY_API_KEY matches the Hermes Gateway API_SERVER_KEY."
def test_frontend_renders_gateway_auth_error_with_specific_label():
src = Path("static/messages.js").read_text(encoding="utf-8")
start = src.find("source.addEventListener('apperror'")
end = src.find("source.addEventListener('warning'", start)
assert start != -1 and end != -1, "apperror handler not found"
block = src[start:end]
assert "d.type==='gateway_auth_error'" in block
assert "isGatewayAuthError" in block
assert "gateway_auth_label" in block
assert "Gateway authentication failed" in block
assert "isGatewayAuthError?(typeof t==='function'?t('gateway_auth_label'):'Gateway authentication failed'):isAuthMismatch" in block, (
"Gateway API key failures should use their own label before generic provider mismatch handling."
)
def test_gateway_auth_label_i18n_key_exists_for_every_locale():
src = Path("static/i18n.js").read_text(encoding="utf-8")
locale_names = [
match.group("quoted") or match.group("plain")
for match in re.finditer(
r"^\s{2}(?:'(?P<quoted>[A-Za-z0-9-]+)'|(?P<plain>[A-Za-z0-9-]+))\s*:\s*\{",
src,
re.MULTILINE,
)
]
assert src.count("gateway_auth_label") >= len(locale_names)
def test_gateway_chat_health_payload_is_documented_as_operator_diagnostic_only():
# The Gateway-backed-chat operator docs moved out of the README into
# docs/advanced-chat-setup.md during the v0.51.192 README IA pass (it's a
# niche self-hosted feature). The contract — that gateway_chat is documented
# as an operator-only diagnostic, not a user-facing banner — now lives there.
# CHANGELOG keeps its release-note entry. (Contract test moved with content.)
advanced = Path("docs/advanced-chat-setup.md").read_text(encoding="utf-8")
changelog = Path("CHANGELOG.md").read_text(encoding="utf-8")
for text in (advanced, changelog):
assert "gateway_chat" in text
assert "operator diagnostic" in text
assert "not currently rendered as a user-facing health banner" in text
def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
captured = {}
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'event: hermes.tool.progress\n'
yield b'data: {"tool":"terminal","label":"terminal: pytest","toolCallId":"call-1","status":"running"}\n\n'
yield b'data: {"choices":[{"delta":{"content":"hel"}}]}\n\n'
yield b'event: hermes.tool.progress\n'
yield b'data: {"tool":"_thinking","text":"Thinking from tool progress"}\n\n'
yield b'event: reasoning.available\n'
yield b'data: {"text":"Reasoning preview", "preview":"Reasoning preview"}\n\n'
yield b'event: hermes.tool.progress\n'
yield b'data: {"tool":"terminal","toolCallId":"call-1","status":"completed"}\n\n'
yield b'data: {"choices":[{"delta":{"content":"lo"}}],"usage":{"prompt_tokens":4,"completion_tokens":2}}\n\n'
yield b'data: [DONE]\n\n'
def fake_urlopen(req, timeout=0):
captured["url"] = req.full_url
captured["headers"] = dict(req.header_items())
captured["body"] = req.data.decode("utf-8")
return FakeResponse()
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_API_KEY", "secret-token")
monkeypatch.setattr(gateway_chat, "_gateway_reasoning_effort_for_request", lambda *args, **kwargs: "high")
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {
"status": "loaded",
"source": "test",
"label": "test",
"message_count": 2,
"messages": [
{"role": "assistant", "content": "prefill summary"},
{"role": "user", "content": "prefill"},
],
})
monkeypatch.setattr(streaming, "_prefill_messages_with_webui_context", lambda ctx, cfg: list(ctx["messages"]) + [{"role": "user", "content": "webui session context"}])
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
s = new_session()
stream_id = "stream-gateway-test"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.pending_started_at = 123
s.save()
channel = create_stream_channel()
subscriber = channel.subscribe()
STREAMS[stream_id] = channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
stream_id,
[],
)
saved = models.get_session(s.session_id)
assert [m["role"] for m in saved.messages] == ["user", "assistant"]
assert saved.messages[-1]["content"] == "hello"
assert isinstance(saved.messages[0]["timestamp"], float)
assert isinstance(saved.messages[1]["timestamp"], float)
assert saved.messages[0]["timestamp"] < saved.messages[1]["timestamp"]
assert saved.active_stream_id is None
assert stream_id not in STREAMS
assert captured["url"] == "http://gateway.local/v1/chat/completions"
assert captured["headers"]["Authorization"] == "Bearer secret-token"
assert captured["headers"]["X-hermes-session-id"] == s.session_id
assert captured["headers"]["X-hermes-session-key"] == f"webui:{s.session_id}"
assert '"stream": true' in captured["body"]
payload = json.loads(captured["body"])
assert payload["reasoning_effort"] == "high"
# #3324: the gateway path's first system message is now the full WebUI
# ephemeral system prompt (progress prompt + session/delivery context),
# NOT the bare _WEBUI_PROGRESS_PROMPT — otherwise the delivery/session
# context is silently dropped on Gateway-routed WebUI chats.
system_msg = payload["messages"][0]
assert system_msg["role"] == "system"
assert "Final visible assistant replies" in system_msg["content"]
assert "Need script" in system_msg["content"]
# The moved session/delivery context must be present in the system prompt.
assert "Connected Platforms:" in system_msg["content"]
assert "Delivery options for scheduled tasks:" in system_msg["content"]
# The gateway path keeps safe recall prefill context while removing
# terminal user-role prefill before the actual browser user turn.
assert [m["content"] for m in payload["messages"][1:]] == [
"prefill summary",
"Say hello",
]
assert [m["role"] for m in payload["messages"]] == ["system", "assistant", "user"]
events = []
while not subscriber.empty():
events.append(subscriber.get_nowait())
event_pairs = [(item[0], item[1]) for item in events]
assert ("tool", {
"event_type": "tool.started",
"name": "terminal",
"preview": "terminal: pytest",
"args": {},
"is_error": False,
"tid": "call-1",
}) in event_pairs
assert ("reasoning", {"text": "Thinking from tool progress"}) in event_pairs
assert ("reasoning", {"text": "Reasoning preview"}) in event_pairs
assert ("tool_complete", {
"event_type": "tool.completed",
"name": "terminal",
"preview": None,
"args": {},
"is_error": False,
"tid": "call-1",
}) in event_pairs
assert all(len(item) == 3 and item[2] for item in events)
def test_gateway_chat_worker_records_turn_journal_completion(tmp_path, monkeypatch):
"""#6366 re-gate: a successful Gateway run must record durable
same-stream completion evidence in the crash-safe turn journal.
The stale-cancel recovery predicate derives its completion evidence
from the turn journal, so a Gateway run that persisted its final
answer but lost the run journal's terminal write would otherwise
have no completion evidence at all — and recovery would re-append a
duplicate recovered row after the valid final answer.
"""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'
yield b"data: [DONE]\n\n"
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_API_KEY", "secret-token")
monkeypatch.setattr(
gateway_chat.urllib.request,
"urlopen",
lambda req, timeout=0: FakeResponse(),
)
s = new_session()
stream_id = "stream-gateway-turn-journal-completed"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.pending_started_at = 456
s.save()
channel = create_stream_channel()
STREAMS[stream_id] = channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
stream_id,
[],
)
journal = read_turn_journal(s.session_id, session_dir=session_dir)
states, _ = derive_turn_journal_states(journal.get("events") or [])
stream_events = [
event
for event in states.values()
if str(event.get("stream_id") or "") == stream_id
]
assert stream_events, "expected turn journal events for the gateway stream"
assert any(
event.get("event") == "completed" for event in stream_events
), "the gateway success writeback must record a completed turn journal event"
def test_gateway_chat_worker_classifies_terminal_provider_error_without_text(tmp_path, monkeypatch):
"""Gateway terminal errors must survive an empty assistant stream."""
from unittest.mock import MagicMock
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
error_text = (
'HTTP 400: {"detail":"Invalid Request: Invalid model format or no credentials '
'for provider: <redacted>"}'
)
response_error = [error_text]
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
if response_error[0] == "partial":
yield b'data: {"choices":[{"delta":{"content":"partial"}}]}\n\n'
yield f'data: {{"error":{json.dumps(error_text)}}}\n\n'.encode()
elif response_error[0]:
yield f'data: {{"error":{json.dumps(response_error[0])}}}\n\n'.encode()
yield b"data: [DONE]\n\n"
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {"status": "not_configured", "source": "none", "label": "", "message_count": 0, "messages": []})
monkeypatch.setattr(streaming, "_prefill_messages_with_webui_context", lambda ctx, cfg: [])
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse())
events = []
channel = MagicMock()
channel.put_nowait = lambda item: events.append(item)
s = new_session()
stream_id = "stream-gateway-terminal-provider-error-test"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_started_at = 222
s.pending_attachments = [{"name": "current.png"}]
s.messages = [
{"role": "user", "content": "Say hello", "timestamp": 111, "attachments": [{"name": "old.png"}]},
{"role": "assistant", "content": "Earlier answer"},
]
s.context_messages = [
{"role": "user", "content": "Say hello", "timestamp": 111, "attachments": [{"name": "old.png"}]},
{"role": "assistant", "content": "Earlier answer"},
]
s.save()
STREAMS[stream_id] = channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
stream_id,
[],
)
apperrors = [item[1] for item in events if item[0] == "apperror"]
assert apperrors
assert apperrors[-1]["type"] in {"model_not_found", "auth_mismatch"}
assert apperrors[-1]["session_id"] == s.session_id
saved = models.get_session(s.session_id)
user_messages = [m for m in saved.messages if m.get("role") == "user"]
assert len(user_messages) == 2
assert user_messages[-1]["timestamp"] == 222
assert user_messages[-1]["attachments"] == [{"name": "current.png"}]
context_users = [m for m in saved.context_messages if m.get("role") == "user"]
assert len(context_users) == 2
assert context_users[-1]["timestamp"] == 222
assert context_users[-1]["attachments"] == [{"name": "current.png"}]
assert saved.messages[-1].get("_error") is True
response_error[0] = ""
empty_stream_id = "stream-gateway-empty-response-test"
s = new_session()
s.active_stream_id = empty_stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.save()
empty_events = []
empty_channel = MagicMock()
empty_channel.put_nowait = lambda item: empty_events.append(item)
STREAMS[empty_stream_id] = empty_channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
empty_stream_id,
[],
)
empty_errors = [item[1] for item in empty_events if item[0] == "apperror"]
assert empty_errors[-1]["type"] == "gateway_empty_response"
assert empty_errors[-1]["session_id"] == s.session_id
response_error[0] = "Gateway provider failed without a known classification"
unknown_stream_id = "stream-gateway-unknown-terminal-error-test"
s = new_session()
s.active_stream_id = unknown_stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.save()
unknown_events = []
unknown_channel = MagicMock()
unknown_channel.put_nowait = lambda item: unknown_events.append(item)
STREAMS[unknown_stream_id] = unknown_channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
unknown_stream_id,
[],
)
unknown_errors = [item[1] for item in unknown_events if item[0] == "apperror"]
assert unknown_errors[-1]["type"] == "error"
assert "Gateway provider failed" in unknown_errors[-1]["message"]
unknown_payload_error = unknown_errors[-1]["session"]["messages"][-1]
assert unknown_payload_error.get("_error") is True
assert "_turnDuration" not in unknown_payload_error
response_error[0] = error_text
future_stream_id = "stream-gateway-future-duration-terminal-error-test"
s = new_session()
s.active_stream_id = future_stream_id
s.pending_user_message = "Say hello"
s.pending_started_at = time.time() + 30
s.pending_attachments = []
s.save()
future_events = []
future_channel = MagicMock()
future_channel.put_nowait = lambda item: future_events.append(item)
STREAMS[future_stream_id] = future_channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
future_stream_id,
[],
)
future_errors = [item[1] for item in future_events if item[0] == "apperror"]
assert future_errors[-1]["type"] in {"model_not_found", "auth_mismatch"}
saved = models.get_session(s.session_id)
assert saved.messages[-1].get("_error") is True
assert "_turnDuration" not in saved.messages[-1]
future_payload_error = future_errors[-1]["session"]["messages"][-1]
assert future_payload_error.get("_error") is True
assert "_turnDuration" not in future_payload_error
response_error[0] = "partial"
partial_stream_id = "stream-gateway-partial-terminal-error-test"
s = new_session()
s.active_stream_id = partial_stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.save()
partial_events = []
partial_channel = MagicMock()
partial_channel.put_nowait = lambda item: partial_events.append(item)
STREAMS[partial_stream_id] = partial_channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
partial_stream_id,
[],
)
partial_errors = [item[1] for item in partial_events if item[0] == "apperror"]
assert partial_errors[-1]["type"] in {"model_not_found", "auth_mismatch"}
saved = models.get_session(s.session_id)
assert [message.get("role") for message in saved.messages[-3:]] == ["user", "assistant", "assistant"]
partial_message = saved.messages[-2]
assert partial_message.get("_partial") is True
assert partial_message["content"] == "partial"
error_message = saved.messages[-1]
assert error_message.get("_error") is True
assert "Invalid Request" in error_message.get("provider_details", "")
payload_messages = partial_errors[-1]["session"]["messages"]
assert payload_messages[-2]["_partial"] is True
assert payload_messages[-2]["content"] == "partial"
assert payload_messages[-1]["_error"] is True
assert "_turnDuration" not in payload_messages[-1]
def test_gateway_chat_worker_persists_reasoning_and_tool_state_on_terminal_error(tmp_path, monkeypatch):
from unittest.mock import MagicMock
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
error_text = (
'HTTP 400: {"detail":"Invalid Request: Invalid model format or no credentials '
'for provider: <redacted>"}'
)
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"part"}}]}\n\n'
yield b'event: hermes.tool.progress\n'
yield b'data: {"tool":"terminal","label":"terminal: pytest","toolCallId":"call-1","status":"running","arguments":{}}\n\n'
yield b'event: reasoning.available\n'
yield b'data: {"text":"Preview reasoning"}\n\n'
yield f'data: {{"error":{json.dumps(error_text)}}}\n\n'.encode()
yield b"data: [DONE]\n\n"
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {"status": "not_configured", "source": "none", "label": "", "message_count": 0, "messages": []})
monkeypatch.setattr(streaming, "_prefill_messages_with_webui_context", lambda ctx, cfg: [])
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse())
events = []
channel = MagicMock()
channel.put_nowait = lambda item: events.append(item)
s = new_session()
stream_id = "stream-gateway-terminal-reasoning-tool-error-test"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.save()
STREAMS[stream_id] = channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
stream_id,
[],
)
saved = models.get_session(s.session_id)
partial_message = saved.messages[-2]
assert partial_message.get("_partial") is True
assert partial_message["content"] == "part"
assert partial_message["reasoning"] == "Preview reasoning"
assert partial_message["_partial_tool_calls"] == [{
"name": "terminal",
"args": {},
"done": True,
"tid": "call-1",
"_sealed_by_terminal_error": True,
}]
apperrors = [item[1] for item in events if item[0] == "apperror"]
assert apperrors[-1]["session"]["messages"][-2]["reasoning"] == "Preview reasoning"
assert apperrors[-1]["session"]["messages"][-2]["_partial_tool_calls"][0]["name"] == "terminal"
assert apperrors[-1]["session"]["messages"][-1]["_error"] is True
def test_gateway_chat_worker_preserves_reasoning_delta_whitespace_and_persists_reasoning(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"hel"}}]}\n\n'
yield b'event: hermes.tool.progress\n'
yield b'data: {"tool":"_thinking","text":"Let me"}\n\n'
yield b'event: reasoning.available\n'
yield b'data: {"text":" think", "preview":"should not win"}\n\n'
yield b'event: reasoning.available\n'
yield b'data: {"content":{"text":"safe","debug":{"note":"x"}}}\n\n'
yield b'event: reasoning.available\n'
yield b'data: {"preview":" more"}\n\n'
yield b'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n'
yield b'data: [DONE]\n\n'
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse())
s = new_session()
stream_id = "stream-gateway-reasoning-persist-test"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.pending_started_at = 123
s.save()
channel = create_stream_channel()
subscriber = channel.subscribe()
STREAMS[stream_id] = channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
stream_id,
[],
)
saved = models.get_session(s.session_id)
assert saved.messages[-1]["content"] == "hello"
assert saved.messages[-1]["reasoning"] == "Let me think more"
reasoning_events = []
while not subscriber.empty():
item = subscriber.get_nowait()
if item[0] == "reasoning":
reasoning_events.append(item[1]["text"])
assert reasoning_events == ["Let me", " think", " more"]
assert not any("debug" in text for text in reasoning_events)
def test_gateway_chat_worker_reads_reasoning_content_deltas_from_chat_completions(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"reasoning_content":"Let me ","content":"hel"}}]}\n\n'
yield b'data: {"choices":[{"delta":{"reasoning_content":"think","content":"lo"}}]}\n\n'
yield b'data: [DONE]\n\n'
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse())
s = new_session()
stream_id = "stream-gateway-reasoning-content-test"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.pending_started_at = 123
s.save()
channel = create_stream_channel()
subscriber = channel.subscribe()
STREAMS[stream_id] = channel
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
stream_id,
[],
)
saved = models.get_session(s.session_id)
assert saved.messages[-1]["content"] == "hello"
assert saved.messages[-1]["reasoning"] == "Let me think"
reasoning_events = []
while not subscriber.empty():
item = subscriber.get_nowait()
if item[0] == "reasoning":
reasoning_events.append(item[1]["text"])
assert reasoning_events == ["Let me ", "think"]
def test_gateway_chat_worker_emits_goal_continue_for_goal_related_turn(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"goal "}}]}\n\n'
yield b'data: {"choices":[{"delta":{"content":"reply"}}]}\n\n'
yield b'data: [DONE]\n\n'
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse())
from api import goals as webui_goals
monkeypatch.setattr(webui_goals, "has_active_goal", lambda *args, **kwargs: True)
monkeypatch.setattr(
webui_goals,
"evaluate_goal_after_turn",
lambda *args, **kwargs: {
"should_continue": True,
"continuation_prompt": "continue the goal",
"message": "Continuing goal",
"message_key": "goal_continuing",
"message_args": ["one step remains"],
},
)
s = new_session()
stream_id = "stream-gateway-goal-continue"
s.active_stream_id = stream_id
s.pending_user_message = "finish it"
s.pending_attachments = []
s.pending_started_at = 123
s.save()
channel = create_stream_channel()
subscriber = channel.subscribe()
STREAMS[stream_id] = channel
PENDING_GOAL_CONTINUATION.discard(s.session_id)
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"finish it",
"test-model",
str(tmp_path),
stream_id,
[],
goal_related=True,
)
saved = models.get_session(s.session_id)
events = []
while not subscriber.empty():
events.append(subscriber.get_nowait())
event_names = [item[0] for item in events]
assert event_names.count("goal") == 2
assert "goal_continue" in event_names
assert "done" in event_names
assert "stream_end" in event_names
assert event_names.index("goal_continue") < event_names.index("done")
assert event_names.index("done") < event_names.index("stream_end")
assert s.session_id in PENDING_GOAL_CONTINUATION
goal_continue_event = next(item for item in events if item[0] == "goal_continue")
assert goal_continue_event[1]["continuation_prompt"] == "continue the goal"
assert goal_continue_event[1]["message"] == "Continuing goal"
assert goal_continue_event[1]["message_key"] == "goal_continuing"
assert saved.messages[-1]["role"] == "assistant"
assert saved.messages[-1]["content"] == "goal reply"
def test_gateway_goal_eval_keeps_runtime_active_after_success_writeback_before_done(
tmp_path, monkeypatch
):
"""Persisted idle flags can precede terminal emission while STREAMS is still active."""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"goal reply"}}]}\n\n'
yield b"data: [DONE]\n\n"
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(
gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse()
)
from api import goals as webui_goals
goal_entered = threading.Event()
release_goal = threading.Event()
observed = {}
monkeypatch.setattr(webui_goals, "has_active_goal", lambda *args, **kwargs: True)
def blocked_goal_eval(*args, **kwargs):
saved = models.get_session(s.session_id)
observed["active_stream_id"] = saved.active_stream_id
observed["pending_user_message"] = saved.pending_user_message
observed["stream_registered"] = stream_id in STREAMS
goal_entered.set()
assert release_goal.wait(timeout=10)
return {"should_continue": False, "message": "Goal checked"}
monkeypatch.setattr(webui_goals, "evaluate_goal_after_turn", blocked_goal_eval)
s = new_session()
stream_id = "stream-gateway-goal-runtime-window"
s.active_stream_id = stream_id
s.pending_user_message = "finish it"
s.pending_attachments = []
s.pending_started_at = 123
s.save()
channel = create_stream_channel()
subscriber = channel.subscribe()
STREAMS[stream_id] = channel
worker = threading.Thread(
target=gateway_chat._run_gateway_chat_streaming,
args=(
s.session_id,
"finish it",
"test-model",
str(tmp_path),
stream_id,
[],
),
kwargs={"goal_related": True},
daemon=True,
)
worker.start()
assert goal_entered.wait(timeout=10), "worker did not reach post-writeback goal evaluation"
# This is the exact authority split the browser recovery must respect:
# persisted session metadata already looks idle, while the runtime stream
# (and therefore /api/chat/stream/status) is still active and can emit
# goal / goal_continue / done / stream_end.
assert observed == {
"active_stream_id": None,
"pending_user_message": None,
"stream_registered": True,
}
assert stream_id in STREAMS
assert worker.is_alive()
assert not any(item[0] == "done" for item in list(subscriber.queue))
release_goal.set()
worker.join(timeout=10)
assert not worker.is_alive()
events = []
while not subscriber.empty():
events.append(subscriber.get_nowait())
names = [item[0] for item in events]
assert "goal" in names
assert "done" in names
assert "stream_end" in names
assert names.index("goal") < names.index("done") < names.index("stream_end")
def test_gateway_chat_worker_skips_goal_judge_for_non_goal_turn(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"plain reply"}}]}\n\n'
yield b'data: [DONE]\n\n'
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse())
from api import goals as webui_goals
has_goal_calls = []
judge_calls = []
monkeypatch.setattr(
webui_goals,
"has_active_goal",
lambda *args, **kwargs: has_goal_calls.append((args, kwargs)) or True,
)
monkeypatch.setattr(
webui_goals,
"evaluate_goal_after_turn",
lambda *args, **kwargs: judge_calls.append((args, kwargs)),
)
s = new_session()
stream_id = "stream-gateway-no-goal"
s.active_stream_id = stream_id
s.pending_user_message = "plain turn"
s.pending_attachments = []
s.pending_started_at = 123
s.save()
channel = create_stream_channel()
subscriber = channel.subscribe()
STREAMS[stream_id] = channel
PENDING_GOAL_CONTINUATION.discard(s.session_id)
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"plain turn",
"test-model",
str(tmp_path),
stream_id,
[],
goal_related=False,
)
events = []
while not subscriber.empty():
events.append(subscriber.get_nowait())
event_names = [item[0] for item in events]
assert "goal" not in event_names
assert "goal_continue" not in event_names
assert "done" in event_names
assert "stream_end" in event_names
assert has_goal_calls == []
assert judge_calls == []
assert s.session_id not in PENDING_GOAL_CONTINUATION
def test_gateway_chat_worker_normalizes_prefill_slice_before_system_prefix(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
captured = {}
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"done"}}]}\n\n'
yield b'data: [DONE]\n\n'
prefill_raw = [
{"role": "assistant", "content": "prefill summary"},
{"role": "user", "content": "first terminal user"},
{"role": "user", "content": "second terminal user"},
]
def fake_urlopen(req, timeout=0):
captured["body"] = json.loads(req.data.decode("utf-8"))
return FakeResponse()
original_normalizer = streaming._normalize_prefill_messages_before_user_turn
def recording_normalizer(messages):
captured["normalizer_input"] = list(messages)
return original_normalizer(messages)
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {
"status": "loaded",
"source": "test",
"label": "test",
"message_count": len(prefill_raw),
"messages": prefill_raw,
})
monkeypatch.setattr(streaming, "_prefill_messages_with_webui_context", lambda ctx, cfg: list(ctx["messages"]))
monkeypatch.setattr(streaming, "_normalize_prefill_messages_before_user_turn", recording_normalizer)
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
s = new_session()
stream_id = "stream-gateway-prefill-slice-test"
s.active_stream_id = stream_id
s.pending_user_message = "Say hello"
s.pending_attachments = []
s.save()
STREAMS[stream_id] = create_stream_channel()
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"Say hello",
"test-model",
str(tmp_path),
stream_id,
[],
)
assert captured["normalizer_input"] == prefill_raw
payload_messages = captured["body"]["messages"]
assert [m["role"] for m in payload_messages] == ["system", "assistant", "user"]
assert [m["content"] for m in payload_messages[1:]] == ["prefill summary", "Say hello"]
def test_gateway_chat_worker_backfills_context_only_turns_into_display(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"done"}}]}\n\n'
yield b'data: [DONE]\n\n'
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {"status": "not_configured", "source": "none", "label": "", "message_count": 0, "messages": []})
monkeypatch.setattr(streaming, "_prefill_messages_with_webui_context", lambda ctx, cfg: [])
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse())
s = new_session()
s.context_messages = [
{
"role": "assistant",
"content": "[context compaction] Hidden summary for model continuity.",
"timestamp": 9.5,
},
{"role": "user", "content": "delete the matrix apps", "timestamp": 10.0},
{"role": "assistant", "content": "I will verify the Matrix cleanup targets.", "timestamp": 10.1},
]
s.messages = [
{"role": "user", "content": "when done also delete tunesync", "timestamp": 11.0},
]
stream_id = "stream-gateway-context-backfill-test"
s.active_stream_id = stream_id
s.pending_user_message = "when done also delete tunesync"
s.pending_attachments = []
s.save()
STREAMS[stream_id] = create_stream_channel()
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"when done also delete tunesync",
"test-model",
str(tmp_path),
stream_id,
[],
)
saved = models.get_session(s.session_id)
assert [m["content"] for m in saved.messages] == [
"delete the matrix apps",
"I will verify the Matrix cleanup targets.",
"when done also delete tunesync",
"done",
]
assert len(saved.messages) == 4
assert not any("context compaction" in m["content"] for m in saved.messages)
def test_gateway_chat_worker_preserves_old_visible_turns_when_context_is_compacted(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"new answer"}}]}\n\n'
yield b'data: [DONE]\n\n'
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {"status": "not_configured", "source": "none", "label": "", "message_count": 0, "messages": []})
monkeypatch.setattr(streaming, "_prefill_messages_with_webui_context", lambda ctx, cfg: [])
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse())
s = new_session()
old_visible_turns = [
{"role": "user", "content": "turn one", "timestamp": 1.0},
{"role": "assistant", "content": "answer one", "timestamp": 1.1},
{"role": "user", "content": "turn two", "timestamp": 2.0},
{"role": "assistant", "content": "answer two", "timestamp": 2.1},
{"role": "user", "content": "recent turn", "timestamp": 3.0},
{"role": "assistant", "content": "recent answer", "timestamp": 3.1},
]
s.messages = old_visible_turns + [
{"role": "user", "content": "new question", "timestamp": 4.0},
]
s.context_messages = [
{
"role": "assistant",
"content": "[context compaction] Hidden summary for model continuity.",
"timestamp": 2.9,
},
old_visible_turns[-2],
old_visible_turns[-1],
]
stream_id = "stream-gateway-compacted-visible-preserve-test"
s.active_stream_id = stream_id
s.pending_user_message = "new question"
s.pending_attachments = []
s.save()
STREAMS[stream_id] = create_stream_channel()
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"new question",
"test-model",
str(tmp_path),
stream_id,
[],
)
saved = models.get_session(s.session_id)
assert [m["content"] for m in saved.messages] == [
"turn one",
"answer one",
"turn two",
"answer two",
"recent turn",
"recent answer",
"new question",
"new answer",
]
assert not any("context compaction" in m["content"] for m in saved.messages)
def test_gateway_chat_worker_keeps_repeated_identical_visible_turns(tmp_path, monkeypatch):
"""#3300 regression (Codex gate): two identical visible user turns must BOTH
survive gateway finalization even when context-only rows are backfilled.
_message_identity ignores timestamps, so a shared identity must not let the
backfill dedup suppress the second visible turn."""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"answer"}}]}\n\n'
yield b'data: [DONE]\n\n'
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {"status": "not_configured", "source": "none", "label": "", "message_count": 0, "messages": []})
monkeypatch.setattr(streaming, "_prefill_messages_with_webui_context", lambda ctx, cfg: [])
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse())
s = new_session()
# Two identical visible "same" user turns surround a context-only gap that
# only lives in context_messages (plus a hidden compaction marker).
s.messages = [
{"role": "user", "content": "same", "timestamp": 1.0},
{"role": "assistant", "content": "first reply", "timestamp": 1.1},
{"role": "user", "content": "same", "timestamp": 3.0},
{"role": "user", "content": "new question", "timestamp": 4.0},
]
s.context_messages = [
{"role": "assistant", "content": "[context compaction] hidden", "timestamp": 0.9},
{"role": "user", "content": "same", "timestamp": 1.0},
{"role": "assistant", "content": "first reply", "timestamp": 1.1},
{"role": "user", "content": "context only gap", "timestamp": 2.0},
{"role": "user", "content": "same", "timestamp": 3.0},
]
stream_id = "stream-gateway-repeated-identical-turns-test"
s.active_stream_id = stream_id
s.pending_user_message = "new question"
s.pending_attachments = []
s.save()
STREAMS[stream_id] = create_stream_channel()
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"new question",
"test-model",
str(tmp_path),
stream_id,
[],
)
saved = models.get_session(s.session_id)
contents = [m["content"] for m in saved.messages]
# BOTH identical "same" visible turns must survive (the original bug dropped one).
assert contents.count("same") == 2, contents
# The context-only gap is backfilled into the visible transcript.
assert "context only gap" in contents
# The latest turn + reply are present.
assert contents[-2:] == ["new question", "answer"]
# No compaction marker leaks into the visible transcript.
assert not any("context compaction" in c for c in contents)
def test_gateway_chat_worker_forwards_image_attachments_as_multimodal_parts(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
image_bytes = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
image_path = tmp_path / "photo.png"
image_path.write_bytes(image_bytes)
captured = {}
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"saw it"}}]}\n\n'
yield b'data: [DONE]\n\n'
def fake_urlopen(req, timeout=0):
captured["body"] = json.loads(req.data.decode("utf-8"))
return FakeResponse()
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {"status": "not_configured", "source": "none", "label": "", "message_count": 0, "messages": []})
monkeypatch.setattr(streaming, "_prefill_messages_with_webui_context", lambda ctx, cfg: [{"role": "user", "content": "webui session context"}])
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
s = new_session()
stream_id = "stream-gateway-image-test"
s.active_stream_id = stream_id
s.save()
STREAMS[stream_id] = create_stream_channel()
gateway_chat._run_gateway_chat_streaming(
s.session_id,
"What is in this image?",
"test-model",
str(tmp_path),
stream_id,
[{"path": str(image_path), "mime": "image/png", "is_image": True}],
)
content = captured["body"]["messages"][-1]["content"]
assert captured["body"]["messages"][0]["role"] == "system"
assert "Final visible assistant replies" in captured["body"]["messages"][0]["content"]
image_payload = captured["body"]["messages"][1]
assert image_payload["role"] == "user"
assert image_payload["content"][0] == {"type": "text", "text": "What is in this image?"}
assert content[0] == {"type": "text", "text": "What is in this image?"}
assert content[1]["type"] == "image_url"
assert content[1]["image_url"]["url"].startswith("data:image/png;base64,")
# ── #4113 (salvage): _resolve_image_input_mode delegates to the agent's
# canonical decide_image_input_mode, but preserves the WebUI carve-out that
# UNKNOWN/custom models still forward images NATIVELY. ──────────────────────
#
# The agent package is not importable in the WebUI standalone test environment
# (``import agent`` raises ModuleNotFoundError), so we inject fake
# ``agent.image_routing`` / ``agent.auxiliary_client`` modules to exercise the
# real delegation branch. The fakes let us control exactly what the canonical
# router returns and what capability lookup reports, so each behaviour is
# pinned independently of models.dev data.
def _install_fake_agent_routing(monkeypatch, *, decision, supports,
provider="customcorp", model="mystery-9000"):
"""Inject fake agent.image_routing + agent.auxiliary_client into sys.modules.
``decision`` is what the canonical ``decide_image_input_mode`` returns;
``supports`` is what ``_lookup_supports_vision`` returns (True / False /
None — None means "unknown / no capability data").
"""
import sys
import types
img = types.ModuleType("agent.image_routing")
img.decide_image_input_mode = lambda p, m, cfg, **kw: decision
img._lookup_supports_vision = lambda p, m, cfg=None, **kw: supports
aux = types.ModuleType("agent.auxiliary_client")
aux._read_main_provider = lambda: provider
aux._read_main_model = lambda: model
pkg = types.ModuleType("agent")
monkeypatch.setitem(sys.modules, "agent", pkg)
monkeypatch.setitem(sys.modules, "agent.image_routing", img)
monkeypatch.setitem(sys.modules, "agent.auxiliary_client", aux)
def test_resolve_image_input_mode_unknown_model_forwards_native(monkeypatch):
"""WebUI carve-out: an UNKNOWN/custom model (no capability data) forwards
images natively even though the canonical router conservatively returns
``"text"`` for it.
This is the behaviour the gateway image-forwarding test relies on, and the
agent's strip-and-retry guard downgrades to text on a provider rejection.
"""
_install_fake_agent_routing(monkeypatch, decision="text", supports=None)
cfg = {"agent": {"image_input_mode": "auto"},
"auxiliary": {"vision": {"provider": "auto"}}}
assert streaming._resolve_image_input_mode(cfg) == "native"
def test_resolve_image_input_mode_known_text_only_routes_text(monkeypatch):
"""NON-VACUOUS regression for #4113's real divergence.
The OLD local re-implementation never consulted model capability, so a
model KNOWN to lack vision (``supports_vision == False``) still got images
embedded as native ``image_url`` parts — silently sending pixels to a model
that cannot see them (#21160). Delegating to the canonical router fixes
this: a known text-only model now routes through the text (vision_analyze)
pipeline.
Against master this assertion FAILS — the old code returns ``"native"`` for
this exact config (auto mode, no explicit vision backend) because it ignored
capability entirely.
"""
_install_fake_agent_routing(monkeypatch, decision="text", supports=False)
cfg = {"agent": {"image_input_mode": "auto"},
"auxiliary": {"vision": {"provider": "auto"}}}
assert streaming._resolve_image_input_mode(cfg) == "text"
def test_resolve_image_input_mode_known_vision_model_forwards_native(monkeypatch):
"""A model KNOWN to support vision forwards natively (canonical native)."""
_install_fake_agent_routing(monkeypatch, decision="native", supports=True)
cfg = {"agent": {"image_input_mode": "auto"}}
assert streaming._resolve_image_input_mode(cfg) == "native"
def test_resolve_image_input_mode_explicit_text_signal_honored(monkeypatch):
"""An explicit user choice for the text pipeline is honoured even for an
unknown model — the carve-out only fires when there is NO explicit signal.
Both an explicit ``agent.image_input_mode: text`` and a configured
``auxiliary.vision`` backend count as explicit signals.
"""
_install_fake_agent_routing(monkeypatch, decision="text", supports=None)
assert streaming._resolve_image_input_mode(
{"agent": {"image_input_mode": "text"}}) == "text"
assert streaming._resolve_image_input_mode(
{"agent": {"image_input_mode": "auto"},
"auxiliary": {"vision": {"provider": "openai", "model": "gpt-4o"}}}) == "text"
def test_resolve_image_input_mode_fallback_when_agent_unavailable(monkeypatch):
"""When the agent package cannot be imported (standalone WebUI env), fall
back to historical WebUI behaviour: explicit text signal wins, else native.
"""
import sys
# Ensure the delegation import fails: stub agent.image_routing as a module
# that raises on attribute access of the routing fn would still import, so
# instead force an ImportError by mapping the submodule to None.
monkeypatch.setitem(sys.modules, "agent", None)
monkeypatch.setitem(sys.modules, "agent.image_routing", None)
# No explicit signal -> native (this is what keeps the gateway image test
# green, since agent is not importable there either).
assert streaming._resolve_image_input_mode(
{"agent": {"image_input_mode": "auto"},
"auxiliary": {"vision": {"provider": "auto"}}}) == "native"
# Explicit text mode -> text.
assert streaming._resolve_image_input_mode(
{"agent": {"image_input_mode": "text"}}) == "text"
# Explicit auxiliary vision backend -> text.
assert streaming._resolve_image_input_mode(
{"auxiliary": {"vision": {"provider": "anthropic"}}}) == "text"
def test_gateway_use_runs_api_is_default_off():
for env in ({}, {"HERMES_WEBUI_GATEWAY_USE_RUNS_API": ""}):
assert _gateway_use_runs_api_enabled({}, env) is False
def test_gateway_use_runs_api_only_accepts_explicit_truthy_values():
for value in ("1", "true", "yes", "on", " True ", " ON "):
assert _gateway_use_runs_api_enabled({}, {"HERMES_WEBUI_GATEWAY_USE_RUNS_API": value}) is True
def test_gateway_use_runs_api_rejects_generic_truthy_strings():
for value in ("enabled", "gateway", "api_server", "absolutely"):
assert _gateway_use_runs_api_enabled({}, {"HERMES_WEBUI_GATEWAY_USE_RUNS_API": value}) is False
def test_gateway_use_runs_api_can_be_enabled_from_config():
assert _gateway_use_runs_api_enabled({"webui_gateway_use_runs_api": "true"}, {}) is True
assert _gateway_use_runs_api_enabled({"webui_gateway_use_runs_api": "1"}, {}) is True
def test_gateway_use_runs_api_env_wins_over_config():
assert _gateway_use_runs_api_enabled(
{"webui_gateway_use_runs_api": "true"},
{"HERMES_WEBUI_GATEWAY_USE_RUNS_API": "false"},
) is False
def test_gateway_runs_api_body_includes_session_id():
"""#4535: the runs API body must carry session_id so the agent reuses the
browser session instead of creating a fresh run_<uuid> per message."""
from unittest.mock import patch, MagicMock
from api.config import STREAMS, STREAMS_LOCK
from api.gateway_chat import _run_gateway_chat_streaming
captured = {}
events = []
q = MagicMock()
q.put_nowait = lambda item: events.append(item)
stream_id = "sid-runs-session-id"
with STREAMS_LOCK:
STREAMS[stream_id] = q
call_count = [0]
def fake_urlopen(req, *, timeout=None):
call_count[0] += 1
if call_count[0] == 1:
captured["body"] = json.loads(req.data.decode("utf-8"))
captured["url"] = req.full_url
resp = MagicMock()
resp.read = lambda sz=65536: json.dumps({"run_id": "run_abc"}).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = lambda s, *a: None
return resp
resp = MagicMock()
resp.__iter__ = lambda s: iter([
b'data: {"choices":[{"delta":{"content":"ok"}}]}\n',
b'data: [DONE]\n',
])
resp.__enter__ = lambda s: s
resp.__exit__ = lambda s, *a: None
return resp
import os
env = {k: v for k, v in os.environ.items()}
env["HERMES_WEBUI_CHAT_BACKEND"] = "gateway"
env["HERMES_WEBUI_GATEWAY_USE_RUNS_API"] = "1"
env["HERMES_WEBUI_GATEWAY_BASE_URL"] = "http://gateway.local"
try:
with patch.dict("os.environ", env, clear=True):
with patch("api.gateway_chat.gateway_supports_approval", return_value=True), \
patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.gateway_chat.get_session", return_value=MagicMock(
active_stream_id=stream_id, workspace="/tmp",
profile=None, context_messages=[], messages=[],
)):
_run_gateway_chat_streaming(
session_id="sess-stable-uuid",
msg_text="hi",
model="test",
workspace="/tmp",
stream_id=stream_id,
)
assert "/v1/runs" in captured["url"]
assert captured["body"]["session_id"] == "sess-stable-uuid"
finally:
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
def test_gateway_runs_api_classifies_terminal_provider_error(tmp_path, monkeypatch):
from unittest.mock import MagicMock, patch
from api.config import STREAMS, STREAMS_LOCK
from api.gateway_chat import _run_gateway_chat_streaming
error_text = "HTTP 400: Invalid model format or no credentials for provider"
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
events = []
q = MagicMock()
q.put_nowait = lambda item: events.append(item)
stream_id = "sid-runs-terminal-provider-error"
with STREAMS_LOCK:
STREAMS[stream_id] = q
call_count = [0]
def fake_urlopen(req, *, timeout=None):
call_count[0] += 1
resp = MagicMock()
resp.__enter__ = lambda s: s
resp.__exit__ = lambda s, *a: None
if call_count[0] == 1:
resp.read = lambda sz=65536: b'{"run_id":"run_error"}'
else:
resp.__iter__ = lambda s: iter([
b'event: run.failed\n',
f'data: {{"error":{json.dumps(error_text)}}}\n'.encode(),
])
return resp
try:
s = new_session()
s.active_stream_id = stream_id
s.pending_user_message = "hi"
s.pending_attachments = []
s.save()
with patch.dict("os.environ", {
"HERMES_WEBUI_CHAT_BACKEND": "gateway",
"HERMES_WEBUI_GATEWAY_USE_RUNS_API": "1",
"HERMES_WEBUI_GATEWAY_BASE_URL": "http://gateway.local",
}, clear=True), \
patch("api.gateway_chat.gateway_supports_approval", return_value=True), \
patch("urllib.request.urlopen", side_effect=fake_urlopen):
_run_gateway_chat_streaming(
session_id=s.session_id,
msg_text="hi",
model="test",
workspace="/tmp",
stream_id=stream_id,
)
apperrors = [item[1] for item in events if item[0] == "apperror"]
assert apperrors[-1]["type"] in {"model_not_found", "auth_mismatch"}
assert apperrors[-1]["session_id"] == s.session_id
saved = models.get_session(s.session_id)
assert [message.get("role") for message in saved.messages[-2:]] == ["user", "assistant"]
assert saved.messages[-1]["_error"] is True
assert apperrors[-1]["session"]["messages"][-1]["_error"] is True
finally:
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
def test_gateway_worker_skips_runs_api_when_opt_in_absent():
"""Worker uses chat/completions even when gateway advertises approval support, unless opt-in is set."""
from unittest.mock import patch, MagicMock
from api.config import STREAMS, STREAMS_LOCK
from api.gateway_chat import _run_gateway_chat_streaming
events = []
q = MagicMock()
q.put_nowait = lambda item: events.append(item)
stream_id = "sid-optin-gate"
with STREAMS_LOCK:
STREAMS[stream_id] = q
sse_body = b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'
def fake_urlopen(req, *, timeout=None):
assert "/v1/chat/completions" in req.full_url
resp = MagicMock()
resp.__iter__ = lambda s: iter(sse_body.split(b"\n"))
resp.__enter__ = lambda s: s
resp.__exit__ = lambda s, *a: None
return resp
import os
env_override = {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}
env_without_opt_in = {
k: v for k, v in os.environ.items()
if k != "HERMES_WEBUI_GATEWAY_USE_RUNS_API"
}
env_without_opt_in.update(env_override)
try:
with patch.dict("os.environ", env_without_opt_in, clear=True):
with patch("api.gateway_chat.gateway_supports_approval", return_value=True), \
patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.gateway_chat.get_session", return_value=MagicMock(
active_stream_id=stream_id, workspace="/tmp",
profile=None, context_messages=[], messages=[],
)):
_run_gateway_chat_streaming(
session_id="sess-optin",
msg_text="hi",
model="test",
workspace="/tmp",
stream_id=stream_id,
)
event_types = [e[0] for e in events if isinstance(e, tuple) and len(e) >= 2]
assert "token" in event_types, "expected a token event from chat/completions path"
assert "apperror" not in event_types, "runs API path fired unexpectedly"
finally:
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)