mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
fix(tui): surface backend error as visible text when final_response is empty (#21245)
When the provider rejects a request (e.g. invalid model slug like
'--provider nous --model kimi-k2.6' where the valid slug is
'moonshotai/kimi-k2.6'), run_conversation() returns
{failed: True, error: <detail>, final_response: None}. The TUI gateway
and one-shot CLI mode both dropped the error on the floor and emitted
an empty turn, so the user saw a blank response with no indication
that anything went wrong.
Mirror the interactive CLI's existing pattern (cli.py:9832): when
final_response is empty AND (failed|partial) is set AND error is
populated, surface 'Error: <detail>' as the visible text. Leaves
the None-with-no-error path and the '(empty)' sentinel path
untouched — an empty successful turn still renders empty, and
existing sentinel handlers keep owning their lane.
Reported by @counterposition in PR #20873; taking a minimal fix
rather than the broader structured-failure refactor proposed there.
This commit is contained in:
parent
8dcdc3cbc2
commit
6e46f99e7e
3 changed files with 118 additions and 1 deletions
13
cli.py
13
cli.py
|
|
@ -12526,7 +12526,18 @@ def main(
|
|||
):
|
||||
cli.session_id = cli.agent.session_id
|
||||
response = result.get("final_response", "") if isinstance(result, dict) else str(result)
|
||||
if response:
|
||||
# Surface backend errors that produced no visible output
|
||||
# (e.g. invalid model slug → provider 4xx). Mirrors the
|
||||
# interactive CLI path. Write to stderr so piped stdout
|
||||
# stays clean for automation wrappers.
|
||||
if (
|
||||
not response
|
||||
and isinstance(result, dict)
|
||||
and result.get("error")
|
||||
and (result.get("failed") or result.get("partial"))
|
||||
):
|
||||
print(f"Error: {result['error']}", file=sys.stderr)
|
||||
elif response:
|
||||
print(response)
|
||||
# Session ID goes to stderr so piped stdout is clean.
|
||||
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
|
||||
|
|
|
|||
|
|
@ -3603,6 +3603,100 @@ def test_prompt_submit_skips_auto_title_when_response_empty(monkeypatch):
|
|||
mock_title.assert_not_called()
|
||||
|
||||
|
||||
def test_prompt_submit_surfaces_backend_error_as_visible_text(monkeypatch):
|
||||
"""When the backend fails with no visible response (e.g. invalid model slug
|
||||
→ provider 4xx), the TUI must surface result['error'] as visible text
|
||||
instead of emitting a blank message.complete turn."""
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
return {
|
||||
"final_response": None,
|
||||
"messages": [],
|
||||
"api_calls": 0,
|
||||
"completed": False,
|
||||
"failed": True,
|
||||
"error": "HTTP 400: invalid model id 'kimi-k2.6'",
|
||||
}
|
||||
|
||||
server._sessions["sid"] = _session(agent=_Agent())
|
||||
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
||||
|
||||
emitted: list[tuple[str, str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_emit",
|
||||
lambda event, sid, payload=None: emitted.append((event, sid, payload or {})),
|
||||
)
|
||||
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
|
||||
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
|
||||
server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "hello"},
|
||||
}
|
||||
)
|
||||
|
||||
complete_events = [e for e in emitted if e[0] == "message.complete"]
|
||||
assert complete_events, "expected message.complete to be emitted"
|
||||
payload = complete_events[-1][2]
|
||||
assert payload.get("status") == "error"
|
||||
assert payload.get("text", "").startswith("Error:")
|
||||
assert "kimi-k2.6" in payload.get("text", "")
|
||||
|
||||
|
||||
def test_prompt_submit_preserves_empty_response_without_error(monkeypatch):
|
||||
"""An empty final_response with NO backend error must stay empty — do not
|
||||
synthesize an error string. Preserves the existing None/empty-sentinel
|
||||
semantics owned by downstream handlers."""
|
||||
|
||||
class _Agent:
|
||||
def run_conversation(
|
||||
self, prompt, conversation_history=None, stream_callback=None
|
||||
):
|
||||
return {
|
||||
"final_response": None,
|
||||
"messages": [],
|
||||
"api_calls": 1,
|
||||
"completed": True,
|
||||
}
|
||||
|
||||
server._sessions["sid"] = _session(agent=_Agent())
|
||||
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
|
||||
|
||||
emitted: list[tuple[str, str, dict]] = []
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_emit",
|
||||
lambda event, sid, payload=None: emitted.append((event, sid, payload or {})),
|
||||
)
|
||||
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
|
||||
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
|
||||
server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "prompt.submit",
|
||||
"params": {"session_id": "sid", "text": "hello"},
|
||||
}
|
||||
)
|
||||
|
||||
complete_events = [e for e in emitted if e[0] == "message.complete"]
|
||||
assert complete_events, "expected message.complete to be emitted"
|
||||
payload = complete_events[-1][2]
|
||||
# Status stays "complete" because no error flag was set
|
||||
assert payload.get("status") == "complete"
|
||||
# Text stays empty — we did NOT fabricate an "Error:" string
|
||||
text = payload.get("text", "")
|
||||
assert text in ("", None), f"expected empty text, got {text!r}"
|
||||
|
||||
|
||||
# ── session.most_recent ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3137,6 +3137,18 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|||
if result.get("interrupted")
|
||||
else "error" if result.get("error") else "complete"
|
||||
)
|
||||
# When the backend produced no visible response AND reported a
|
||||
# real error (e.g. invalid model slug → provider 4xx), surface
|
||||
# that error as the visible text instead of shipping an empty
|
||||
# turn to Ink. Mirrors classic CLI behavior at cli.py where
|
||||
# (failed|partial) + no final_response → "Error: <detail>".
|
||||
# Leaves the None-with-no-error path untouched: an empty
|
||||
# successful turn still renders as empty, and the existing
|
||||
# "(empty)" sentinel handling stays in its own lane.
|
||||
if (not raw) and result.get("error") and (
|
||||
result.get("failed") or result.get("partial")
|
||||
):
|
||||
raw = f"Error: {result.get('error')}"
|
||||
lr = result.get("last_reasoning")
|
||||
if isinstance(lr, str) and lr.strip():
|
||||
last_reasoning = lr.strip()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue