fix(stream): persist a turn that errored without an answer as failed

WorkflowEngine reports node failures by *yielding* `{"type": "error"}`
rather than raising, so complete_stream's generator returns normally and
the except handler never runs. The turn was finalized `status="complete"`
with an empty response.

Live, the client renders an error bubble with a Retry button. On reload
it does not: mapServerQueryToClient only surfaces `metadata.error` for
`failed` rows, so history showed a blank message with no error and no way
to retry. A user hitting this re-sends the same prompt into new
conversations, which is exactly what the 2026-08-01 report shows — nine
blank first messages in seven hours.

Tracks a `stream_error` flag alongside the existing `paused` machinery
and finalizes `failed` when the turn produced no answer, recording the
user-facing message in `metadata.error`. An error arriving *after* output
keeps `complete` so partial text is not discarded; structured answers
count as output too, since they live in `structured_chunks` rather than
`response_full`. The flag is recorded before the pause branches so those
paths cannot lose it.

save_conversation grew a `status` parameter (default `complete`) for the
non-WAL branch, which took no status and so landed on the column default
— the same blank-complete row on a path the WAL fix did not cover.
Title generation now also runs for failed turns: _maybe_generate_title
only regenerates while the name is still the question-prefix fallback, so
skipping it would strand a conversation whose first turn failed with the
raw prompt as its name forever.

logging.py counts a yielded error toward `activity_finished.status`.
These failures previously logged `status=ok` with `answer_length=0`,
which is why user-visible blank answers never appeared in error metrics.
This commit is contained in:
Alex committed 2026-08-05 11:25:11 +01:00
1 parent f5129a5656
commit bda11242e1
6 files changed
+261 -5

No files matched your search

+29
View File
@@ -257,6 +257,35 @@ class TestLogActivity:
assert finished.status == "error"
assert finished.error_class == "ValueError"
def test_log_activity_records_error_status_on_yielded_error(self, caplog):
# Workflow node failures are reported as a yielded ``type: error``
# event rather than a raised exception, so the decorator's ``except``
# never runs. Those turns used to log ``status="ok"`` with
# ``answer_length=0``, which kept real user-visible failures out of
# every error dashboard.
import logging as _logging
from application.logging import log_activity
class FakeAgent:
endpoint = "stream"
user = "user1"
user_api_key = ""
query = "hello"
@log_activity()
def erroring(agent, log_context=None):
yield {"type": "error", "error": "No LLM class found for type foundry"}
with patch("application.logging._log_activity_to_db"), \
caplog.at_level(_logging.INFO, logger="root"):
list(erroring(FakeAgent()))
finished = next(r for r in caplog.records if r.message == "activity_finished")
assert finished.status == "error"
assert finished.error_class == "StreamError"
assert finished.answer_length == 0
def test_log_activity_emits_response_summary_aggregates(self, caplog):
# Replaces the ``agent_response`` event that ``run_agent_logic``
# used to emit only on the Celery webhook path: every Flask