Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
440f1e6eb8
7 files changed
+232
-8
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
Reference in new issue
Block a user