Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(chat): surface a failed-turn bubble with Retry instead of swallowing chat errors

open#2804CedarCopilot

CedarCopilot wants to merge 1 commit into staging from fix/chat-failed-message-retry

Not deployedTimeline and evidence
  1. Opened
    Sep 16, 2026, 7:27 AM
  2. Sep 16, 2026, 7:34 AM
  3. Merged, live on staging, live on prod, observed
    Pending
  4. Not deployed

    Not deployed

    Pending

Libra has no production signal for this change yet because it has not deployed. Libra checks hourly for 3 days after each deploy.

Bug

Cedar's in-app AI chat (apps/mail) could fail a turn with zero visible feedback:

  1. Swallowed stream errors. agentConnectionSlice.ts's stream handler had a case 'error': that only did console.error(...) and break , no rethrow. The existing generic error-bubble fallback in the outer catch block never got a chance to fire, because the provider's streamLLM.completion promise resolves normally after emitting an error event (it only calls the handler, then returns without throwing , see providers/mastra.ts's internal try/catch). So a stream error left the user's message sitting there with no reply and no error state.
  2. Requests that vanish before reaching the backend at all. If the POST is dropped by a proxy or the server never opens a stream, there's no HTTP error and no stream error event either , nothing ever settles the turn as failed.

Fix

  • chat-failed message type + FailedMessageRenderer , a new bubble (ShimmerText in its error state, matching the existing "Agent Interrupted" bubble's styling) with a Retry button that resends the original message (same text, same thread) through the normal sendMessage() pathway , using the same overridePrompt replay contract messageQueueSlice already uses for queued sends.
  • surfaceFailedChatMessage , a shared helper (agentConnectionSlice.ts) called from every place a turn can die silently: the stream error case, a genuine exception in sendMessage (replacing the old static "An error occurred..." text bubble that had no retry), and a resumed stream's own error case , instead of duplicating (or, for the stream-error path, omitting) the failure UI.
  • Send-ack timeout (45s) , detects "the request never reached the backend at all": if sendMessage hasn't heard any stream event within SEND_ACK_TIMEOUT_MS of sending, it aborts the stream and surfaces the same failed bubble. This is a one-shot "did anything come back at all" timer, not a per-chunk staleness check , it clears on the very first event (including an early tool-call notification), so a long multi-tool-call turn that's merely slow is unaffected; only total silence from the start counts as vanished. Skipped if the user already stopped the run by hand (no double "Interrupted" + "failed" bubble).

How I decided to detect "never reached the backend"

There's no existing beacon/ack pattern in this codebase for that case (checked: providers/mastra.ts, agentUtils.ts, no AbortSignal.timeout or health-check usage anywhere in agentConnection/). The simplest reliable mechanism given the existing StreamHandler/StreamResponse shape is a client-side "first event" timer: schedule it right after streamLLM() returns, clear it in the wrapped handler on the first event of any type, and treat a fully-silent timeout exactly like a stream error (abort + same failed bubble). 45s is a judgment call , generous enough for a cold start, but open to tuning if telemetry says otherwise.

Tests

  • agentConnectionSlice.failedMessage.test.tsx , stream error → one failed bubble (and exactly one even if the stream errors twice); an exception thrown before streaming starts → failed bubble; ack-timeout fires + aborts when nothing ever arrives; ack-timeout stays quiet (no abort, no bubble) when the user already cancelled the run.
  • FailedMessageRenderer.test.tsx , clicking Retry resends the original threadId/overridePrompt through sendMessage().

Verification

  • pnpm --filter @zero/mail run types , same pre-existing error set as staging (paraglide/react-router codegen +types/@/paraglide/* modules not generated in this checkout); no new errors in any touched file.
  • pnpm deps:check , no dependency violations (1833 modules, 9699 dependencies).
  • pnpm --filter @zero/mail exec jest modules/cedar-os , 31 suites / 443 tests pass, including the 6 new ones.
  • Ran the `thermo-review
Show production surfaces and changed-file mapping

Production surfaces

Libra has not measured any production surfaces for this change yet.

Changed files → surfaces

  • apps/mail/modules/cedar-os/__tests__/chatComponents/FailedMessageRenderer.test.tsxno production surface mapped
  • apps/mail/modules/cedar-os/__tests__/store/agentConnection/agentConnectionSlice.failedMessage.test.tsxno production surface mapped
  • apps/mail/modules/cedar-os/src/store/agentConnection/agentConnectionSlice.tsno production surface mapped
  • apps/mail/modules/cedar-os/src/store/messages/renderers/FailedMessageRenderer.tsxno production surface mapped
  • apps/mail/modules/cedar-os/src/store/messages/renderers/initializeMessageRendererRegistry.tsxno production surface mapped