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
- Unit tests: classifier covers every row of the pattern table above + benign messages return null
- Integration: stream replay ending in
Error: T: Connection stalled + ready event → lastModelError set, banner renders, dot pulses, Dismiss clears
- Happy path: normal assistant messages unaffected
Problem
When Cursor's upstream model fails mid-turn,
cursor-agentdoes 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 areadyevent. 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)
agent/messageError: T: [resource_exhausted] ErrorError: T: [resource_exhausted] Error(x2)Error: T: [canceled] http/2 stream closed with error code CANCEL (0x8)Error: T: Connection stalledGemini 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 "receiveagent/message" and "persist + emitready". 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/messageevent before it is persisted. Recognises the following patterns:^Error: T: \[resource_exhausted\]quota_exhausted^Error: T: \[canceled\]canceled^Error: T: \[deadline_exceeded\]deadline_exceeded^Error: T: \[unavailable\]unavailable^Error: T: Connection stalledconnection_stalledGemini prompt failed: .*token count exceedscontext_windowGemini prompt failed: .*exhausted your capacitycapacity_exhaustedReturns
nullfor anything not matching (benign messages pass through unchanged).2. Session metadata update
When a match is found, set
session.metadata.lastModelError:3. Synthetic
modelErrorsession eventEmit a
modelErrorevent distinct frommessageso the web client and notification pipeline can react without parsing message text.4. In-session banner
A persistent banner inside the session view when
lastModelErroris set and not acknowledged:Copy is harsher when
priorAssistantClaimsDone === true. Banner clears on Dismiss (setsacknowledgedAt).5. Pulsing amber dot in session list row
When
lastModelErroris unacknowledged, the session's ready indicator switches from green to pulsing amber (CSSping/pulseanimation). 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
cursorRemoteLauncher.ts)Files likely touched
cli/src/cursor/cursorAgentMessageClassifier.ts(new)cli/src/cursor/cursorRemoteLauncher.tsor ACP event handlermodelErrorevent; mark turn degradedshared/src/protocol.tslastModelErrortoSessionMetadata; addmodelErrorevent typehub/src/sync/messageService.tslastModelError; broadcast SSEweb/src/components/SessionView/ModelErrorBanner.tsx(new)web/src/components/SessionView/SessionRow.tsxlastModelErrorunacknowledgedTest plan
Error: T: Connection stalled+readyevent →lastModelErrorset, banner renders, dot pulses, Dismiss clears