feat(cursor): detect inline model errors, surface, notify, and bridge (#878) - #987
feat(cursor): detect inline model errors, surface, notify, and bridge (#878)#987heavygee wants to merge 98 commits into
Conversation
1739480 to
289515b
Compare
There was a problem hiding this comment.
Findings
- [Major] Telegram model-error notifications can be dropped by Markdown parsing.
Summary
- Review mode: initial
- Found one issue in the new Telegram model-error notification path. The message enables Markdown while embedding session/raw error content, so certain ordinary names/errors can make Telegram reject the send before the user sees the alert.
Testing
- Not run (automation; static review only).
HAPI Bot
|
Addressed the Telegram Markdown finding in 22801bc: dropped Siren emoji prefix retained for visual urgency without relying on bold formatting. |
There was a problem hiding this comment.
Findings
- [Major] Clear previous-turn assistant text before each prompt —
turnHasModelErroris reset at the start of a new turn, butlastAssistantTextis left from the previous turn. If turn N ends with “Done.” and turn N+1 fails before emitting any text,recordModelError()reads the stale text and setspriorAssistantClaimsDone: true, causing the banner/push copy to accuse the current turn of claiming completion when it did not. Evidencecli/src/cursor/cursorAcpRemoteLauncher.ts:238andcli/src/cursor/cursorAcpRemoteLauncher.ts:372.
Suggested fix:session.onThinkingChange(true); this.turnHasModelError = false; this.lastAssistantText = null;
Summary
- Review mode: follow-up after new commits
- Found one issue in the current diff. The prior Telegram Markdown parsing issue has been fixed in the follow-up commit.
Testing
- Not run (automation; static review only). Add a cursor ACP launcher/classification test for a successful “Done.” turn followed by a structural failure before any assistant text.
HAPI Bot
|
Fixed in 2f7ff3a: |
There was a problem hiding this comment.
Findings
- No issues found with confidence >= 80% in the latest diff.
Summary
- Review mode: follow-up after new commits
- The prior HAPI Bot finding about stale
lastAssistantTextin the Cursor ACP launcher is addressed in the current head. Residual risk: this was a static review; launcher behavior still depends on real cursor-agent ACP error sequencing.
Testing
- Not run (automation; static review only). Existing added tests cover classifier patterns and notification dedupe/copy paths; no manual Cursor ACP run verified here.
HAPI Bot
There was a problem hiding this comment.
Findings
- [Major] Bind model-error acknowledgment to the displayed error — the dismiss request currently sends only the session id, and the hub writes
acknowledgedAtonto whatevermetadata.lastModelErroris current when the request is handled. If a second model error arrives after the page rendered but before the user taps Dismiss, this endpoint can acknowledge the newer, unseen error and remove the banner/dot for the failure that should still demand attention. Evidence:web/src/api/client.ts:424sends no error identity, andhub/src/web/routes/sessions.ts:339acknowledges by session id only.
Suggested fix:// web: send the error instance the user dismissed await props.api.acknowledgeModelError(props.session.id, err.atTs) // hub/cache: only acknowledge the same error; force refresh on races if (currentMetadata.lastModelError.atTs !== acknowledgedAtTs) { throw new Error('Model error changed; refresh before acknowledging.') }
Summary
Review mode: follow-up after new commits
One major issue found in the latest full diff. The follow-up commit only changed the RetriableError [resource_exhausted] transient classification, but the full PR still has an acknowledgment race in the new model-error UI/API flow.
Testing
Not run (automation; static review only).
HAPI Bot
8565169 to
0ea9a9b
Compare
There was a problem hiding this comment.
Findings
- [Major] Bind model-error acknowledgment to the displayed error — the dismiss flow still sends only the session id, and the hub writes
acknowledgedAtonto whichevermetadata.lastModelErroris current when the request is handled. If a newer model error arrives after the page rendered but before the user taps Dismiss, this can acknowledge the newer unseen error and hide its banner/dot. Evidence:web/src/api/client.ts:433sends no error identity, andhub/src/web/routes/sessions.ts:366acknowledges by session id only.
Suggested fix:// web: send the error instance the user dismissed const atTs = props.session.metadata?.lastModelError?.atTs if (typeof atTs === 'number') { await props.api.acknowledgeModelError(props.session.id, atTs) } // hub/cache: only acknowledge that same error instance if (!currentMetadata.lastModelError || currentMetadata.lastModelError.atTs !== acknowledgedAtTs) { throw new Error('Model error changed; refresh before acknowledging.') }
Summary
- Review mode: follow-up after new commits
- One Major issue remains in the latest full diff. The prior bot acknowledgment-race finding is still present: the new endpoint/client path does not bind dismissal to
lastModelError.atTs.
Testing
- Not run (automation; static review only).
HAPI Bot
|
Addressed the acknowledge-binding Major in a3bc9a8:
Prior Majors still present on tip:
Tests: |
There was a problem hiding this comment.
Findings
- [Minor] Render
modelErrorevents before persisting them into chat UI —recordModelErrornow sends a persistedmodelErroragent event, but the web event presenter has no case for that type, so the chat thread falls through toJSON.stringify(event)and shows a raw JSON system row containingrawSnippetin addition to the dedicated banner. Evidence:cli/src/cursor/cursorAcpRemoteLauncher.ts:431; related fallbackweb/src/chat/presentation.ts:212.
Suggested fix:// web/src/chat/presentation.ts if (event.type === 'modelError') { const kind = typeof event.kind === 'string' ? event.kind : 'unknown' return { icon: null, text: `Model error: ${kind}` } }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- One Minor UI regression found in the latest full diff. The previous bot finding about acknowledging the displayed error is addressed by passing
atTsthrough the client, route, engine, and cache.
Testing
- Not run:
bunis not installed in this runner (bun: command not found).
HAPI Bot
|
Cleared the Minor in da14312: |
There was a problem hiding this comment.
Findings
- No issues found.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Full diff reviewed. The previous bot finding is addressed by the new
modelErrorpresentation case and regression test. Residual risk: local test execution was unavailable in this runner.
Testing
- Not run:
bunis not installed in this runner (bun: command not found).
HAPI Bot
0f5b978 to
294b3ea
Compare
Rebase onto upstream/main (0.23.4)Rebased
Force-pushed with lease to |
There was a problem hiding this comment.
Findings
- [Major] RPC
[canceled] Operation abortederrors are filtered out before gRPC classification —classifyAcpRpcRejectionreturnsnullfor any message containingaborted, so a JSON-RPC rejection likeError: T: [canceled] Operation abortednever reaches the existingclassifyCursorAgentMessage(raw)pass. The text path has regression coverage for that exact[canceled] Operation abortedshape, and this PR explicitly relies on RPC-side gRPC strings being classified, so the structural-first path can still miss degraded turns and emit a spurious ready state. Evidence:cli/src/cursor/cursorAgentMessageClassifier.ts:220
Suggested fix:const textMatch = classifyCursorAgentMessage(raw) if (textMatch) { return { ...textMatch, source: 'rpc' } } if (lower.includes('aborted by user') || lower.includes('user cancelled') || lower.includes('user canceled')) { return null }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Full diff reviewed. One blocking behavior remains in the new RPC classifier: a real canceled model-side gRPC error can be suppressed by the broad user-abort guard.
Testing
- Not run (automation):
bunis not installed in this runner (bun: command not found).
HAPI Bot
Follow-up: RPC
|
There was a problem hiding this comment.
Findings
- [Major] Do not send raw model errors through external notification channels —
rawSnippetis captured from provider/RPC stderr and persisted as the first 400 chars incli/src/cursor/cursorAcpRemoteLauncher.ts:411, thenformatModelErrorBodyappends it into the shared notification body athub/src/notifications/modelErrorCopy.ts:58. Both Web Push and Telegram use that body (hub/src/push/pushNotificationChannel.ts:173,hub/src/telegram/bot.ts:283), so a provider error that includes prompt text, file paths, auth details, or headers gets copied out of the local hub into third-party notification services. Keep the raw excerpt in the authenticated web banner only, or redact it before external delivery.
Suggested fix:export function formatModelErrorBody( notification: ModelErrorNotification, context: { agentName: string; sessionName: string } ): string { const lines: string[] = [] if (notification.priorAssistantClaimsDone) { lines.push('Agent claimed completion before this error - work likely INCOMPLETE.') } lines.push(`${context.agentName} - ${context.sessionName}`) // Raw provider/RPC text stays in the authenticated web banner only. if (notification.transient) { lines.push('(transient - safe to retry)') } return lines.join('\n') }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- One Major privacy/security issue found in the notification copy path. The follow-up commit around
[canceled] Operation abortedordering does not change this finding.
Testing
- Not run (automation). Suggested coverage: update
hub/src/notifications/modelErrorCopy.test.tsto assert external notification bodies omit raw snippets, while the web banner can still revealrawSnippetinside the authenticated app.
HAPI Bot
Follow-up: omit
|
Follow-up: omit
|
There was a problem hiding this comment.
Findings
- [Minor] Preserve same-message completion claims before text-classified errors — when Cursor appends the error to the same assistant text block, the new warning is lost; see inline comment.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- One Minor issue found in the current head. The prior raw-snippet notification leakage finding is not present in this revision.
Testing
- Not run (automation). Suggested coverage: add a launcher-level test where a text message starts with a completion phrase and ends with
Error: T: [resource_exhausted], assertingpriorAssistantClaimsDone: true.
HAPI Bot
Follow-up: same-message
|
There was a problem hiding this comment.
Findings
- [Major] Preserve
lastModelErroracross sparse metadata writes — this field is now durable alert state, butupdateSessionMetadata()still only carries forward the pre-existing allowlist (path,host, routing fields, resume tokens). The store comments document that sparse/stale metadata writes already happen; after this addition, any such write that omitslastModelErrorcan erase an unacknowledged model-error banner or dropacknowledgedAt, so the user may lose the alert state without dismissing it. Evidence:shared/src/schemas.ts:81, related merge contexthub/src/store/sessions.ts:121.
Suggested fix:const ALERT_STATE_FIELDS = ['lastModelError'] as const export function mergeSessionMetadata(prior: unknown, next: unknown): unknown { if (!isPlainObject(prior) || !isPlainObject(next)) { return next } let merged: Record<string, unknown> | null = null merged = carryForwardIfMissing(prior, next, merged, PARSE_IDENTITY_FIELDS) merged = carryForwardIfMissing(prior, next, merged, ROUTING_FIELDS) merged = carryForwardIfMissing(prior, next, merged, SIMPLE_RESUME_TOKENS) merged = carryForwardIfMissing(prior, next, merged, ALERT_STATE_FIELDS) merged = preserveCursorProtocolPair(prior, next, merged) return merged ?? next }
Questions
- None.
Summary
- Review mode: follow-up after new commits
- One Major issue found: the new model-error metadata can be lost by existing sparse metadata replacement paths unless the store merge preserves it. Prior same-message completion review feedback is addressed in this head.
Testing
- Not run (automation). Suggested coverage: add a store-level
mergeSessionMetadata/updateSessionMetadatatest where prior metadata haslastModelErrorwithacknowledgedAt, next metadata omits it, and the field is preserved.
HAPI Bot
Follow-up: preserve
|
There was a problem hiding this comment.
Findings
- No issues found with confidence >= 80% in the latest full diff.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Full diff reviewed. The previous HAPI Bot finding about preserving
lastModelErroracross sparse metadata writes is addressed by carryinglastModelErrorthroughmergeSessionMetadataand adding store coverage. Residual risk: static review only; real Cursor ACP error sequencing was not exercised here.
Testing
- Not run (automation; static review only, no PR code executed).
HAPI Bot
f8b9b2c to
aeeb74b
Compare
Rebase onto current
|
Rebase onto
|
aeeb74b to
c510881
Compare
Keep the per-session Cursor RPC aligned with CLI create/get: auto-bridge is owner/default-namespace only so tenants cannot opt themselves in. Co-authored-by: Cursor <cursoragent@cursor.com>
Front-queuing a stale bridge during a newer turn can replay work after that turn succeeds. Auto-bridge at settle remains allowed. Co-authored-by: Cursor <cursoragent@cursor.com>
Manual bridge is refused mid-prompt; park the post-turn wait so pending bridge coverage still observes queue rows without violating the gate. Co-authored-by: Cursor <cursoragent@cursor.com>
Persist supersededByUserTurn when a non-bridge batch starts, enforce it in CLI/hub/UI, and hide Bridge on inactive Cursor sessions. Co-authored-by: Cursor <cursoragent@cursor.com>
After CLI restart, load durable hub metadata so the first newer normal turn can still persist supersededByUserTurn and keep Bridge closed. Co-authored-by: Cursor <cursoragent@cursor.com>
Settings fanout only hits already-active rows; push the owner pref again when a Cursor CLI becomes ready so bootstrapping processes cannot keep a stale enable/disable value. Co-authored-by: Cursor <cursoragent@cursor.com>
Mark transport_closed/agent_crashed non-bridgeable, keep idle stderr alerts but bridgeable=false, and move auto-bridge Settings fanout into the hub under a lock shared with session-ready/first-active reconcile. Co-authored-by: Cursor <cursoragent@cursor.com>
If a corrective/cancel prompt is already waiting, mark the error superseded and refuse auto/manual Bridge instead of unshifting the stale retry. Co-authored-by: Cursor <cursoragent@cursor.com>
Attribute Bridge via MessageQueue2.internal (not caller localId), strip reserved bridge: ingress ids, and refuse Bridge when the last user prompt exceeds the exact-replay size limit. Co-authored-by: Cursor <cursoragent@cursor.com>
Forged bridge:* IDs stay on the message for messages-consumed/cancel; attribution still requires MessageQueue2.internal model-error-bridge. Co-authored-by: Cursor <cursoragent@cursor.com>
Serialize disk write/rollback with fanout under SyncEngine's auto-bridge lock so concurrent Settings PUTs cannot leave CLI prefs ahead of settings.json, and replace zero-delay ticks with bounded polls in reconcile tests. Co-authored-by: Cursor <cursoragent@cursor.com>
…ge fanout Re-check pending user turns when a model-error Bridge batch starts so a newer instruction cannot be overtaken after enqueue. Track CLI targets whose Settings fanout/rollback RPC failed and reconcile them on later heartbeats so a 409 rollback cannot leave auto-bridge enabled live. Co-authored-by: Cursor <cursoragent@cursor.com>
bridgeModelError() returns after the CLI accepts the retry, which used to clear isBridging immediately and re-enable a second click. Hold pending eventId until metadata records recovered, failed, superseded, or ack. Co-authored-by: Cursor <cursoragent@cursor.com>
…osed Seed persisted-active Cursor rows after reloadAll so a hub restart still pushes settings.json on the first heartbeat. Do not wrap pass-through slash commands as Bridge retries, and scope banner Bridge failures to the requested eventId. Co-authored-by: Cursor <cursoragent@cursor.com>
Reject combined hub-settings bodies so a 409 cannot leave sessionSummaryContract committed. Clear local Bridge pending when the session goes inactive so a reconnect can retry. Co-authored-by: Cursor <cursoragent@cursor.com>
Upstream tiann#1477 added the required HappyChatContext field; the scheme-link test helper omitted it and failed web typecheck after rebase. Co-authored-by: Cursor <cursoragent@cursor.com>
Owner-only PUT already 403s; stop rendering the switch for non-default namespaces so tenants do not see a dead control. Co-authored-by: Cursor <cursoragent@cursor.com>
The session-list amber indicator is the operator's only warning if a retry fails. Do not replace it with the green thinking spinner. Co-authored-by: Cursor <cursoragent@cursor.com>
Rebase onto upstream left the required HappyChatContext field twice. Co-authored-by: Cursor <cursoragent@cursor.com>
Abort cancels CLI Bridge without recovered/failed metadata and leaves the session active, so the banner stayed stuck on Bridging until remount. Co-authored-by: Cursor <cursoragent@cursor.com>
Queue ACK must not fire messages-consumed for a predictable bridge: eventId, or a later client prompt with that localId can be acked when Bridge dequeues. Co-authored-by: Cursor <cursoragent@cursor.com>
Recheck newer user intent after mode apply and immediately before session/prompt so a correction queued during pre-prompt drain cannot be overtaken. Classify unbracketed Rate limit exceeded / status 429 JSON-RPC errors as bridgeable. Co-authored-by: Cursor <cursoragent@cursor.com>
Rebase onto upstream/main kept both Cursor auto-retry and model-error field sets; collapse the duplicate promptInFlight/userAbortRequested and harness deferPrompt keys. Co-authored-by: Cursor <cursoragent@cursor.com>
The auto-retry safety branch stopped retries after a tool call without recording failure, so finally still marked a failed Bridge recovered. Co-authored-by: Cursor <cursoragent@cursor.com>
Android previously dumped unknown types into default task_notifications and rebuilt tags as type-sessionId, so model-error never heads-up and distinct events collapsed. Parse hub tag, route to a HIGH channel, and send the same type on iOS APNs with event-specific collapse ids. Co-authored-by: Cursor <cursoragent@cursor.com>
Soft-steer registration lost braces during conflict resolution; restore upstream steer handler beside Bridge RPC, clear pending Bridge on abort, and collapse duplicate harness/import leftovers so typecheck and tests pass. Co-authored-by: Cursor <cursoragent@cursor.com>
Rebase onto mark-unread left a conflict marker and double-rendered the unread tooltip. Show amber model-error as a sibling; keep upstream's exclusive unread/thinking/attention ternary. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Minor] Gate “safe to retry” on actual Bridge eligibility —
transientdescribes the provider failure, but Bridge is separately blocked for non-bridgeable, retry-failed, superseded, or already-bridged events. External push/Telegram/FCM copy can therefore recommend retrying while the banner and REST/RPC gates reject the same action. Evidencehub/src/notifications/modelErrorCopy.ts:59.
Suggested fix:const bridgeEligible = lastModelError.transient && lastModelError.bridgeable !== false && !lastModelError.retriedAndFailed && !lastModelError.supersededByUserTurn && lastModelError.bridgedForEventId !== lastModelError.eventId if (notification.bridgeEligible) { lines.push(`(transient - safe to retry)`) }
Questions
- None.
Summary
Review mode: follow-up after new commits
The previously reported Minor remains in the full latest diff. No additional findings met the 80% confidence threshold.
Testing
- Not run locally (automation review; PR code was not executed).
- Static
git diff --check: passed. - GitHub checks at review time: Android build, integration, iOS app/package tests, fixture drift gate, and test jobs passed;
pr-reviewwas in progress. - Add copy tests for transient events that are non-bridgeable, retry-failed, superseded, or already bridged; each should omit “safe to retry.”
HAPI Bot
| lines.push('Agent claimed completion before this error - work likely INCOMPLETE.') | ||
| } | ||
| lines.push(`${context.agentName} - ${context.sessionName}`) | ||
| if (notification.transient) { |
There was a problem hiding this comment.
[MINOR] Gate “safe to retry” on actual Bridge eligibility
transient only classifies the provider failure. The same metadata can still have bridgeable === false, retriedAndFailed, supersededByUserTurn, or bridgedForEventId === eventId; in those cases the web/REST/RPC path rejects Bridge, but every external channel still tells the operator it is safe to retry.
Carry a derived eligibility bit into both notification construction sites and gate this line on it:
const bridgeEligible = lastModelError.transient
&& lastModelError.bridgeable !== false
&& !lastModelError.retriedAndFailed
&& !lastModelError.supersededByUserTurn
&& lastModelError.bridgedForEventId !== lastModelError.eventId
if (notification.bridgeEligible) {
lines.push(`(transient - safe to retry)`)
}
Summary
Completes tiann/hapi#878 in one PR: detect inline cursor-agent model failures, surface them honestly, emergency-notify, and bridge & retry transient hiccups.
Relationship to #871: #871 merged web warning styling only. This PR adds the CLI structural-first detection path (
cursorAcpRemoteLauncher), text classifier fallback (includingError: T:andError: RetriableError:prefixes),lastModelErrormetadata, hub emergency notifications (Push/Telegram/FCM), blocks spuriousreadyon degraded turns, and adds Bridge & retry.Bridge & retry (leg 3, folded in)
bridgedForEventId(not wall-clockatTs).Explicitly out of scope
transport_closed). Auto-bridge may interact poorly with that residual on multi-session estates — leave auto-bridge off until bug(cursor): residual ACP exit 143 / false transport_closed after #835 (live PID, sole session) #1472 is addressed, or use manual bridge only.Not in this PR: separate FCM soup layer is no longer required for model-error wrist push (
FcmNotificationChannel.sendModelErroris included here; depends on upstream FCM channel from #803).Test plan
bun typecheckcursorModelErrorBridge.test.ts,ModelErrorBanner.test.ts)Fixes #878