Merge latest master into compression recovery action

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Frank SongandPaperclip committed 2026-07-05 09:22:00 +08:00
commit 440f1e6eb8
7 files changed
+232 -8

No files matched your search

+2
View File
@@ -14,6 +14,8 @@
### Fixed
- **A completed answer is no longer followed by a misleading "No response from provider" card.** On the streaming settlement path, a stale terminal/partial-result flag could force the generic `no_response` error even when the current turn had already produced a full assistant answer — so you'd see the reply and then an error card under it. The generic silent-error path is now suppressed only when the merged current turn actually has a completed assistant answer; genuine failures (real silent failures, partial failures, auth/quota errors, cancellation, replayed rows, terminal tool/compression cases) all keep their existing error behavior. Thanks @rodboev. (#5592, #5575)
- **The Mermaid diagram toolbar's "fit" and "fullscreen" buttons are now distinct icons.** The two buttons rendered a byte-identical glyph (the fullscreen icon was the fit icon's corner brackets drawn twice), so you couldn't tell them apart. Fullscreen now shows a distinct corner-frame-with-diagonal-arrows icon, visually separable from fit-to-screen's plain corner brackets. Thanks @rodboev. (#5565, #5525)
- **Running out of provider credentials now shows an actionable message instead of a bare "Error."** When a provider's credential pool was exhausted ("All 0 credential(s) exhausted for <provider>"), the turn failed with a generic error and no hint, because that message didn't match the quota-error pattern and fell through to the catch-all. It's now classified distinctly with a hint that tells you what happened and what to do, without swallowing genuine quota errors or blocking partial-result harvesting. (This is the classification half of the credential-exhaustion work; recovering partial output on that path is a separate follow-up.) (#5559, #3929)
- **A long-open tab no longer breaks with a runaway login-redirect URL.** If an auth session expired while a tab sat idle on the login page, the login redirect could feed itself its own address — each auth bounce wrapped and re-encoded the previous `/session/login?next=…` one level deeper, so the URL grew exponentially (a reporter measured ~12,000 characters) until it exceeded the browser's limit and the tab stopped working. All three redirect guards (client 401 handler, login page, and server login render/OIDC) now collapse a `next` whose destination resolves to the login route — detected through bounded percent-decoding so a deeply-encoded chain is still caught — while preserving legitimate redirects (including non-login paths that carry their own `next=` query key) and the existing open-redirect protections. (#5578)
- **Concurrent chats on different profiles no longer intermittently fail turn-init citing the wrong profile's provider.** A background worker (e.g. automatic title generation) mirrored its profile's home into the process-global `HERMES_HOME` env var and ran the worker body outside the setup lock, so a concurrent worker on another profile could clobber that env var mid-run — making the agent's config reader resolve the wrong profile's `config.yaml` and fail the turn citing a provider the session never used. The worker now pins its profile via the agent's context-local home override (a task-local `ContextVar` the config reader consults before the env var), so a concurrent env-var clobber can't affect it — no worker serialization, no env mutation. Degrades cleanly on older agents (the override is resolved optionally; without it, behavior is exactly as before). Thanks @b3nw. (#5567)
+19 -1
View File
@@ -8458,13 +8458,31 @@ def _run_agent_streaming(
source=getattr(s, 'pending_user_source', None) or 'webui',
drop_replayed_assistant=_drop_replayed_assistant,
)
_is_agent_result_terminal = _agent_result_terminal_failure(result)
_terminal_failure = (
_agent_result_terminal_failure(result)
_is_agent_result_terminal
or (
_saved_transcript_lacks_final_answer
and _classification['type'] not in {'cancelled', 'interrupted'}
)
)
_result_status = str(result.get('status') or result.get('state') or '').strip().lower()
_soft_partial_terminal_failure = (
_is_agent_result_terminal
and (_result_status == 'partial' or bool(result.get('partial')))
and _result_status not in {'failed', 'error', 'compression_exhausted'}
and not result.get('failed')
and not result.get('compression_exhausted')
and not _tool_limit_reached
and not _last_err
)
if (
_terminal_failure
and _soft_partial_terminal_failure
and _classification['type'] == 'no_response'
and not _saved_transcript_lacks_final_answer
):
_terminal_failure = False
if _terminal_failure:
_assistant_added = False
elif _tool_limit_reached and not _session_lacks_final_assistant_answer(s.messages):
+1 -1
View File
@@ -1507,7 +1507,7 @@ function _mermaidViewerIcon(kind) {
zoomOut: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10" cy="10" r="6"></circle><path d="M7 10h6"></path><path d="M15 15l4 4"></path></svg>',
reset: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9V4H1"></path><path d="M1 4l4 4"></path><path d="M10 4a8 8 0 1 1-5.66 13.66"></path></svg>',
fit: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"></path></svg>',
fullscreen: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"></path></svg>',
fullscreen: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"></path><path d="M4 4l5 5M20 4l-5 5M4 20l5-5M20 20l-5-5"></path></svg>',
};
return icons[kind] || '';
}
@@ -176,13 +176,14 @@ def test_compression_exhausted_result_is_terminal_failure_even_after_streamed_te
def test_terminal_failure_gates_shape_check_to_no_streamed_text():
src = _read("api/streaming.py")
start = src.find("_terminal_failure = (")
assert start != -1, "terminal failure assignment not found"
start = src.find("_is_agent_result_terminal = _agent_result_terminal_failure(result)")
assert start != -1, "terminal failure result assignment not found"
end = src.find("if _terminal_failure:", start)
assert end != -1, "terminal failure guard not found"
block = src[start:end]
assert "_agent_result_terminal_failure(result)" in block
assert "_is_agent_result_terminal = _agent_result_terminal_failure(result)" in block
assert "_is_agent_result_terminal" in block
assert "_saved_transcript_lacks_final_answer" in block
assert "_classification['type'] not in {'cancelled', 'interrupted'}" in block
assert "not _token_sent" not in block
+4 -3
View File
@@ -147,13 +147,14 @@ class TestCancelledTurnPersistenceGuards:
def test_streamed_progress_without_final_assistant_still_reports_error(self):
src = _read("api/streaming.py")
failure_idx = src.find("_terminal_failure = (")
assert failure_idx != -1, "terminal-failure guard not found"
failure_idx = src.find("_is_agent_result_terminal = _agent_result_terminal_failure(result)")
assert failure_idx != -1, "terminal-failure result guard not found"
apperror_idx = src.find("put('apperror', _error_payload)", failure_idx)
assert apperror_idx != -1, "terminal-failure guard must emit apperror"
block = src[failure_idx:apperror_idx]
assert "_agent_result_terminal_failure(result)" in block
assert "_is_agent_result_terminal = _agent_result_terminal_failure(result)" in block
assert "_is_agent_result_terminal" in block
assert "if _terminal_failure or (not _assistant_added and not _token_sent):" in block, (
"Explicit terminal failures, including compression/tool-tail failures, must report "
"an error even when interim progress already streamed."
@@ -455,6 +455,158 @@ def test_non_auth_silent_failure_still_uses_no_response(tmp_path, monkeypatch):
assert saved.messages[-1]["_error"] is True
def test_completed_assistant_answer_with_stale_partial_flag_settles_done(tmp_path, monkeypatch):
session = _prepare_session(
"completed_answer_stale_partial",
"stream_completed_answer_stale_partial",
pending_user_message="Please finish cleanly",
)
class CompletedAnswerStalePartialAgent(MockAgent):
def run_conversation(self, **kwargs):
history = list(kwargs.get("conversation_history") or [])
return {
"status": "partial",
"partial": True,
"messages": history + [{"role": "assistant", "content": "Completed answer"}],
"error": "",
}
fake_queue = _run_stream(
monkeypatch,
session,
"stream_completed_answer_stale_partial",
CompletedAnswerStalePartialAgent,
workspace=str(tmp_path),
)
saved = Session.load("completed_answer_stale_partial")
assert saved is not None
events = _queue_events(fake_queue)
assert any(event == "done" for event, _ in events)
assert not any(event == "apperror" for event, _ in events)
assert saved.messages[-1]["role"] == "assistant"
assert saved.messages[-1]["content"] == "Completed answer"
assert not any(msg.get("_error") for msg in saved.messages)
def test_stale_partial_with_unfinished_tool_call_still_reports_no_response(tmp_path, monkeypatch):
session = _prepare_session(
"unfinished_tool_call_stale_partial",
"stream_unfinished_tool_call_stale_partial",
pending_user_message="Use a tool first",
)
class UnfinishedToolCallStalePartialAgent(MockAgent):
def run_conversation(self, **kwargs):
history = list(kwargs.get("conversation_history") or [])
return {
"status": "partial",
"partial": True,
"messages": history + [
{"role": "user", "content": "Use a tool first"},
{
"role": "assistant",
"content": "Checking the tool result",
"tool_calls": [{"id": "call_1", "type": "function"}],
},
],
"error": "",
}
fake_queue = _run_stream(
monkeypatch,
session,
"stream_unfinished_tool_call_stale_partial",
UnfinishedToolCallStalePartialAgent,
workspace=str(tmp_path),
)
saved = Session.load("unfinished_tool_call_stale_partial")
assert saved is not None
events = _queue_events(fake_queue)
apperrors = [data for event, data in events if event == "apperror"]
assert apperrors, "expected apperror for unfinished tool-call partial"
assert apperrors[-1]["type"] == "no_response"
assert not any(event == "done" for event, _ in events)
assert saved.messages[-1]["_error"] is True
def test_stale_partial_repeated_prompt_replay_still_reports_no_response(tmp_path, monkeypatch):
session = _prepare_session(
"repeated_prompt_replay_stale_partial",
"stream_repeated_prompt_replay_stale_partial",
pending_user_message="Please repeat this",
)
_seed_prior_turn(
session,
prior_user="Please repeat this",
prior_assistant="Old answer",
)
class RepeatedPromptReplayStalePartialAgent(MockAgent):
def run_conversation(self, **kwargs):
if self.stream_delta_callback is not None:
self.stream_delta_callback("Partial text before stale replay")
return {
"status": "partial",
"partial": True,
"messages": list(kwargs.get("conversation_history") or []),
"error": "",
}
fake_queue = _run_stream(
monkeypatch,
session,
"stream_repeated_prompt_replay_stale_partial",
RepeatedPromptReplayStalePartialAgent,
workspace=str(tmp_path),
)
saved = Session.load("repeated_prompt_replay_stale_partial")
assert saved is not None
events = _queue_events(fake_queue)
apperrors = [data for event, data in events if event == "apperror"]
assert apperrors, "expected apperror for repeated-prompt stale replay"
assert apperrors[-1]["type"] == "no_response"
assert not any(event == "done" for event, _ in events)
assert saved.messages[-1]["_error"] is True
def test_hard_failure_with_completed_answer_still_reports_no_response(tmp_path, monkeypatch):
session = _prepare_session(
"hard_failure_completed_answer",
"stream_hard_failure_completed_answer",
pending_user_message="Please finish despite failure",
)
class HardFailureCompletedAnswerAgent(MockAgent):
def run_conversation(self, **kwargs):
history = list(kwargs.get("conversation_history") or [])
return {
"status": "failed",
"messages": history + [{"role": "assistant", "content": "Completed answer"}],
"error": "",
}
fake_queue = _run_stream(
monkeypatch,
session,
"stream_hard_failure_completed_answer",
HardFailureCompletedAnswerAgent,
workspace=str(tmp_path),
)
saved = Session.load("hard_failure_completed_answer")
assert saved is not None
events = _queue_events(fake_queue)
apperrors = [data for event, data in events if event == "apperror"]
assert apperrors, "expected apperror for hard failed result"
assert apperrors[-1]["type"] == "no_response"
assert not any(event == "done" for event, _ in events)
assert saved.messages[-1]["_error"] is True
def test_non_auth_partial_delivery_persists_error_turn(tmp_path, monkeypatch):
session = _prepare_session("partial_escape", "stream_partial_escape", pending_user_message="Please handle partial silence")
@@ -0,0 +1,50 @@
"""Regression coverage for #5525 — Mermaid toolbar fit/fullscreen icons must differ.
Reported alongside the (already-fixed on master) mobile height:0 issue: the
"fit to screen" and "fullscreen" toolbar icons look identical. Confirmed by
inspection — the old `fullscreen` glyph was `fit`'s four corner-brackets path
drawn twice (`...M9 4H4v5M15 4h5v5...`), so it rendered pixel-identical to `fit`.
Fix: give `fullscreen` a distinct glyph (corner frame + outward diagonal expand
arrows) so the two controls are visually distinguishable.
"""
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parent.parent
UI = ROOT / "static" / "ui.js"
def _icon(kind: str) -> str:
"""Extract the SVG string for a given _mermaidViewerIcon kind."""
src = UI.read_text(encoding="utf-8")
# Match `kind: '<svg ...></svg>',` inside the icons map.
m = re.search(rf"\b{kind}:\s*'(<svg.*?</svg>)'", src)
assert m, f"icon {kind!r} not found in _mermaidViewerIcon"
return m.group(1)
def test_fit_and_fullscreen_icons_are_distinct():
"""The two toolbar glyphs must not be byte-identical (they were: #5525)."""
fit = _icon("fit")
fullscreen = _icon("fullscreen")
assert fit and fullscreen
assert fit != fullscreen, (
"fit and fullscreen mermaid toolbar icons are identical — users can't "
"tell the controls apart (#5525)"
)
def test_fullscreen_icon_has_distinct_expand_arrows():
"""Fullscreen keeps a corner frame but adds outward diagonal expand arrows,
which `fit` does not have — a concrete distinguishing feature."""
fit = _icon("fit")
fullscreen = _icon("fullscreen")
# Diagonal expand strokes from the corners (e.g. "M4 4l5 5") — present in
# fullscreen, absent from fit's pure corner-bracket path.
assert re.search(r"M4 4l5 5|l5 5|l-5 5|l5 -5", fullscreen), (
"fullscreen icon should carry outward diagonal expand arrows"
)
assert not re.search(r"l5 5|l-5 5", fit), (
"fit icon should stay corner-brackets only (no diagonal arrows)"
)