From 7ee9f78a2ef22e1dba8c5a01e9c98a7a1039423d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 5 Jul 2026 00:53:42 +0000 Subject: [PATCH 1/4] fix(#5525): distinct Mermaid fullscreen toolbar icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mermaid toolbar's "fit" and "fullscreen" icons rendered byte-identical (the fullscreen path was fit's corner-brackets drawn twice). Replaced the fullscreen glyph with a distinct corner-frame + diagonal-expand-arrows icon so the two actions are visually distinguishable at 14px. Still wired to openLightbox; no dependency on the old path. (The other half of #5525 — mermaid height:0 on mobile — does not reproduce on current master; verified live at 390x844.) Self-built (nesquena-hermes); nesquena independent review APPROVED. --- static/ui.js | 2 +- tests/test_issue5525_mermaid_icon_distinct.py | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue5525_mermaid_icon_distinct.py diff --git a/static/ui.js b/static/ui.js index 61960aed..b0fb6842 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1422,7 +1422,7 @@ function _mermaidViewerIcon(kind) { zoomOut: '', reset: '', fit: '', - fullscreen: '', + fullscreen: '', }; return icons[kind] || ''; } diff --git a/tests/test_issue5525_mermaid_icon_distinct.py b/tests/test_issue5525_mermaid_icon_distinct.py new file mode 100644 index 00000000..64906c97 --- /dev/null +++ b/tests/test_issue5525_mermaid_icon_distinct.py @@ -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: '',` inside the icons map. + m = re.search(rf"\b{kind}:\s*'()'", 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)" + ) From a646a23e5b7d0771e321021d3a9bf70b6ee2fb42 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 5 Jul 2026 01:07:35 +0000 Subject: [PATCH 2/4] release #5565: distinct Mermaid fullscreen toolbar icon + CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41fe9035..04f2b6ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed +- **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 "), 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) From 73d4b2b227afbf99a3f6116b0f3b21bc408d90b5 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 5 Jul 2026 01:03:49 +0000 Subject: [PATCH 3/4] fix(#5575): preserve completed assistant replies during settle The streaming settlement path could show a misleading "No response from provider" error card AFTER a completed answer, because a stale terminal/partial result flag still forced the generic no_response error path even when the merged current turn already had a final assistant answer. Now the generic no_response terminal error is suppressed ONLY when the already-merged current turn has a completed assistant reply (scoped to soft `partial` results per the issue-thread recommendation), letting normal `done` settlement continue. Real silent failures, partial failures, auth/quota errors, cancellation, replayed rows, and terminal tool/compression cases all keep their existing error behavior. Self-built stage of contributor PR; gate-pass. Co-authored-by: rodboev --- api/streaming.py | 20 ++- .../test_auto_compression_terminal_failure.py | 7 +- tests/test_cancelled_turn_status.py | 7 +- ..._issue5121_provider_auth_terminal_error.py | 152 ++++++++++++++++++ 4 files changed, 179 insertions(+), 7 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index 697e258b..3768140e 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -8457,13 +8457,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): diff --git a/tests/test_auto_compression_terminal_failure.py b/tests/test_auto_compression_terminal_failure.py index a75d882d..196caf2a 100644 --- a/tests/test_auto_compression_terminal_failure.py +++ b/tests/test_auto_compression_terminal_failure.py @@ -170,13 +170,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 diff --git a/tests/test_cancelled_turn_status.py b/tests/test_cancelled_turn_status.py index bb7c3118..bfeebe5b 100644 --- a/tests/test_cancelled_turn_status.py +++ b/tests/test_cancelled_turn_status.py @@ -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." diff --git a/tests/test_issue5121_provider_auth_terminal_error.py b/tests/test_issue5121_provider_auth_terminal_error.py index ef54a5eb..b3332dbc 100644 --- a/tests/test_issue5121_provider_auth_terminal_error.py +++ b/tests/test_issue5121_provider_auth_terminal_error.py @@ -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") From 2694ba9e034c25319c781bb0743adbd70876b4a8 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 5 Jul 2026 01:15:43 +0000 Subject: [PATCH 4/4] release #5592: preserve completed assistant replies during settle + CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04f2b6ef..d75a87ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### 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 "), 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)