Merge remote-tracking branch 'origin/master' into paperclip/pr-6222

This commit is contained in:
Frank Song committed 2026-07-20 22:44:22 +08:00
commit 39ef186a20
5 files changed
+496 -10

No files matched your search

+2
View File
@@ -17,6 +17,8 @@
### Fixed
- **Gateway-backed sessions can once again send with a MoA (Mixture-of-Agents) default model.** A legitimate gateway-default MoA send was being rejected as if it were a disallowed per-request override. The chat-start handler now distinguishes the two: a send is allowed only when the *configured* provider is MoA, the resolved model matches the configured default, and no explicit picker override was made — all evaluated against one request-owned config snapshot captured atomically, so a concurrent profile/config change can't flip the decision mid-request. Explicit `moa_config` overrides on gateway sessions still return 409. Thanks @rodboev. (#5869, #5853)
- **Content-search and sidebar polling are faster: run-journal summaries are now cached.** Repeated reads of an unchanged run journal reuse a cached summary instead of re-parsing the whole file, keyed on a complete filesystem signature (device, inode, size, mtime, and ctime) so the cache is always dropped the instant the journal is appended to, replaced, truncated, or recreated — including a same-size rewrite that preserves mtime. Bounded LRU; a first-write race can never pin a stale empty result. Thanks @sjungwon03. (#6291)
- **Live Stream: a settled Worklog now shows the side effects a turn performed (memory/skill saves).** The `state_saved` SSE event (memory + skill saves) was classified as an Assistant-Turn-Anchor side effect but the renderer-neutral scene projection discarded it, so the browser only flashed a toast and a reloaded Worklog lost that record. Anchor `artifacts`/`side_effects` are now projected into the settled `activity_scene_v1`. Purely additive — turns with no side effects render byte-identical (side-effect-only turns still don't force a Worklog), and the change is compatible with the stable-run-identity plumbing. Thanks @franksong2702. (#6204, #3400)
+19
View File
@@ -484,6 +484,25 @@ def get_config() -> dict:
return _cfg_cache
def get_config_snapshot() -> dict:
"""Return a request-owned config snapshot captured under the cache lock."""
with _cfg_lock:
config_path = _get_config_path()
try:
current_mtime = config_path.stat().st_mtime
except OSError:
current_mtime = 0.0
path_changed = _cfg_path != config_path
mtime_stale = current_mtime != _cfg_mtime
if not _cfg_cache or path_changed or (mtime_stale and not _cfg_has_in_memory_overrides()):
_refresh_config_cache(config_path)
try:
active_cfg = cfg if cfg is not _cfg_cache else _cfg_cache
except NameError:
active_cfg = _cfg_cache
return copy.deepcopy(active_cfg)
def get_webui_session_save_mode(config_data: dict | None = None) -> str:
"""Return the validated first-turn session persistence mode.
+26 -7
View File
@@ -2823,7 +2823,9 @@ from api.config import (
set_reasoning_display,
set_reasoning_effort,
create_stream_channel,
get_config,
get_webui_session_save_mode,
get_config_snapshot,
STREAM_GOAL_RELATED,
PENDING_GOAL_CONTINUATION,
_get_config_path,
@@ -21260,6 +21262,7 @@ def _start_run(
route: str,
diag=None,
moa_config=None,
gateway_chat_enabled: bool | None = None,
):
"""Shared start-run helper for /api/chat/start and start_session_turn.
@@ -21300,6 +21303,7 @@ def _start_run(
diag=diag,
source=request.source or source,
moa_config=moa_config,
external_runtime_owned=gateway_chat_enabled,
)
def _legacy_adapter_factory():
@@ -21340,7 +21344,7 @@ def _start_run(
diag=diag,
source=source,
moa_config=moa_config,
external_runtime_owned=webui_gateway_chat_enabled(get_config()),
external_runtime_owned=gateway_chat_enabled,
)
@@ -22066,7 +22070,8 @@ def _handle_chat_start(handler, body, diag=None):
_pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(s, requested_provider)
explicit_model_pick = bool(body.get("explicit_model_pick"))
moa_config = None
gateway_chat_enabled = webui_gateway_chat_enabled(get_config())
config_snapshot = get_config_snapshot()
gateway_chat_enabled = webui_gateway_chat_enabled(config_snapshot)
if body.get("moa_config"):
if gateway_chat_enabled:
return bad(handler, "MoA override is unavailable on gateway-backed sessions", 409)
@@ -22113,9 +22118,25 @@ def _handle_chat_start(handler, body, diag=None):
explicit_model_pick=explicit_model_pick,
profile_provider=catalog_profile_provider,
)
if model_provider == "moa" and moa_config is None:
if webui_gateway_chat_enabled(get_config()):
if model_provider == "moa" and gateway_chat_enabled:
from api.config import get_effective_default_model
model_config = config_snapshot.get("model") if isinstance(config_snapshot, dict) else None
configured_default, configured_default_provider, configured_default_is_moa = (
_moa_fast_path_model_state(get_effective_default_model(config_snapshot))
)
configured_provider = _clean_session_model_provider(
model_config.get("provider") if isinstance(model_config, dict) else None
)
if configured_provider is None and configured_default_is_moa:
configured_provider = configured_default_provider
if (
configured_provider != "moa"
or model != configured_default
or explicit_model_pick
):
return bad(handler, "MoA override is unavailable on gateway-backed sessions", 409)
elif model_provider == "moa" and moa_config is None:
from api.commands import resolve_moa_config
try:
@@ -22136,6 +22157,7 @@ def _handle_chat_start(handler, body, diag=None):
"source": "webui",
"route": "/api/chat/start",
"diag": diag,
"gateway_chat_enabled": gateway_chat_enabled,
}
if not gateway_chat_enabled and moa_config is not None:
start_run_kwargs["moa_config"] = moa_config
@@ -25744,9 +25766,6 @@ def _handle_session_import(handler, body):
return j(handler, {"ok": True, "session": s.compact() | {"messages": s.messages}})
# ── MCP Server helpers ──
from api.config import get_config, _save_yaml_config_file, _get_config_path, reload_config
def _mask_secrets(obj):
"""Mask sensitive values in env vars and headers."""
if not isinstance(obj, dict):
+6 -2
View File
@@ -595,8 +595,11 @@ def test_gateway_owned_start_run_bypasses_local_runtime_barrier(monkeypatch):
captured = {}
monkeypatch.setattr("api.runtime_adapter.runtime_adapter_enabled", lambda: False)
monkeypatch.setattr("api.runtime_adapter.runtime_adapter_runner_enabled", lambda: False)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
monkeypatch.setattr(routes, "get_config", lambda: {})
monkeypatch.setattr(
routes,
"get_config",
lambda: (_ for _ in ()).throw(AssertionError("_start_run reread shared config")),
)
monkeypatch.setattr(
routes,
"ensure_agent_runtime_current",
@@ -621,6 +624,7 @@ def test_gateway_owned_start_run_bypasses_local_runtime_barrier(monkeypatch):
normalized_model=False,
source="webui",
route="/api/chat/start",
gateway_chat_enabled=True,
)
assert response["stream_id"] == "gateway-stream-1"
+443 -1
View File
@@ -12,6 +12,40 @@ import pytest
from tests.conftest import TEST_BASE, requires_agent_modules
def test_config_snapshot_waits_for_reload_lock(monkeypatch, tmp_path):
"""Snapshot capture cannot run concurrently with cache replacement."""
import threading
from api import config
config_path = tmp_path / "config.yaml"
config_path.write_text("{}", encoding="utf-8")
shared_config = {"chat_backend": "gateway"}
monkeypatch.setattr(config, "_cfg_cache", shared_config)
monkeypatch.setattr(config, "cfg", shared_config)
monkeypatch.setattr(config, "_cfg_path", config_path)
monkeypatch.setattr(config, "_cfg_mtime", config_path.stat().st_mtime)
monkeypatch.setattr(config, "_cfg_fingerprint", config._fingerprint_config(shared_config))
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
complete = threading.Event()
result = {}
def read_snapshot():
result["snapshot"] = config.get_config_snapshot()
complete.set()
with config._cfg_lock:
reader = threading.Thread(target=read_snapshot)
reader.start()
assert not complete.wait(0.1)
reader.join(timeout=1)
assert complete.is_set()
assert result["snapshot"] == shared_config
assert result["snapshot"] is not shared_config
def _install_fake_moa_config(monkeypatch, *, default_preset="moa-default", usage_text="Usage: /moa <prompt>"):
hermes_cli_pkg = sys.modules.get("hermes_cli") or ModuleType("hermes_cli")
# monkeypatch.setattr restores the REAL hermes_cli.__path__ on teardown;
@@ -174,7 +208,7 @@ def test_moa_gateway_chat_start_fails_closed(monkeypatch, tmp_path):
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
monkeypatch.setattr(routes, "_start_run", start_run)
monkeypatch.setattr(routes, "get_config", lambda: {"chat_backend": "gateway"})
monkeypatch.setattr(routes, "get_config_snapshot", lambda: {"chat_backend": "gateway"})
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
monkeypatch.setattr(commands, "resolve_moa_config", resolve_moa_config)
@@ -192,3 +226,411 @@ def test_moa_gateway_chat_start_fails_closed(monkeypatch, tmp_path):
body = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert handler.status == 409
assert body["error"] == "MoA override is unavailable on gateway-backed sessions"
def test_moa_gateway_configured_default_reaches_start_run(monkeypatch, tmp_path):
"""Gateway sessions may use only the configured MoA default."""
import api.routes as routes
class _Handler:
def __init__(self):
import io
self.status = None
self.response_headers = []
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
self.response_headers.append(("__end__", ""))
class _Session:
session_id = "sess-moa-gateway-default"
workspace = str(tmp_path)
model = "gpt-5.5"
model_provider = "moa"
profile = "default"
messages = []
context_messages = []
pending_user_message = None
captured = {}
def start_run(*_args, **kwargs):
captured.update(kwargs)
return {"ok": True}
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
monkeypatch.setattr(routes, "_start_run", start_run)
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
monkeypatch.delenv(name, raising=False)
config_snapshot = {
"chat_backend": "gateway",
"model": {"provider": "moa", "default": "@moa:moa-configured"},
}
monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
routes._handle_chat_start(
_Handler(),
{
"session_id": "sess-moa-gateway-default",
"message": "diagnose issue",
"workspace": str(tmp_path),
"model": "moa/moa-configured",
"model_provider": "moa",
},
)
assert captured["model"] == "moa-configured"
assert captured["model_provider"] == "moa"
assert "moa_config" not in captured
def test_moa_gateway_string_model_config_reaches_start_run(monkeypatch, tmp_path):
"""String-form gateway defaults may also authorize MoA sends."""
import api.routes as routes
class _Handler:
def __init__(self):
import io
self.status = None
self.response_headers = []
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
self.response_headers.append(("__end__", ""))
class _Session:
session_id = "sess-moa-gateway-string-default"
workspace = str(tmp_path)
model = "gpt-5.5"
model_provider = "moa"
profile = "default"
messages = []
context_messages = []
pending_user_message = None
captured = {}
def start_run(*_args, **kwargs):
captured.update(kwargs)
return {"ok": True}
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
monkeypatch.setattr(routes, "_start_run", start_run)
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(routes, "get_config_snapshot", lambda: {"chat_backend": "gateway", "model": "@moa:moa-configured"})
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
routes._handle_chat_start(
_Handler(),
{
"session_id": "sess-moa-gateway-string-default",
"message": "diagnose issue",
"workspace": str(tmp_path),
"model": "moa/moa-configured",
"model_provider": "moa",
},
)
assert captured["model"] == "moa-configured"
assert captured["model_provider"] == "moa"
assert captured["gateway_chat_enabled"] is True
assert "moa_config" not in captured
@pytest.mark.parametrize(
("initial_config", "mutated_config", "expected_status"),
[
(
{"chat_backend": "gateway", "model": {"provider": "openai", "default": "gpt-5.5"}},
{"chat_backend": "gateway", "model": {"provider": "moa", "default": "@moa:moa-configured"}},
409,
),
(
{"chat_backend": "gateway", "model": {"provider": "moa", "default": "@moa:moa-configured"}},
{"chat_backend": "gateway", "model": {"provider": "openai", "default": "gpt-5.5"}},
200,
),
],
)
def test_moa_gateway_authorization_uses_request_owned_config(
monkeypatch, tmp_path, initial_config, mutated_config, expected_status
):
"""Gateway MoA authorization stays on the request's config snapshot."""
import io
import api.routes as routes
class _Handler:
def __init__(self):
self.status = None
self.response_headers = []
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
self.response_headers.append(("__end__", ""))
class _Session:
session_id = "sess-moa-gateway-config-race"
workspace = str(tmp_path)
model = "gpt-5.5"
model_provider = "moa"
profile = "default"
messages = []
context_messages = []
pending_user_message = None
captured = {}
config_snapshot = initial_config
original_resolve = routes._resolve_compatible_session_model_state
def resolve_and_mutate(*args, **kwargs):
result = original_resolve(*args, **kwargs)
config_snapshot.clear()
config_snapshot.update(mutated_config)
return result
def start_run(*_args, **kwargs):
captured.update(kwargs)
return {"ok": True}
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
monkeypatch.setattr(routes, "_start_run", start_run)
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", resolve_and_mutate)
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(routes, "get_config_snapshot", lambda: routes.copy.deepcopy(config_snapshot))
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
handler = _Handler()
routes._handle_chat_start(
handler,
{
"session_id": "sess-moa-gateway-config-race",
"message": "diagnose issue",
"workspace": str(tmp_path),
"model": "moa/moa-configured",
"model_provider": "moa",
},
)
assert handler.status == expected_status
if expected_status == 200:
assert captured["gateway_chat_enabled"] is True
else:
assert not captured
def test_moa_gateway_explicit_configured_default_fails_closed(monkeypatch, tmp_path):
"""A browser picker cannot select the gateway's MoA default."""
import api.routes as routes
class _Handler:
def __init__(self):
import io
self.status = None
self.response_headers = []
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
self.response_headers.append(("__end__", ""))
class _Session:
session_id = "sess-moa-gateway-picker"
workspace = str(tmp_path)
model = "gpt-5.5"
model_provider = "moa"
profile = "default"
messages = []
context_messages = []
pending_user_message = None
def start_run(*_args, **_kwargs):
raise AssertionError("explicit MoA picker selection must fail before starting a run")
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
monkeypatch.setattr(routes, "_start_run", start_run)
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
monkeypatch.delenv(name, raising=False)
config_snapshot = {
"chat_backend": "gateway",
"model": {"provider": "moa", "default": "@moa:moa-configured"},
}
monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
handler = _Handler()
routes._handle_chat_start(
handler,
{
"session_id": "sess-moa-gateway-picker",
"message": "diagnose issue",
"workspace": str(tmp_path),
"model": "moa/moa-configured",
"model_provider": "moa",
"explicit_model_pick": True,
},
)
body = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert handler.status == 409
assert body["error"] == "MoA override is unavailable on gateway-backed sessions"
def test_moa_gateway_spoofed_provider_fails_closed(monkeypatch, tmp_path):
"""A client cannot claim MoA for a model outside the configured default."""
import api.routes as routes
class _Handler:
def __init__(self):
import io
self.status = None
self.response_headers = []
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
self.response_headers.append(("__end__", ""))
class _Session:
session_id = "sess-moa-gateway-spoofed"
workspace = str(tmp_path)
model = "gpt-5.5"
model_provider = "openai-codex"
profile = "default"
messages = []
context_messages = []
pending_user_message = None
def start_run(*_args, **_kwargs):
raise AssertionError("spoofed MoA provider must fail before starting a run")
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
monkeypatch.setattr(routes, "_start_run", start_run)
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
monkeypatch.delenv(name, raising=False)
config_snapshot = {
"chat_backend": "gateway",
"model": {"provider": "moa", "default": "@moa:moa-configured"},
}
monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
handler = _Handler()
routes._handle_chat_start(
handler,
{
"session_id": "sess-moa-gateway-spoofed",
"message": "diagnose issue",
"workspace": str(tmp_path),
"model": "moa/moa-spoofed",
"model_provider": "moa",
},
)
body = json.loads(handler.wfile.getvalue().decode("utf-8"))
assert handler.status == 409
assert body["error"] == "MoA override is unavailable on gateway-backed sessions"
def test_gateway_non_moa_model_reaches_start_run(monkeypatch, tmp_path):
"""Gateway sends keep their non-MoA provider when the default uses MoA."""
import api.routes as routes
class _Handler:
def __init__(self):
import io
self.status = None
self.response_headers = []
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
self.response_headers.append(("__end__", ""))
class _Session:
session_id = "sess-gateway-non-moa"
workspace = str(tmp_path)
model = "gpt-5.5"
model_provider = "openai-codex"
profile = "default"
messages = []
context_messages = []
pending_user_message = None
captured = {}
def start_run(*_args, **kwargs):
captured.update(kwargs)
return {"ok": True}
monkeypatch.setattr(routes, "get_session", lambda _sid: _Session())
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path))
monkeypatch.setattr(routes, "_start_run", start_run)
for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"):
monkeypatch.delenv(name, raising=False)
config_snapshot = {
"chat_backend": "gateway",
"model": {"provider": "moa", "default": "@moa:moa-configured"},
}
monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
routes._handle_chat_start(
_Handler(),
{
"session_id": "sess-gateway-non-moa",
"message": "diagnose issue",
"workspace": str(tmp_path),
"model": "gpt-5.5",
"model_provider": "openai-codex",
},
)
assert captured["model_provider"] == "openai-codex"
assert "moa_config" not in captured