Skip to content

feat: detect inline model errors (T: [resource_exhausted] / Connection stalled) and surface with pulsing session-row indicator #878

Description

@heavygee

Problem

When Cursor's upstream model fails mid-turn, cursor-agent does not exit. Instead it emits the error as a normal assistant message (Error: T: [canceled], Error: T: Connection stalled, Error: T: [resource_exhausted], etc.) and immediately sends a ready event. HAPI treats this as a clean turn completion.

If the agent's prior message claimed completion ("All done", "Committed", "Done."), the operator sees that text, sees the green ready dot, and walks away. The actual tool call never completed.

This is distinct from the quota-stderr exit-1 case (where the agent process dies with a non-zero exit code). Here the process stays alive; the error surfaces only as text in the message stream.

Observed patterns (fleet evidence, 2026-06-08 to -10)

Session name Error text in agent/message Provider Mid-flight tool
android watch Error: T: [resource_exhausted] Error Bedrock Claude idle
valutwarden Error: T: [resource_exhausted] Error (x2) Bedrock Claude idle
enophone & DJ Error: T: [canceled] http/2 stream closed with error code CANCEL (0x8) Bedrock Claude mid-reasoning
coolify Error: T: Connection stalled Vertex Claude mid-Edit File after agent said "All done"

Gemini flavor (separate runner path, same UX failure):

  • Gemini prompt failed: Failed to generate content: The input token count exceeds the maximum number of tokens allowed 1048576.
  • Gemini prompt failed: You have exhausted your capacity on this model.

Root cause

cursor-agent (and the Gemini runner) surface gRPC/upstream errors as assistant text events rather than process exits. HAPI's current event loop has no intercept between "receive agent/message" and "persist + emit ready". There is no classification, no metadata update, and no distinct UI state for a failed turn vs a clean one.


Proposed fix

1. Inline message classifier

Add a classifier that runs on every incoming agent/message event before it is persisted. Recognises the following patterns:

Pattern Kind Transient
^Error: T: \[resource_exhausted\] quota_exhausted no
^Error: T: \[canceled\] canceled yes
^Error: T: \[deadline_exceeded\] deadline_exceeded yes
^Error: T: \[unavailable\] unavailable yes
^Error: T: Connection stalled connection_stalled yes
Gemini prompt failed: .*token count exceeds context_window no
Gemini prompt failed: .*exhausted your capacity capacity_exhausted no

Returns null for anything not matching (benign messages pass through unchanged).

2. Session metadata update

When a match is found, set session.metadata.lastModelError:

{
  kind: string,           // one of the kinds above
  transient: boolean,
  rawSnippet: string,     // first 400 chars of the error text
  atTs: number,
  priorAssistantClaimsDone: boolean,  // was the prior assistant message "All done" / "Committed" / "Done." etc?
  acknowledgedAt?: number,
}

3. Synthetic modelError session event

Emit a modelError event distinct from message so the web client and notification pipeline can react without parsing message text.

4. In-session banner

A persistent banner inside the session view when lastModelError is set and not acknowledged:

⚠ MODEL ERROR — connection_stalled (transient)
The agent said "All done" but its last tool call (Edit File) did not complete.
The work is likely INCOMPLETE. Verify before trusting this session's output.

[Retry last message]   [Dismiss]   [View raw error]

Copy is harsher when priorAssistantClaimsDone === true. Banner clears on Dismiss (sets acknowledgedAt).

5. Pulsing amber dot in session list row

When lastModelError is unacknowledged, the session's ready indicator switches from green to pulsing amber (CSS ping / pulse animation). This gives a fleet-level visual signal without requiring the operator to be inside the session. Stops pulsing on Dismiss.


What this does NOT include

  • The stderr/exit-1 quota crash path (separate issue; different code path in cursorRemoteLauncher.ts)
  • Proactive quota gauges (separate feature)
  • Model picker label changes (separate issue)
  • Auto-retry on failure (can stack on top; not required for v1)

Files likely touched

File Change
cli/src/cursor/cursorAgentMessageClassifier.ts (new) Classifier function + unit tests
cli/src/cursor/cursorRemoteLauncher.ts or ACP event handler Call classifier before persisting; emit modelError event; mark turn degraded
shared/src/protocol.ts Add lastModelError to SessionMetadata; add modelError event type
hub/src/sync/messageService.ts Recognise + persist lastModelError; broadcast SSE
web/src/components/SessionView/ModelErrorBanner.tsx (new) In-session banner
web/src/components/SessionView/SessionRow.tsx Pulsing amber dot when lastModelError unacknowledged

Test plan

  1. Unit tests: classifier covers every row of the pattern table above + benign messages return null
  2. Integration: stream replay ending in Error: T: Connection stalled + ready event → lastModelError set, banner renders, dot pulses, Dismiss clears
  3. Happy path: normal assistant messages unaffected

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions