* Harden interrupted recovery control filtering * Redesign live-to-final assistant replies * Fix live activity anchor test fixture * Fix CI lint issues for live reply tests * Strengthen live progress prompt contract * Recover PR #3401 refresh on origin/master * Repair live-to-final refresh regressions * Fix live worklog refresh regressions * Show live footer timer on initial stream start * Restore live stream shell after reload * Preserve per-frame live SSE replay cursors * Preserve reasoning as Worklog Thinking cards * Quiet Worklog Thinking card styling * Align Worklog Thinking card styling * Scope live Worklog Thinking cards by segment * Suppress exact duplicate settled Thinking * Close #3401 merge review test gaps * fix(#3401): resolve 4 deep-review regressions (inline-think, reconnect-dup, neon skin, busy-gate worklog) Deep review (Codex diff-vs-master + live-browser drive) of the live-to-final refactor surfaced 4 regressions vs master that the rewritten suite no longer guarded: 1. Inline <think>…</think>answer reasoning vanished — _assistantReasoningPayloadText used $-anchored regexes so a leading think block + visible answer extracted nothing and the Thinking card never rendered. Removed the 3 $ anchors to match the (non-anchored) display stripper. Live: inline-think thinking-only turn now renders. 2. (CORE) reconnect/reload duplicated the live reply — _rememberRunJournalCursor advanced a closure-local seq but never wrote INFLIGHT[activeSid].lastRunJournalSeq, so a reload replayed the journal from after_seq=0 over restored lastAssistantText. Now mirrors the cursor onto INFLIGHT + schedules a throttled persist. 3. Neon skin silently broke — PR deleted the :root[data-skin="neon"] CSS but left Neon in the picker. Restored the neon CSS block from master. 4. Settled tool-worklog rebuild gated purely on !S.busy — dropped every prior settled turn's worklog when renderMessages re-ran during an active stream (switch-back to an in-progress session). Restored master's !S.busy || (S.toolCalls && S.toolCalls.length). Live: busy re-render now preserves tool cards (4→4, was 4→0). Live-verified all 4 + confirmed #3709/#3592 invariants still hold (1 thinking card, none below footer; distinct siblings preserved). + tests/test_issue3401_deep_review_fixes.py (7). * test(#3401): realign 3 stale source-shape assertions to the deep-review fixes Fix commit changed two source literals that existing stage tests scanned for: - test_live_activity_timeline.py (x2): split anchor 'if(!S.busy){' → the restored 'if(!S.busy || (S.toolCalls&&S.toolCalls.length)){' guard (fix 4). - test_run_journal_frontend_static.py: 'after_seq=0' not in source — fix 2's comment contained that literal; rephrased the comment to 'the zero floor (after_seq of 0)'. Intent of all three assertions unchanged; only the matched string updated. No code behavior change. * docs(changelog): v0.51.294 — Release JJ (stage-3401, #3401 live-to-final redesign) --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: Nathan-Hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: nesquena-hermes <[email protected]>
675 lines
26 KiB
Python
675 lines
26 KiB
Python
from collections import OrderedDict
|
|
import base64
|
|
from email.message import Message
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import urllib.error
|
|
|
|
import api.gateway_chat as gateway_chat
|
|
import api.models as models
|
|
import api.streaming as streaming
|
|
from api.config import STREAMS, create_stream_channel
|
|
from api.models import new_session
|
|
from api.gateway_chat import (
|
|
_gateway_http_error_event,
|
|
_gateway_sse_delta,
|
|
_gateway_stream_usage,
|
|
_gateway_tool_progress_event,
|
|
gateway_chat_config_status,
|
|
webui_chat_backend_mode,
|
|
webui_gateway_chat_enabled,
|
|
)
|
|
|
|
|
|
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"}) is None
|
|
|
|
|
|
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":"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(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"])
|
|
# #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 ("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_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,")
|