diff --git a/android/app/src/main/kotlin/app/hapi/companion/fcm/NotificationChannels.kt b/android/app/src/main/kotlin/app/hapi/companion/fcm/NotificationChannels.kt
index ea971bdc10..0930296461 100644
--- a/android/app/src/main/kotlin/app/hapi/companion/fcm/NotificationChannels.kt
+++ b/android/app/src/main/kotlin/app/hapi/companion/fcm/NotificationChannels.kt
@@ -13,6 +13,9 @@ import app.hapi.data.push.PushPayload
*
* - `permission_requests` — HIGH: an agent is blocked on the operator; the
* heads-up + sound interruption is the point.
+ * - `model_errors` — HIGH: the agent hit a model-side failure (rate limit,
+ * quota, crash); same urgency as a blocked permission, event-tagged so
+ * distinct errors do not overwrite each other.
* - `ready` — DEFAULT: the agent finished and is waiting for input.
* - `task_notifications` — DEFAULT: task completed/failed; also the bucket
* for unknown types / contract versions (never heads-up for those).
@@ -34,6 +37,13 @@ object NotificationChannels {
).apply {
description = context.getString(R.string.channel_permission_requests_desc)
},
+ NotificationChannel(
+ PushPayload.CHANNEL_MODEL_ERROR,
+ context.getString(R.string.channel_model_errors),
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ description = context.getString(R.string.channel_model_errors_desc)
+ },
NotificationChannel(
PushPayload.CHANNEL_READY,
context.getString(R.string.channel_ready),
diff --git a/android/app/src/main/kotlin/app/hapi/companion/fcm/PushNotifications.kt b/android/app/src/main/kotlin/app/hapi/companion/fcm/PushNotifications.kt
index 853e1c9250..f3b6ba2833 100644
--- a/android/app/src/main/kotlin/app/hapi/companion/fcm/PushNotifications.kt
+++ b/android/app/src/main/kotlin/app/hapi/companion/fcm/PushNotifications.kt
@@ -64,7 +64,7 @@ object PushNotifications {
when (payload.type) {
PushType.PERMISSION_REQUEST -> addPermissionActions(context, builder, payload)
PushType.READY, PushType.TASK_NOTIFICATION -> addReplyActions(context, builder, payload)
- null -> Unit
+ PushType.MODEL_ERROR, null -> Unit
}
}
diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml
index 926edf7b76..5bd072d580 100644
--- a/android/app/src/main/res/values-zh-rCN/strings.xml
+++ b/android/app/src/main/res/values-zh-rCN/strings.xml
@@ -172,6 +172,8 @@
权限请求
智能体正在等待您允许或拒绝一次工具调用
+ 模型错误
+ 智能体遇到需要处理的模型侧失败
代理就绪
智能体已完成并正在等待输入
任务通知
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 8000898697..48c6fd03be 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -165,6 +165,8 @@
Permission requests
An agent is waiting for you to allow or deny a tool call
+ Model errors
+ An agent hit a model-side failure that needs attention
Agent ready
An agent finished and is waiting for input
Task notifications
diff --git a/android/core/data/src/main/kotlin/app/hapi/data/push/PushPayload.kt b/android/core/data/src/main/kotlin/app/hapi/data/push/PushPayload.kt
index 7cc54b93b5..eaaa2e506f 100644
--- a/android/core/data/src/main/kotlin/app/hapi/data/push/PushPayload.kt
+++ b/android/core/data/src/main/kotlin/app/hapi/data/push/PushPayload.kt
@@ -12,7 +12,8 @@ import kotlinx.serialization.Serializable
enum class PushType(val wire: String) {
READY("ready"),
PERMISSION_REQUEST("permission-request"),
- TASK_NOTIFICATION("task-notification");
+ TASK_NOTIFICATION("task-notification"),
+ MODEL_ERROR("model-error");
companion object {
fun fromWire(value: String): PushType? = entries.firstOrNull { it.wire == value }
@@ -75,6 +76,8 @@ data class PushPayload(
val severity: PushSeverity?,
val contractVersion: String?,
val notifySummary: PushNotifySummary?,
+ /** Hub coalescing tag (`FcmSendPayload.tag`), when present on the data map. */
+ val tag: String?,
) {
/**
@@ -95,30 +98,31 @@ data class PushPayload(
get() = isKnownContractVersion && when (type) {
PushType.PERMISSION_REQUEST -> requestId != null
PushType.READY, PushType.TASK_NOTIFICATION -> true
- null -> false
+ PushType.MODEL_ERROR, null -> false
}
/**
* Notification channel routing: `permission_requests` (HIGH) /
- * `ready` (DEFAULT) / `task_notifications` (DEFAULT). Unknown types and
- * unknown contract versions land in the default-importance
- * `task_notifications` bucket — never in the heads-up channel.
+ * `model_errors` (HIGH) / `ready` (DEFAULT) / `task_notifications`
+ * (DEFAULT). Unknown types and unknown contract versions land in the
+ * default-importance `task_notifications` bucket — never heads-up.
*/
val channelId: String
get() = when {
!isKnownContractVersion -> CHANNEL_TASK_NOTIFICATIONS
type == PushType.PERMISSION_REQUEST -> CHANNEL_PERMISSION_REQUESTS
+ type == PushType.MODEL_ERROR -> CHANNEL_MODEL_ERROR
type == PushType.READY -> CHANNEL_READY
else -> CHANNEL_TASK_NOTIFICATIONS
}
/**
- * Coalescing tag `type-`: a newer push of the same type for
- * the same session replaces the previous notification instead of
- * stacking (mirrors the hub-side `tag` scheme it uses for Web Push).
+ * Coalescing tag: prefer the hub-supplied `data.tag` (event-specific
+ * for `model-error`) before falling back to `type-`.
*/
val notificationTag: String
- get() = "${rawType.ifBlank { "unknown" }}-$sessionId"
+ get() = tag?.takeIf { it.isNotBlank() }
+ ?: "${rawType.ifBlank { "unknown" }}-$sessionId"
/** Title to render; falls back to the session name, then a constant. */
val displayTitle: String
@@ -152,6 +156,7 @@ data class PushPayload(
const val CHANNEL_PERMISSION_REQUESTS = "permission_requests"
const val CHANNEL_READY = "ready"
const val CHANNEL_TASK_NOTIFICATIONS = "task_notifications"
+ const val CHANNEL_MODEL_ERROR = "model_errors"
private const val DEFAULT_TITLE = "HAPI"
@@ -175,6 +180,7 @@ data class PushPayload(
severity = PushSeverity.fromWire(data["severity"]),
contractVersion = data["contractVersion"]?.takeIf { it.isNotBlank() },
notifySummary = data["notifySummary"]?.let(::parseNotifySummary),
+ tag = data["tag"]?.takeIf { it.isNotBlank() },
)
}
diff --git a/android/core/data/src/test/kotlin/app/hapi/data/push/PushPayloadTest.kt b/android/core/data/src/test/kotlin/app/hapi/data/push/PushPayloadTest.kt
index 37dcdca2fc..e2ee50f6d4 100644
--- a/android/core/data/src/test/kotlin/app/hapi/data/push/PushPayloadTest.kt
+++ b/android/core/data/src/test/kotlin/app/hapi/data/push/PushPayloadTest.kt
@@ -82,6 +82,16 @@ class PushPayloadTest {
PushPayload.CHANNEL_TASK_NOTIFICATIONS,
PushPayload.parse(permissionData("type" to "task-notification"))!!.channelId,
)
+ assertEquals(
+ PushPayload.CHANNEL_MODEL_ERROR,
+ PushPayload.parse(
+ permissionData(
+ "type" to "model-error",
+ "severity" to "error",
+ "tag" to "model-error-11111111-2222-3333-4444-555555555555-evt-1",
+ )
+ )!!.channelId,
+ )
}
@Test
@@ -127,6 +137,51 @@ class PushPayloadTest {
)
}
+ @Test
+ fun `model-error is a known type and prefers the hub event tag`() {
+ val eventTag = "model-error-11111111-2222-3333-4444-555555555555-evt-1710000000000"
+ val payload = PushPayload.parse(
+ permissionData(
+ "type" to "model-error",
+ "severity" to "error",
+ "title" to "Rate limited",
+ "body" to "status 429",
+ "tag" to eventTag,
+ ).minus("requestId"),
+ )!!
+
+ assertEquals(PushType.MODEL_ERROR, payload.type)
+ assertEquals("model-error", payload.rawType)
+ assertEquals(PushPayload.CHANNEL_MODEL_ERROR, payload.channelId)
+ assertEquals(eventTag, payload.notificationTag)
+ assertEquals(PushSeverity.ERROR, payload.severity)
+ assertFalse(payload.supportsActions)
+ }
+
+ @Test
+ fun `model-error without a hub tag falls back to type-sessionId`() {
+ val payload = PushPayload.parse(
+ permissionData("type" to "model-error").minus("requestId"),
+ )!!
+ assertEquals(
+ "model-error-11111111-2222-3333-4444-555555555555",
+ payload.notificationTag,
+ )
+ }
+
+ @Test
+ fun `distinct model-error tags do not collapse`() {
+ val session = "11111111-2222-3333-4444-555555555555"
+ val first = PushPayload.parse(
+ permissionData("type" to "model-error", "tag" to "model-error-$session-evt-1"),
+ )!!.notificationTag
+ val second = PushPayload.parse(
+ permissionData("type" to "model-error", "tag" to "model-error-$session-evt-2"),
+ )!!.notificationTag
+ assertEquals("model-error-$session-evt-1", first)
+ assertEquals("model-error-$session-evt-2", second)
+ }
+
// ---------------------------------------------------------- ready bodies --
@Test
diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts
index 4d9036018b..d20fe65ff8 100644
--- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts
+++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts
@@ -735,6 +735,39 @@ describe('AcpSdkBackend', () => {
expect(turnCompleteIdx).toBeGreaterThan(lateIdx);
});
+ it('skips session/prompt when shouldSend is false after pre-prompt drain', async () => {
+ backendStatics.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS = 1;
+ backendStatics.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS = 50;
+ backendStatics.UPDATE_QUIET_PERIOD_MS = 1;
+ backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 50;
+ backendStatics.LATE_FLUSH_WINDOW_MS = 1;
+
+ const backend = new AcpSdkBackend({ command: 'opencode' });
+ const backendInternal = backend as unknown as {
+ transport: {
+ sendRequest: (...args: unknown[]) => Promise;
+ close: () => Promise;
+ } | null;
+ };
+ let sendCalls = 0;
+ backendInternal.transport = {
+ sendRequest: async () => {
+ sendCalls += 1;
+ return { stopReason: 'end_turn' };
+ },
+ close: async () => {}
+ };
+
+ const sent = await backend.prompt(
+ 'session-1',
+ [{ type: 'text', text: 'hi' }],
+ () => {},
+ { shouldSend: () => false }
+ );
+ expect(sent).toBe(false);
+ expect(sendCalls).toBe(0);
+ });
+
it('attributes pre-prompt straggler chunks to the previous turn\'s onUpdate', async () => {
backendStatics.UPDATE_QUIET_PERIOD_MS = 25;
backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200;
diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts
index dca431371f..df01425137 100644
--- a/cli/src/agent/backends/acp/AcpSdkBackend.ts
+++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts
@@ -542,8 +542,9 @@ export class AcpSdkBackend implements AgentBackend {
async prompt(
sessionId: string,
content: PromptContent[],
- onUpdate: (msg: AgentMessage) => void
- ): Promise {
+ onUpdate: (msg: AgentMessage) => void,
+ options?: { shouldSend?: () => boolean }
+ ): Promise {
if (!this.transport) {
throw new Error('ACP transport not initialized');
}
@@ -561,6 +562,9 @@ export class AcpSdkBackend implements AgentBackend {
);
await this.sessionUpdateQueue;
this.messageHandler?.drainBuffers();
+ if (options?.shouldSend && !options.shouldSend()) {
+ return false;
+ }
this.messageHandler = new AcpMessageHandler(onUpdate, {
textChunkMode: this.options.textChunkMode,
flavor: this.options.flavor,
@@ -655,6 +659,7 @@ export class AcpSdkBackend implements AgentBackend {
}
}
}
+ return true;
}
async cancelPrompt(sessionId: string): Promise {
diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts
index 6a39494665..fafc1f258b 100644
--- a/cli/src/agent/types.ts
+++ b/cli/src/agent/types.ts
@@ -105,7 +105,12 @@ export interface AgentBackend {
setConfigOption?(sessionId: string, configId: string, value: string): Promise;
getSessionModelsMetadata?(sessionId: string): AgentSessionModelsMetadata | undefined;
getThoughtLevelConfigOption?(sessionId: string): AgentSessionConfigOptionDescriptor | undefined;
- prompt(sessionId: string, content: PromptContent[], onUpdate: (msg: AgentMessage) => void): Promise;
+ prompt(
+ sessionId: string,
+ content: PromptContent[],
+ onUpdate: (msg: AgentMessage) => void,
+ options?: { shouldSend?: () => boolean }
+ ): Promise;
cancelPrompt(sessionId: string): Promise;
respondToPermission(sessionId: string, request: PermissionRequest, response: PermissionResponse): Promise;
onPermissionRequest(handler: (request: PermissionRequest) => void): void;
diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts
index 5b3acf23b8..9f5d3ef6c9 100644
--- a/cli/src/api/api.ts
+++ b/cli/src/api/api.ts
@@ -1,6 +1,7 @@
import axios from 'axios'
import type { AgentState, ClearOpencodeSessionCallbackRequest, ClearOpencodeSessionResponse, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types'
import { applyHubSessionSummaryContract } from '@/modules/common/sessionSummaryInstruction'
+import { setAutoBridgeTransientModelErrors } from '@/cursor/cursorModelErrorBridgePrefs'
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
import {
AgentStateSchema,
@@ -88,6 +89,9 @@ export class ApiClient {
if (typeof parsed.data.sessionSummaryContract === 'boolean') {
applyHubSessionSummaryContract(parsed.data.sessionSummaryContract)
}
+ if (typeof parsed.data.autoBridgeTransientModelErrors === 'boolean') {
+ setAutoBridgeTransientModelErrors(parsed.data.autoBridgeTransientModelErrors)
+ }
const raw = parsed.data.session
@@ -144,6 +148,9 @@ export class ApiClient {
if (typeof parsed.data.sessionSummaryContract === 'boolean') {
applyHubSessionSummaryContract(parsed.data.sessionSummaryContract)
}
+ if (typeof parsed.data.autoBridgeTransientModelErrors === 'boolean') {
+ setAutoBridgeTransientModelErrors(parsed.data.autoBridgeTransientModelErrors)
+ }
const raw = parsed.data.session
const metadata = (() => {
diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts
index 64766f172a..c6bc537b0a 100644
--- a/cli/src/api/apiSession.ts
+++ b/cli/src/api/apiSession.ts
@@ -1088,6 +1088,17 @@ export class ApiSessionClient extends EventEmitter {
summary: string
tokensBefore?: number
estimatedTokensAfter?: number
+ } | {
+ type: 'modelError'
+ kind: string
+ transient: boolean
+ rawSnippet: string
+ priorAssistantClaimsDone: boolean
+ } | {
+ type: 'modelErrorBridged'
+ kind: string
+ auto: boolean
+ eventId: string
}, id?: string): void {
const content = {
role: 'agent',
diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts
index 010b4bd04a..d1233a6b90 100644
--- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts
+++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts
@@ -16,7 +16,6 @@ const harness = vi.hoisted(() => ({
newSessionAttempts: 0,
promptCalls: 0,
prompts: [] as unknown[][],
- deferPrompt: null as Promise | null,
deferSoftSteer: null as Promise | null,
softSteerDispatchError: null as Error | null,
deferSoftSteerDispatch: null as Promise | null,
@@ -24,14 +23,41 @@ const harness = vi.hoisted(() => ({
promptMessages: [] as AgentMessage[],
promptMessageBatches: [] as AgentMessage[][],
promptStderrErrors: [] as Array<{ type: string; message: string; raw: string }>,
- releasePrompt: null as (() => void) | null,
backendArgs: null as { command: string; args?: string[] } | null,
setConfigOptionCalls: [] as Array<{ sessionId: string; configId: string; value: string }>,
deferSetConfigOption: null as Promise | null,
releaseSetConfigOption: null as (() => void) | null,
deferLoadSession: null as Promise | null,
releaseLoadSession: null as (() => void) | null,
- stderrErrorHandler: null as ((error: { type: string; message: string; raw?: string }) => void) | null,
+ stderrErrorHandler: null as ((error: {
+ type: string
+ message: string
+ raw: string
+ }) => void) | null,
+ emitStderrOnPrompt: null as {
+ type: 'rate_limit' | 'model_not_found' | 'authentication' | 'quota_exceeded' | 'unknown'
+ message: string
+ raw: string
+ } | null,
+ emitStderrOnInitialize: null as {
+ type: 'rate_limit' | 'model_not_found' | 'authentication' | 'quota_exceeded' | 'unknown'
+ message: string
+ raw: string
+ } | null,
+ emitStderrOnLoadSession: null as {
+ type: 'rate_limit' | 'model_not_found' | 'authentication' | 'quota_exceeded' | 'unknown'
+ message: string
+ raw: string
+ } | null,
+ emitTextOnPrompt: null as string | null,
+ promptReject: null as Error | null,
+ deferPrompt: null as Promise | null,
+ releasePrompt: null as (() => void) | null,
+ deferBeforeSend: null as Promise | null,
+ releaseBeforeSend: null as (() => void) | null,
+ promptSends: 0,
+ /** When cancelPrompt runs, reject the deferred prompt with this error. */
+ rejectPromptOnCancel: null as Error | null,
disconnectError: null as Error | null,
overlayCleanup: null as ReturnType | null,
agentActivityListener: null as ((thinking: boolean) => void) | null
@@ -55,12 +81,19 @@ vi.mock('./utils/cursorAcpBackend', () => ({
return {
initialize: vi.fn(async () => {
harness.initializeAttempts += 1;
+ if (harness.emitStderrOnInitialize && harness.stderrErrorHandler) {
+ harness.stderrErrorHandler(harness.emitStderrOnInitialize);
+ }
+ // Remap path (#1430 / stale spawn): fail only the first initialize
+ // so the launcher can retry after rewriting --model.
if (harness.initializeError && harness.initializeAttempts === 1) {
- harness.stderrErrorHandler?.({
- type: 'model_not_found',
- message: harness.initializeError.message,
- raw: harness.initializeError.message
- });
+ if (!harness.emitStderrOnInitialize) {
+ harness.stderrErrorHandler?.({
+ type: 'model_not_found',
+ message: harness.initializeError.message,
+ raw: harness.initializeError.message
+ });
+ }
throw harness.initializeError;
}
}),
@@ -71,6 +104,9 @@ vi.mock('./utils/cursorAcpBackend', () => ({
if (harness.deferLoadSession) {
await harness.deferLoadSession;
}
+ if (harness.emitStderrOnLoadSession && harness.stderrErrorHandler) {
+ harness.stderrErrorHandler(harness.emitStderrOnLoadSession);
+ }
if (harness.loadSessionError) throw harness.loadSessionError;
return 'loaded-acp-session';
}),
@@ -137,18 +173,46 @@ vi.mock('./utils/cursorAcpBackend', () => ({
}
return undefined;
}),
- prompt: vi.fn(async (_sessionId: string, content: unknown[], onMessage?: (message: AgentMessage) => void) => {
+ prompt: vi.fn(async (
+ _sessionId: string,
+ content: unknown[],
+ onMessage?: (message: AgentMessage) => void,
+ options?: { shouldSend?: () => boolean }
+ ) => {
harness.promptCalls++;
+ if (harness.deferBeforeSend) {
+ await harness.deferBeforeSend;
+ if (options?.shouldSend && !options.shouldSend()) {
+ return false;
+ }
+ }
+ harness.promptSends++;
harness.prompts.push(content);
const messages = harness.promptMessageBatches.shift() ?? harness.promptMessages.splice(0, 1);
for (const message of messages) onMessage?.(message);
+ if (harness.emitTextOnPrompt && onMessage) {
+ onMessage({ type: 'text', text: harness.emitTextOnPrompt });
+ }
const stderrError = harness.promptStderrErrors.shift();
if (stderrError) harness.stderrErrorHandler?.(stderrError);
+ if (harness.emitStderrOnPrompt && harness.stderrErrorHandler) {
+ harness.stderrErrorHandler(harness.emitStderrOnPrompt);
+ }
if (harness.deferPrompt) await harness.deferPrompt;
const error = harness.promptErrors.shift();
if (error) throw error;
+ if (harness.promptReject) {
+ throw harness.promptReject;
+ }
+ return true;
+ }),
+ cancelPrompt: vi.fn(async () => {
+ // Settlement of a deferred prompt is owned by the test so
+ // userAbortRequested is visible before classifyAcpRpcRejection.
+ if (harness.rejectPromptOnCancel) {
+ harness.promptReject = harness.rejectPromptOnCancel;
+ }
}),
- cancelPrompt: vi.fn(async () => {}),
getPromptGeneration: vi.fn(() => 1),
beginSoftSteerPrompt: vi.fn(() => ({
dispatched: harness.softSteerDispatchError
@@ -160,8 +224,8 @@ vi.mock('./utils/cursorAcpBackend', () => ({
abortSoftSteers: vi.fn(),
waitForResponseComplete: vi.fn(async () => {}),
respondToPermission: vi.fn(async () => {}),
- onStderrError: vi.fn((handler) => {
- harness.stderrErrorHandler = handler ?? null;
+ onStderrError: vi.fn((handler: typeof harness.stderrErrorHandler) => {
+ harness.stderrErrorHandler = handler;
}),
setUsageUpdateListener: vi.fn(),
setAgentActivityListener: vi.fn((listener: ((thinking: boolean) => void) | null) => {
@@ -227,8 +291,9 @@ import {
_resetSharedCursorModelsCacheForTests,
writeSharedCursorModelsCache
} from '@/modules/common/cursorModelsSharedCache';
+import { setAutoBridgeTransientModelErrors } from './cursorModelErrorBridgePrefs';
-function makeSession(sessionId: string | null, closeQueue = true): CursorSession {
+function makeSession(sessionId: string | null, opts?: { keepQueueOpen?: boolean }): CursorSession {
const queue = new MessageQueue2(() => 'mode');
const client = makeClient();
@@ -247,7 +312,7 @@ function makeSession(sessionId: string | null, closeQueue = true): CursorSession
});
session.onSessionFoundWithProtocol = vi.fn();
- if (closeQueue) {
+ if (!opts?.keepQueueOpen) {
queue.close();
}
@@ -265,6 +330,7 @@ function makeClient() {
}),
unregisterHandler: vi.fn()
},
+ getMetadata: vi.fn(() => null),
updateMetadata: vi.fn(),
flushMetadata: vi.fn(async () => true),
sendSessionEvent: vi.fn(),
@@ -287,8 +353,8 @@ describe('cursorAcpRemoteLauncher', () => {
harness.failSetConfigOption = false;
harness.supportsLoadSession = true;
harness.loadSessionCalled = false;
- harness.newSessionCalled = false;
harness.newSessionAttempts = 0;
+ harness.newSessionCalled = false;
harness.promptCalls = 0;
harness.prompts = [];
harness.deferPrompt = null;
@@ -306,16 +372,29 @@ describe('cursorAcpRemoteLauncher', () => {
harness.deferLoadSession = null;
harness.releaseLoadSession = null;
harness.stderrErrorHandler = null;
+ harness.emitStderrOnPrompt = null;
+ harness.emitStderrOnInitialize = null;
+ harness.emitStderrOnLoadSession = null;
+ harness.emitTextOnPrompt = null;
+ harness.promptReject = null;
+ harness.deferPrompt = null;
+ harness.releasePrompt = null;
+ harness.deferBeforeSend = null;
+ harness.releaseBeforeSend = null;
+ harness.promptSends = 0;
+ harness.rejectPromptOnCancel = null;
harness.disconnectError = null;
harness.overlayCleanup = null;
harness.agentActivityListener = null;
legacyLauncher.mockClear();
+ setAutoBridgeTransientModelErrors(false);
process.stdin.isTTY = false;
process.stdout.isTTY = false;
});
afterEach(() => {
vi.clearAllMocks();
+ setAutoBridgeTransientModelErrors(false);
_resetSharedCursorModelsCacheForTests();
});
@@ -325,7 +404,7 @@ describe('cursorAcpRemoteLauncher', () => {
// Soft-steer completion never settles — simulates Cursor keeping the
// concurrent request open after an ordinary Abort.
harness.deferSoftSteer = new Promise(() => {});
- const session = makeSession(null, false);
+ const session = makeSession(null, { keepQueueOpen: true });
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
@@ -352,7 +431,7 @@ describe('cursorAcpRemoteLauncher', () => {
let releasePrompt!: () => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
harness.softSteerDispatchError = new Error('stdin closed');
- const session = makeSession(null, false);
+ const session = makeSession(null, { keepQueueOpen: true });
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
@@ -379,7 +458,7 @@ describe('cursorAcpRemoteLauncher', () => {
let rejectSoftSteer!: (error: Error) => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
harness.deferSoftSteer = new Promise((_, reject) => { rejectSoftSteer = reject; });
- const session = makeSession(null, false);
+ const session = makeSession(null, { keepQueueOpen: true });
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
@@ -411,7 +490,7 @@ describe('cursorAcpRemoteLauncher', () => {
harness.deferSoftSteer = new Promise((_, reject) => { rejectSoftSteer = reject; });
const indeterminate = new Error('ACP transport closed');
Object.defineProperty(indeterminate, ACP_INDETERMINATE_SYMBOL, { value: true });
- const session = makeSession(null, false);
+ const session = makeSession(null, { keepQueueOpen: true });
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
@@ -441,7 +520,7 @@ describe('cursorAcpRemoteLauncher', () => {
const indeterminate = new Error('ACP write callback failed');
Object.defineProperty(indeterminate, ACP_INDETERMINATE_SYMBOL, { value: true });
harness.softSteerDispatchError = indeterminate;
- const session = makeSession(null, false);
+ const session = makeSession(null, { keepQueueOpen: true });
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
@@ -468,7 +547,7 @@ describe('cursorAcpRemoteLauncher', () => {
let releaseDispatch!: () => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
harness.deferSoftSteerDispatch = new Promise((resolve) => { releaseDispatch = resolve; });
- const session = makeSession(null, false);
+ const session = makeSession(null, { keepQueueOpen: true });
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
@@ -499,7 +578,7 @@ describe('cursorAcpRemoteLauncher', () => {
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
harness.deferSoftSteerDispatch = new Promise((resolve) => { releaseDispatch = resolve; });
harness.deferSoftSteer = new Promise((resolve) => { releaseSoftSteer = resolve; });
- const session = makeSession(null, false);
+ const session = makeSession(null, { keepQueueOpen: true });
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
@@ -532,7 +611,7 @@ describe('cursorAcpRemoteLauncher', () => {
let releaseDispatch!: () => void;
harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; });
harness.deferSoftSteerDispatch = new Promise((resolve) => { releaseDispatch = resolve; });
- const session = makeSession(null, false);
+ const session = makeSession(null, { keepQueueOpen: true });
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
@@ -565,7 +644,7 @@ describe('cursorAcpRemoteLauncher', () => {
// Completion never resolves — simulates Cursor keeping the concurrent
// request open past Exit/Switch.
harness.deferSoftSteer = new Promise(() => {});
- const session = makeSession(null, false);
+ const session = makeSession(null, { keepQueueOpen: true });
const mode = { permissionMode: 'default' } as EnhancedMode;
session.queue.push('first', mode, 'first');
@@ -883,6 +962,7 @@ describe('cursorAcpRemoteLauncher', () => {
const queue = new MessageQueue2(() => 'mode');
const client = makeClient() as unknown as ApiSessionClient & {
sendAgentMessage: ReturnType;
+ sendSessionEvent: ReturnType;
};
const session = new CursorSession({
api: {} as never,
@@ -908,6 +988,12 @@ describe('cursorAcpRemoteLauncher', () => {
type: 'error',
message: expect.stringContaining('not retried')
}));
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ )).toBe(true);
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelErrorBridged'
+ )).toBe(false);
});
it('removes the Cursor MCP overlay even when backend.disconnect rejects', async () => {
@@ -1763,4 +1849,1121 @@ describe('cursorAcpRemoteLauncher', () => {
expect(JSON.stringify(harness.prompts[1])).toContain('second');
expect(JSON.stringify(harness.prompts[1])).not.toContain('skill_lookup');
});
+
+ it('keeps generic unknown stderr status-only and still emits ready', async () => {
+ // Bot Major: type:unknown comes from any stderr with error/failed/exception.
+ // Must not set turnHasModelError / suppress ready / write lastModelError.
+ harness.emitStderrOnPrompt = {
+ type: 'unknown',
+ message: 'Some plugin failed to load: exception during init',
+ raw: 'Some plugin failed to load: exception during init'
+ };
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ sendSessionEvent: ReturnType
+ updateMetadata: ReturnType
+ sendAgentMessage: ReturnType
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ session.queue.close();
+
+ await cursorAcpRemoteLauncher(session);
+
+ expect(harness.promptCalls).toBe(1);
+ expect(client.sendSessionEvent).toHaveBeenCalledWith({ type: 'ready' });
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ )).toBe(false);
+ const wroteLastModelError = client.updateMetadata.mock.calls.some((call) => {
+ const updater = call[0] as (m: Record) => Record;
+ if (typeof updater !== 'function') return false;
+ return Boolean(updater({}).lastModelError);
+ });
+ expect(wroteLastModelError).toBe(false);
+ });
+
+ it('keeps weak typed authentication stderr status-only and still emits ready', async () => {
+ // Transport types "authentication provider initialized" as authentication
+ // via bare substring; strong-signature gate must keep it status-only.
+ harness.emitStderrOnPrompt = {
+ type: 'authentication',
+ message: 'authentication provider initialized',
+ raw: 'authentication provider initialized'
+ };
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ sendSessionEvent: ReturnType
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ session.queue.close();
+
+ await cursorAcpRemoteLauncher(session);
+
+ expect(client.sendSessionEvent).toHaveBeenCalledWith({ type: 'ready' });
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ )).toBe(false);
+ });
+
+ it('records modelError for model_not_found stderr during prompt and suppresses ready', async () => {
+ harness.emitStderrOnPrompt = {
+ type: 'model_not_found',
+ message: 'Cannot use this model: cursor-bad-id. Available models: auto',
+ raw: 'Cannot use this model: cursor-bad-id. Available models: auto'
+ };
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ sendSessionEvent: ReturnType
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ session.queue.close();
+
+ await cursorAcpRemoteLauncher(session);
+
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError' && call[0]?.kind === 'model_not_found'
+ )).toBe(true);
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'ready'
+ )).toBe(false);
+ });
+
+ it('ignores Cannot use this model stderr during initialize/load so remap can succeed', async () => {
+ // Setup/load remap rejects a stale spawn model on stderr, then continues.
+ // Must not persist lastModelError / suppress later ready.
+ const stale = {
+ type: 'model_not_found' as const,
+ message: 'Cannot use this model: grok-4.5[fast=true]. Available models: auto',
+ raw: 'Cannot use this model: grok-4.5[fast=true]. Available models: auto'
+ };
+ harness.emitStderrOnInitialize = stale;
+ harness.emitStderrOnLoadSession = stale;
+
+ const session = makeSession('resume-remap-ok', { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ sendSessionEvent: ReturnType
+ updateMetadata: ReturnType
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ session.queue.close();
+
+ await cursorAcpRemoteLauncher(session);
+
+ expect(harness.loadSessionCalled).toBe(true);
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ )).toBe(false);
+ expect(client.updateMetadata.mock.calls.some((call) => {
+ const updater = call[0] as (m: Record) => Record;
+ if (typeof updater !== 'function') return false;
+ return Boolean(updater({}).lastModelError);
+ })).toBe(false);
+ expect(client.sendSessionEvent).toHaveBeenCalledWith({ type: 'ready' });
+ });
+
+ it('still records modelError for typed rate_limit stderr and suppresses ready', async () => {
+ harness.emitStderrOnPrompt = {
+ type: 'rate_limit',
+ message: 'Rate limit exceeded.',
+ raw: 'status 429 ratelimitexceeded'
+ };
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ sendSessionEvent: ReturnType
+ updateMetadata: ReturnType
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ session.queue.close();
+
+ await cursorAcpRemoteLauncher(session);
+
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError' && call[0]?.kind === 'rate_limited'
+ )).toBe(true);
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'ready'
+ )).toBe(false);
+ const wroteLastModelError = client.updateMetadata.mock.calls.some((call) => {
+ const updater = call[0] as (m: Record) => Record;
+ if (typeof updater !== 'function') return false;
+ const next = updater({});
+ return (next.lastModelError as { kind?: string } | undefined)?.kind === 'rate_limited';
+ });
+ expect(wroteLastModelError).toBe(true);
+ });
+
+ it('prefers structural RPC classification over text fallback when both fire', async () => {
+ // Prompt callback emits wire text first (unknown_t_prefix / non-transient),
+ // then the promise rejects with WritableIterable (transport_closed).
+ harness.emitTextOnPrompt = '\n\nError: T: WritableIterable is closed';
+ harness.promptReject = new Error('WritableIterable is closed');
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ sendSessionEvent: ReturnType
+ updateMetadata: ReturnType
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ session.queue.close();
+
+ await cursorAcpRemoteLauncher(session);
+
+ const modelErrors = client.sendSessionEvent.mock.calls
+ .map((call) => call[0])
+ .filter((event) => event?.type === 'modelError');
+ expect(modelErrors).toHaveLength(1);
+ expect(modelErrors[0]?.kind).toBe('transport_closed');
+ expect(modelErrors[0]?.transient).toBe(false);
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'ready'
+ )).toBe(false);
+ });
+
+ it('prefers specific deferred stderr over generic transport_closed RPC', async () => {
+ // Strong quota stderr first, then generic transport close — keep the
+ // non-transient cause so retry copy is not “safe to retry”.
+ harness.emitStderrOnPrompt = {
+ type: 'quota_exceeded',
+ message: 'Quota exceeded.',
+ raw: 'resource exhausted'
+ };
+ harness.promptReject = new Error('WritableIterable is closed');
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ sendSessionEvent: ReturnType
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ session.queue.close();
+
+ await cursorAcpRemoteLauncher(session);
+
+ const modelErrors = client.sendSessionEvent.mock.calls
+ .map((call) => call[0])
+ .filter((event) => event?.type === 'modelError');
+ expect(modelErrors).toHaveLength(1);
+ expect(modelErrors[0]?.kind).toBe('quota_exhausted');
+ expect(modelErrors[0]?.transient).toBe(false);
+ });
+
+ it('still records modelError for canceled RPC rejection without user abort', async () => {
+ harness.promptReject = new Error('Error: T: [canceled] Operation aborted');
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ sendSessionEvent: ReturnType
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ session.queue.close();
+ await cursorAcpRemoteLauncher(session);
+
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError' && call[0]?.kind === 'canceled'
+ )).toBe(true);
+ });
+
+ it('does not promote user Abort cancel rejection to modelError', async () => {
+ // Cursor rejects session/prompt after session/cancel with this wire shape;
+ // classifier maps it to kind=canceled, but Abort must not page/notify.
+ // Switch → requestExit → handleAbort sets shouldExit + userAbortRequested
+ // before we settle the deferred prompt rejection (ordering matches
+ // cancel-then-reject on the wire; avoids queue.reset hang in tests).
+ harness.deferPrompt = new Promise((resolve) => {
+ harness.releasePrompt = resolve;
+ });
+ harness.rejectPromptOnCancel = new Error('Error: T: [canceled] Operation aborted');
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ sendSessionEvent: ReturnType
+ updateMetadata: ReturnType
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+
+ const switchHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === 'switch'
+ )?.[1] as (() => Promise) | undefined;
+ expect(switchHandler).toBeTypeOf('function');
+ await switchHandler!();
+ harness.releasePrompt?.();
+ await launchPromise;
+
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ )).toBe(false);
+ const wroteLastModelError = client.updateMetadata.mock.calls.some((call) => {
+ const updater = call[0] as (m: Record) => Record;
+ if (typeof updater !== 'function') return false;
+ return Boolean(updater({}).lastModelError);
+ });
+ expect(wroteLastModelError).toBe(false);
+ });
+
+ it('does not mark an in-flight bridge recovered when Abort wins the race', async () => {
+ // Enqueue bridge while idle, then hold the bridge prompt and abort before
+ // it settles — must NOT emit recovered.
+ const session = makeSession('acp-session', { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ sendSessionEvent: ReturnType
+ updateMetadata: ReturnType
+ };
+
+ let waitCount = 0;
+ const nextWait = { release: null as (() => void) | null };
+ const originalWait = session.queue.waitForMessagesAndGetAsString.bind(session.queue);
+ session.queue.waitForMessagesAndGetAsString = async (signal) => {
+ waitCount += 1;
+ // Park from the second wait (post-first-turn idle) so bridge stays queued.
+ if (waitCount >= 2 && nextWait.release === null) {
+ await new Promise((resolve) => {
+ nextWait.release = resolve;
+ });
+ }
+ return originalWait(signal);
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+ await vi.waitFor(() => nextWait.release !== null);
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+ const eventId = '55555555-5555-4555-8555-555555555555';
+ expect(await bridgeHandler!({
+ eventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: 'hello',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: true });
+
+ const bridgeGate = { release: null as (() => void) | null };
+ harness.deferPrompt = new Promise((resolve) => {
+ bridgeGate.release = resolve;
+ });
+ nextWait.release?.();
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(2));
+
+ const abortHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.Abort
+ )?.[1] as (() => Promise) | undefined;
+ expect(abortHandler).toBeTypeOf('function');
+ await abortHandler!();
+ bridgeGate.release?.();
+ session.queue.close();
+ await launchPromise;
+
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelErrorBridged'
+ )).toBe(false);
+ const wroteBridgedForEventId = client.updateMetadata.mock.calls.some((call) => {
+ const updater = call[0] as (m: Record) => Record;
+ if (typeof updater !== 'function') return false;
+ const err = updater({}).lastModelError as { bridgedForEventId?: string } | undefined;
+ return err?.bridgedForEventId === eventId;
+ });
+ expect(wroteBridgedForEventId).toBe(false);
+ });
+
+ it('does not promote ACP process-exit rejection after deliberate abort to modelError', async () => {
+ harness.deferPrompt = new Promise((resolve) => {
+ harness.releasePrompt = resolve;
+ });
+
+ // Non-null Cursor session id so handleAbort reaches backend.cancelPrompt.
+ const session = makeSession('acp-session', { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ sendSessionEvent: ReturnType
+ updateMetadata: ReturnType
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+
+ harness.rejectPromptOnCancel = new Error('ACP process exited (code=143, signal=null)');
+ const abortHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.Abort
+ )?.[1] as (() => Promise) | undefined;
+ expect(abortHandler).toBeTypeOf('function');
+ await abortHandler!();
+ harness.releasePrompt?.();
+ session.queue.close();
+ await launchPromise;
+
+ expect(client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ )).toBe(false);
+ const wroteLastModelError = client.updateMetadata.mock.calls.some((call) => {
+ const updater = call[0] as (m: Record) => Record;
+ if (typeof updater !== 'function') return false;
+ return Boolean(updater({}).lastModelError);
+ });
+ expect(wroteLastModelError).toBe(false);
+ expect(session.queue.pendingLocalIds().some((id) => id.startsWith('bridge:'))).toBe(false);
+ });
+
+ it('rejects manual bridge while a normal prompt is in flight', async () => {
+ harness.deferPrompt = new Promise((resolve) => {
+ harness.releasePrompt = resolve;
+ });
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+
+ const eventId = '66666666-6666-4666-8666-666666666666';
+ expect(await bridgeHandler!({
+ eventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: 'older failed turn',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: false, reason: 'prompt_in_flight' });
+ expect(session.queue.pendingLocalIds().some((id) => id.startsWith('bridge:'))).toBe(false);
+
+ session.queue.close();
+ harness.releasePrompt?.();
+ await launchPromise;
+ });
+
+ it('clears pending bridge on abort so the same event can be bridged again', async () => {
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ };
+
+ // Park the post-turn wait so a manual bridge stays queued (idle, not mid-prompt).
+ let waitCount = 0;
+ const nextWait = { release: null as (() => void) | null };
+ const originalWait = session.queue.waitForMessagesAndGetAsString.bind(session.queue);
+ session.queue.waitForMessagesAndGetAsString = async (signal) => {
+ waitCount += 1;
+ if (waitCount >= 2 && nextWait.release === null) {
+ await new Promise((resolve) => {
+ nextWait.release = resolve;
+ });
+ }
+ return originalWait(signal);
+ };
+
+ session.queue.push('hello', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+ await vi.waitFor(() => nextWait.release !== null);
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+ expect(bridgeHandler).toBeTypeOf('function');
+
+ const eventId = '11111111-1111-4111-8111-111111111111';
+ const bridgePayload = {
+ eventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: 'hello',
+ priorAssistantClaimsDone: false
+ };
+
+ expect(await bridgeHandler!(bridgePayload)).toEqual({ ok: true });
+ expect(session.queue.queue.some((item) => item.internal?.kind === 'model-error-bridge' && item.internal.eventId === eventId)).toBe(true);
+ expect(session.queue.pendingLocalIds().some((id) => id.startsWith('bridge:'))).toBe(false);
+ expect(await bridgeHandler!(bridgePayload)).toEqual({
+ ok: false,
+ reason: 'not_bridgeable'
+ });
+
+ const abortHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.Abort
+ )?.[1] as (() => Promise) | undefined;
+ expect(abortHandler).toBeTypeOf('function');
+ await abortHandler!();
+
+ expect(session.queue.pendingLocalIds().some((id) => id.startsWith('bridge:'))).toBe(false);
+ expect(await bridgeHandler!(bridgePayload)).toEqual({ ok: true });
+ expect(session.queue.queue.some((item) => item.internal?.kind === 'model-error-bridge' && item.internal.eventId === eventId)).toBe(true);
+ expect(session.queue.pendingLocalIds().some((id) => id.startsWith('bridge:'))).toBe(false);
+
+ session.queue.close();
+ nextWait.release?.();
+ await launchPromise;
+ });
+
+ it('treats a forged bridge: localId user turn as normal and refuses Bridge overtake', async () => {
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ };
+
+ let waitCount = 0;
+ const nextWait = { release: null as (() => void) | null };
+ const originalWait = session.queue.waitForMessagesAndGetAsString.bind(session.queue);
+ session.queue.waitForMessagesAndGetAsString = async (signal) => {
+ waitCount += 1;
+ if (waitCount >= 2 && nextWait.release === null) {
+ await new Promise((resolve) => {
+ nextWait.release = resolve;
+ });
+ }
+ return originalWait(signal);
+ };
+
+ session.queue.push('first', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+ await vi.waitFor(() => nextWait.release !== null);
+
+ // Forged localId must not count as queue-owned Bridge provenance.
+ session.queue.push('forged', { permissionMode: 'default' }, 'bridge:evt-forged');
+ expect(session.queue.hasPendingNonBridgeTurn()).toBe(true);
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+
+ expect(await bridgeHandler!({
+ eventId: '55555555-5555-4555-8555-555555555555',
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: 'first',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: false, reason: 'superseded_by_newer_turn' });
+ expect(session.queue.queue.some((item) => item.internal?.kind === 'model-error-bridge')).toBe(false);
+
+ session.queue.close();
+ nextWait.release?.();
+ await launchPromise;
+ });
+
+ it('rejects Bridge when the last user prompt exceeds the exact-replay limit', async () => {
+ const { MAX_LAST_USER_MESSAGE_CHARS } = await import('./cursorModelErrorBridge');
+ harness.emitStderrOnPrompt = {
+ type: 'rate_limit',
+ message: 'Rate limit exceeded.',
+ raw: 'status 429 ratelimitexceeded'
+ };
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ sendSessionEvent: ReturnType
+ updateMetadata: ReturnType
+ };
+
+ const longPrompt = 'x'.repeat(MAX_LAST_USER_MESSAGE_CHARS + 1);
+ session.queue.push(longPrompt, { permissionMode: 'default' });
+ session.queue.close();
+ await cursorAcpRemoteLauncher(session);
+
+ await vi.waitFor(() => client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ ));
+
+ const recorded = client.updateMetadata.mock.calls
+ .map((c) => {
+ const u = c[0] as (m: Record) => Record;
+ if (typeof u !== 'function') return null;
+ return u({}).lastModelError as {
+ eventId?: string
+ bridgeable?: boolean
+ lastUserMessage?: string
+ } | undefined;
+ })
+ .find((err) => typeof err?.eventId === 'string');
+ expect(recorded?.bridgeable).toBe(false);
+ expect(recorded?.lastUserMessage).toBe('');
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+ expect(await bridgeHandler!({
+ eventId: recorded?.eventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: longPrompt,
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: false, reason: 'not_bridgeable' });
+ });
+
+ it('does not wrap pass-through slash commands as Bridge prompts', async () => {
+ harness.emitStderrOnPrompt = {
+ type: 'rate_limit',
+ message: 'Rate limit exceeded.',
+ raw: 'status 429 ratelimitexceeded'
+ };
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ sendSessionEvent: ReturnType
+ updateMetadata: ReturnType
+ };
+
+ session.queue.push('/compact keep recap', { permissionMode: 'default' });
+ session.queue.close();
+ await cursorAcpRemoteLauncher(session);
+
+ await vi.waitFor(() => client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ ));
+
+ const recorded = client.updateMetadata.mock.calls
+ .map((c) => {
+ const u = c[0] as (m: Record) => Record;
+ if (typeof u !== 'function') return null;
+ return u({}).lastModelError as {
+ eventId?: string
+ bridgeable?: boolean
+ lastUserMessage?: string
+ } | undefined;
+ })
+ .find((err) => typeof err?.eventId === 'string');
+ expect(recorded?.bridgeable).toBe(false);
+ expect(recorded?.lastUserMessage).toBe('');
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+ expect(await bridgeHandler!({
+ eventId: recorded?.eventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: '/compact keep recap',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: false, reason: 'not_bridgeable' });
+ });
+
+ it('rejects manual bridge when a newer user turn is already queued', async () => {
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ updateMetadata: ReturnType
+ };
+
+ let waitCount = 0;
+ const nextWait = { release: null as (() => void) | null };
+ const originalWait = session.queue.waitForMessagesAndGetAsString.bind(session.queue);
+ session.queue.waitForMessagesAndGetAsString = async (signal) => {
+ waitCount += 1;
+ if (waitCount >= 2 && nextWait.release === null) {
+ await new Promise((resolve) => {
+ nextWait.release = resolve;
+ });
+ }
+ return originalWait(signal);
+ };
+
+ session.queue.push('first', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+ await vi.waitFor(() => nextWait.release !== null);
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+
+ session.queue.push('correction instead of retry', { permissionMode: 'default' });
+ expect(await bridgeHandler!({
+ eventId: '44444444-4444-4444-8444-444444444444',
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: 'first',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: false, reason: 'superseded_by_newer_turn' });
+
+ expect(session.queue.pendingLocalIds().some((id) => id.startsWith('bridge:'))).toBe(false);
+ expect(session.queue.queue.some((item) => item.message === 'correction instead of retry')).toBe(true);
+
+ session.queue.close();
+ nextWait.release?.();
+ await launchPromise;
+ });
+
+ it('does not auto-bridge ahead of a newer queued user turn', async () => {
+ const { setAutoBridgeTransientModelErrors } = await import('./cursorModelErrorBridgePrefs');
+ setAutoBridgeTransientModelErrors(true);
+
+ harness.deferPrompt = new Promise((resolve) => {
+ harness.releasePrompt = resolve;
+ });
+ harness.promptReject = new Error('status 429 ratelimitexceeded');
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ sendSessionEvent: ReturnType
+ };
+
+ session.queue.push('first', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+
+ // User queues a replacement while the failing turn is still settling.
+ session.queue.push('do something else', { permissionMode: 'default' });
+ harness.releasePrompt?.();
+
+ await vi.waitFor(() => client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ ));
+ expect(session.queue.pendingLocalIds().some((id) => id.startsWith('bridge:'))).toBe(false);
+ expect(session.queue.queue[0]?.message).toBe('do something else');
+
+ setAutoBridgeTransientModelErrors(false);
+ session.queue.close();
+ await launchPromise;
+ });
+
+ it('drops an already-queued Bridge when a newer user turn arrives before dequeue', async () => {
+ harness.emitStderrOnPrompt = {
+ type: 'rate_limit',
+ message: 'Rate limit exceeded.',
+ raw: 'status 429 ratelimitexceeded'
+ };
+
+ let metadata: Record = {
+ path: '/tmp/project',
+ host: 'localhost'
+ };
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ sendSessionEvent: ReturnType
+ getMetadata: ReturnType
+ updateMetadata: ReturnType
+ };
+ client.getMetadata.mockImplementation(() => metadata);
+ client.updateMetadata.mockImplementation((updater: unknown) => {
+ if (typeof updater === 'function') {
+ metadata = (updater as (m: Record) => Record)(metadata);
+ }
+ });
+
+ let waitCount = 0;
+ const nextWait = { release: null as (() => void) | null };
+ const originalWait = session.queue.waitForMessagesAndGetAsString.bind(session.queue);
+ session.queue.waitForMessagesAndGetAsString = async (signal) => {
+ waitCount += 1;
+ if (waitCount >= 2 && nextWait.release === null) {
+ await new Promise((resolve) => {
+ nextWait.release = resolve;
+ });
+ }
+ return originalWait(signal);
+ };
+
+ session.queue.push('first', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+ await vi.waitFor(() => client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ ));
+ await vi.waitFor(() => nextWait.release !== null);
+
+ const recorded = metadata.lastModelError as {
+ eventId?: string
+ kind?: string
+ rawSnippet?: string
+ } | undefined;
+ expect(recorded?.eventId).toBeTypeOf('string');
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+ expect(bridgeHandler).toBeTypeOf('function');
+ expect(await bridgeHandler!({
+ eventId: recorded?.eventId,
+ kind: recorded?.kind ?? 'rate_limited',
+ transient: true,
+ rawSnippet: recorded?.rawSnippet ?? 'status 429',
+ lastUserMessage: 'first',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: true });
+ expect(session.queue.queue.some((item) => item.internal?.kind === 'model-error-bridge' && item.internal.eventId === recorded?.eventId)).toBe(true);
+ expect(session.queue.pendingLocalIds().some((id) => id.startsWith('bridge:'))).toBe(false);
+
+ // Newer user intent arrives after Bridge is already at the head.
+ session.queue.push('correction instead of retry', { permissionMode: 'default' });
+ harness.emitStderrOnPrompt = null;
+ nextWait.release?.();
+
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(2));
+ const secondPrompt = JSON.stringify(harness.prompts[1] ?? []);
+ expect(secondPrompt).toContain('correction instead of retry');
+ expect(session.queue.queue.some((item) => item.internal?.kind === 'model-error-bridge')).toBe(false);
+ // Bridge was dropped (not executed); subsequent Bridge RPC must fail closed.
+ expect(await bridgeHandler!({
+ eventId: recorded?.eventId,
+ kind: recorded?.kind ?? 'rate_limited',
+ transient: true,
+ rawSnippet: recorded?.rawSnippet ?? 'status 429',
+ lastUserMessage: 'first',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: false, reason: 'superseded_by_newer_turn' });
+
+ session.queue.close();
+ await launchPromise;
+ });
+
+ it('records idle stderr as non-bridgeable so it cannot replay a finished turn', async () => {
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ sendSessionEvent: ReturnType
+ updateMetadata: ReturnType
+ };
+
+ let waitCount = 0;
+ const nextWait = { release: null as (() => void) | null };
+ const originalWait = session.queue.waitForMessagesAndGetAsString.bind(session.queue);
+ session.queue.waitForMessagesAndGetAsString = async (signal) => {
+ waitCount += 1;
+ if (waitCount >= 2 && nextWait.release === null) {
+ await new Promise((resolve) => {
+ nextWait.release = resolve;
+ });
+ }
+ return originalWait(signal);
+ };
+
+ session.queue.push('first', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+ await vi.waitFor(() => nextWait.release !== null);
+
+ harness.stderrErrorHandler!({
+ type: 'rate_limit',
+ message: 'Rate limit exceeded.',
+ raw: 'status 429 ratelimitexceeded'
+ });
+ await vi.waitFor(() => client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ ));
+
+ const recorded = client.updateMetadata.mock.calls
+ .map((c) => {
+ const u = c[0] as (m: Record) => Record;
+ if (typeof u !== 'function') return null;
+ return u({}).lastModelError as {
+ eventId?: string
+ bridgeable?: boolean
+ } | undefined;
+ })
+ .find((err) => typeof err?.eventId === 'string');
+ expect(recorded?.bridgeable).toBe(false);
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+
+ expect(await bridgeHandler!({
+ eventId: recorded?.eventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: 'first',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: false, reason: 'not_bridgeable' });
+
+ session.queue.close();
+ nextWait.release?.();
+ await launchPromise;
+ });
+
+ it('hydrates persisted lastModelError so a post-restart turn supersedes it', async () => {
+ const eventId = '33333333-3333-4333-8333-333333333333';
+ let metadata: Record = {
+ path: '/tmp/project',
+ host: 'localhost',
+ lastModelError: {
+ eventId,
+ atTs: 1000,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ priorAssistantClaimsDone: false,
+ lastUserMessage: 'old failed turn'
+ }
+ };
+
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ getMetadata: ReturnType
+ updateMetadata: ReturnType
+ rpcHandlerManager: { registerHandler: ReturnType }
+ };
+ client.getMetadata.mockImplementation(() => metadata);
+ client.updateMetadata.mockImplementation((updater: unknown) => {
+ if (typeof updater === 'function') {
+ metadata = (updater as (m: Record) => Record)(metadata);
+ }
+ });
+
+ session.queue.push('continue after restart', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+ await vi.waitFor(() => {
+ const err = metadata.lastModelError as { supersededByUserTurn?: boolean } | undefined;
+ expect(err?.supersededByUserTurn).toBe(true);
+ });
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+ expect(bridgeHandler).toBeTypeOf('function');
+
+ expect(await bridgeHandler!({
+ eventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: 'old failed turn',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: false, reason: 'superseded_by_newer_turn' });
+
+ session.queue.close();
+ await launchPromise;
+ });
+
+ it('rejects bridge after a newer normal turn succeeds', async () => {
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ updateMetadata: ReturnType
+ sendSessionEvent: ReturnType
+ };
+
+ const readLastModelError = (): {
+ eventId?: string
+ supersededByUserTurn?: boolean
+ } | undefined => {
+ for (let i = client.updateMetadata.mock.calls.length - 1; i >= 0; i -= 1) {
+ const updater = client.updateMetadata.mock.calls[i]?.[0] as
+ | ((m: Record) => Record)
+ | undefined;
+ if (typeof updater !== 'function') continue;
+ const err = updater({}).lastModelError as {
+ eventId?: string
+ supersededByUserTurn?: boolean
+ } | undefined;
+ if (typeof err?.eventId === 'string') {
+ return err;
+ }
+ }
+ return undefined;
+ };
+
+ let waitCount = 0;
+ const nextWait = { release: null as (() => void) | null };
+ const originalWait = session.queue.waitForMessagesAndGetAsString.bind(session.queue);
+ session.queue.waitForMessagesAndGetAsString = async (signal) => {
+ waitCount += 1;
+ if (waitCount >= 2 && nextWait.release === null) {
+ await new Promise((resolve) => {
+ nextWait.release = resolve;
+ });
+ }
+ return originalWait(signal);
+ };
+
+ session.queue.push('first', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+ await vi.waitFor(() => nextWait.release !== null);
+
+ // Idle structural stderr records a durable modelError (no auto-bridge).
+ expect(harness.stderrErrorHandler).toBeTypeOf('function');
+ harness.stderrErrorHandler!({
+ type: 'rate_limit',
+ message: 'Rate limit exceeded.',
+ raw: 'status 429 ratelimitexceeded'
+ });
+ await vi.waitFor(() => client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ ));
+
+ const eventId = readLastModelError()?.eventId;
+ expect(eventId).toEqual(expect.any(String));
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+ expect(bridgeHandler).toBeTypeOf('function');
+
+ // Newer normal turn starts → durable supersededByUserTurn gate.
+ session.queue.push('continue without bridging', { permissionMode: 'default' });
+ nextWait.release?.();
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(2));
+ await vi.waitFor(() => readLastModelError()?.supersededByUserTurn === true);
+
+ expect(await bridgeHandler!({
+ eventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: 'first',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: false, reason: 'superseded_by_newer_turn' });
+
+ session.queue.close();
+ await launchPromise;
+ });
+
+ it('cancels a pending bridge when a newer modelError supersedes it', async () => {
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ sendSessionEvent: ReturnType
+ };
+
+ let waitCount = 0;
+ const nextWait = { release: null as (() => void) | null };
+ const originalWait = session.queue.waitForMessagesAndGetAsString.bind(session.queue);
+ session.queue.waitForMessagesAndGetAsString = async (signal) => {
+ waitCount += 1;
+ if (waitCount >= 2 && nextWait.release === null) {
+ await new Promise((resolve) => {
+ nextWait.release = resolve;
+ });
+ }
+ return originalWait(signal);
+ };
+
+ session.queue.push('first', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(1));
+ await vi.waitFor(() => nextWait.release !== null);
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+
+ const staleEventId = '22222222-2222-4222-8222-222222222222';
+ expect(await bridgeHandler!({
+ eventId: staleEventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: 'first',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: true });
+ expect(session.queue.queue.some((item) => item.internal?.kind === 'model-error-bridge' && item.internal.eventId === staleEventId)).toBe(true);
+ expect(session.queue.pendingLocalIds().some((id) => id.startsWith('bridge:'))).toBe(false);
+
+ // Idle structural stderr supersedes the displayed error and drops the pending bridge.
+ expect(harness.stderrErrorHandler).toBeTypeOf('function');
+ harness.stderrErrorHandler!({
+ type: 'rate_limit',
+ message: 'Rate limit exceeded again.',
+ raw: 'status 429 ratelimitexceeded again'
+ });
+ await vi.waitFor(() => client.sendSessionEvent.mock.calls.some(
+ (call) => call[0]?.type === 'modelError'
+ ));
+
+ expect(session.queue.queue.some((item) => item.internal?.kind === 'model-error-bridge' && item.internal.eventId === staleEventId)).toBe(false);
+ expect(await bridgeHandler!({
+ eventId: staleEventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: 'first',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: false, reason: 'model_error_changed' });
+
+ session.queue.close();
+ nextWait.release?.();
+ await launchPromise;
+ });
+
+ it('does not dispatch Bridge session/prompt after a newer turn arrives during pre-send drain', async () => {
+ const session = makeSession(null, { keepQueueOpen: true });
+ const client = session.client as unknown as {
+ rpcHandlerManager: { registerHandler: ReturnType }
+ };
+
+ let waitCount = 0;
+ const nextWait = { release: null as (() => void) | null };
+ const originalWait = session.queue.waitForMessagesAndGetAsString.bind(session.queue);
+ session.queue.waitForMessagesAndGetAsString = async (signal) => {
+ waitCount += 1;
+ if (waitCount >= 2 && nextWait.release === null) {
+ await new Promise((resolve) => {
+ nextWait.release = resolve;
+ });
+ }
+ return originalWait(signal);
+ };
+
+ session.queue.push('first', { permissionMode: 'default' });
+ const launchPromise = cursorAcpRemoteLauncher(session);
+ await vi.waitFor(() => expect(harness.promptSends).toBe(1));
+ await vi.waitFor(() => nextWait.release !== null);
+
+ const bridgeHandler = client.rpcHandlerManager.registerHandler.mock.calls.find(
+ (call) => call[0] === RPC_METHODS.BridgeModelError
+ )?.[1] as ((payload: unknown) => Promise<{ ok: boolean; reason?: string }>) | undefined;
+
+ expect(await bridgeHandler!({
+ eventId: '33333333-3333-4333-8333-333333333333',
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ lastUserMessage: 'first',
+ priorAssistantClaimsDone: false
+ })).toEqual({ ok: true });
+
+ harness.deferBeforeSend = new Promise((resolve) => {
+ harness.releaseBeforeSend = resolve;
+ });
+ nextWait.release?.();
+ await vi.waitFor(() => expect(harness.promptCalls).toBe(2));
+
+ session.queue.push('correction instead of retry', { permissionMode: 'default' });
+ harness.releaseBeforeSend?.();
+
+ await vi.waitFor(() => expect(harness.promptSends).toBe(2));
+ const dispatched = JSON.stringify(harness.prompts);
+ expect(dispatched).not.toContain('[HAPI bridge');
+ expect(dispatched).toContain('correction instead of retry');
+
+ session.queue.close();
+ await launchPromise;
+ });
});
diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts
index 5130a17c6c..d961091582 100644
--- a/cli/src/cursor/cursorAcpRemoteLauncher.ts
+++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts
@@ -12,7 +12,7 @@ import {
} from '@/modules/common/remote/RemoteLauncherBase';
import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay';
import type { CursorSession } from './session';
-import type { PermissionMode } from './loop';
+import type { EnhancedMode, PermissionMode } from './loop';
import {
createCursorAcpBackend,
CURSOR_ACP_REQUIRED_MESSAGE,
@@ -51,6 +51,21 @@ import {
isRetryableCursorError,
stripRetryableCursorError
} from './cursorAutoRetry';
+import {
+ classifyAcpRpcRejection,
+ classifyCursorAgentMessage,
+ isCompletionClaim,
+ mapAcpStderrToFailure,
+ rawSnippetForFailure,
+ type CursorAgentStreamFailure
+} from './cursorAgentMessageClassifier';
+import {
+ buildModelErrorBridgePrompt,
+ canBridgeModelError,
+ mergeBridgeGateFields,
+ MAX_LAST_USER_MESSAGE_CHARS
+} from './cursorModelErrorBridge';
+import { getAutoBridgeTransientModelErrors } from './cursorModelErrorBridgePrefs';
const CURSOR_ABORT_DRAIN_TIMEOUT_MS = 5_000;
@@ -68,8 +83,6 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
private unregisterModelApplyHandler: (() => void) | null = null;
private modelApplySeq = 0;
private activePromptModeHash: string | null = null;
- /** True while a backend.prompt turn is in flight. */
- private promptInFlight = false;
/** Concurrent soft-steer session/prompt RPCs still running after kickoff. */
private softSteerWaiters: Promise[] = [];
/** True when ACP process was spawned with `--auto-review`. */
@@ -81,7 +94,52 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
private pendingRetryableFromStderr = false;
private pendingInlineRetryableError = false;
private attemptProducedToolActivity = false;
+ private lastAssistantText: string | null = null;
+ private turnHasModelError = false;
+ /**
+ * Text-classifier hit retained until `backend.prompt` settles. Callbacks
+ * run before the promise rejects, so recording text immediately would
+ * beat the structural RPC classification (e.g. unknown_t_prefix vs
+ * transport_closed for WritableIterable). Flush on success; prefer RPC
+ * in catch.
+ */
+ private pendingTextFailure: CursorAgentStreamFailure | null = null;
+ /**
+ * Typed stderr failure deferred while prompt is in flight so RPC rejection
+ * keeps precedence (RPC → stderr → text). Flushed on settle like text.
+ */
+ private pendingStderrFailure: CursorAgentStreamFailure | null = null;
+ /** True while backend.prompt() is in flight — lets stderr model_not_found
+ * surface as modelError during a turn without breaking setup/load remap. */
+ private promptInFlight = false;
+ /**
+ * Set in handleAbort before session/cancel. Cursor often rejects the
+ * in-flight prompt as `Error: T: [canceled] Operation aborted`, which the
+ * classifier maps to kind=canceled (real model cancel). Intent tracking
+ * keeps deliberate Abort/Exit/Switch out of the emergency model-error path.
+ */
private userAbortRequested = false;
+ private lastUserMessage: string | null = null;
+ private lastTurnMode: EnhancedMode | null = null;
+ /** Set only while the bridge prompt itself is the active turn. */
+ private bridgingForEventId: string | null = null;
+ private bridgingSource: 'auto' | 'manual' | null = null;
+ /** Enqueued but not yet started — must not attribute the current in-flight turn. */
+ private pendingBridgeEventId: string | null = null;
+ private pendingBridgeSource: 'auto' | 'manual' | null = null;
+ private lastRecordedModelError: {
+ eventId: string;
+ atTs: number;
+ kind: string;
+ rawSnippet: string;
+ priorAssistantClaimsDone: boolean;
+ lastUserMessage: string;
+ transient: boolean;
+ bridgedForEventId?: string;
+ retriedAndFailed?: boolean;
+ supersededByUserTurn?: boolean;
+ bridgeable?: boolean;
+ } | null = null;
constructor(session: CursorSession) {
super(process.env.DEBUG ? session.logPath : undefined);
@@ -423,6 +481,11 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
onSwitch: () => this.handleSwitchRequest()
});
+ session.client.rpcHandlerManager.registerHandler(
+ RPC_METHODS.BridgeModelError,
+ async (payload: unknown) => this.handleBridgeModelErrorRpc(payload)
+ );
+
// Soft steer = Cursor GUI "Send" (next-opportune / soft inject): fire a
// concurrent session/prompt without canceling the in-flight turn. Abort
// remains the hard stop path (GUI "Stop & send").
@@ -573,7 +636,16 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
}
);
+ // Restart / resume: hub metadata may still hold an unresolved error from
+ // the previous process. Hydrate before the queue loop so the first newer
+ // normal turn can durably set supersededByUserTurn.
+ this.hydrateLastRecordedModelErrorFromMetadata();
+
const sendReady = () => {
+ if (this.turnHasModelError) {
+ // Don't clear the error state with a 'ready' — banner stays visible.
+ return;
+ }
session.sendSessionEvent({ type: 'ready' });
};
@@ -589,6 +661,35 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
break;
}
+ // Activate bridge attribution only from queue-owned provenance —
+ // never from caller-controlled localId (which can forge `bridge:`).
+ const bridgeItem = batch.items.find(
+ (item) => item.internal?.kind === 'model-error-bridge'
+ );
+ if (bridgeItem?.internal?.kind === 'model-error-bridge') {
+ const eventId = bridgeItem.internal.eventId;
+ // Enqueue-time queue check can go stale: a normal turn may arrive
+ // after Bridge is already at the head. Drop Bridge and let the
+ // newer user intent run next (isolated batch already dequeued).
+ if (this.session.queue.hasPendingNonBridgeTurn()) {
+ if (this.pendingBridgeEventId === eventId) {
+ this.pendingBridgeEventId = null;
+ this.pendingBridgeSource = null;
+ }
+ this.markModelErrorSupersededByUserTurn();
+ continue;
+ }
+ this.bridgingForEventId = eventId;
+ this.bridgingSource = this.pendingBridgeSource ?? 'manual';
+ if (this.pendingBridgeEventId === eventId) {
+ this.pendingBridgeEventId = null;
+ this.pendingBridgeSource = null;
+ }
+ } else if (this.lastRecordedModelError && !this.lastRecordedModelError.supersededByUserTurn) {
+ // A newer normal turn owns the conversation — stale Bridge must not replay the failed prompt.
+ this.markModelErrorSupersededByUserTurn();
+ }
+
const requestedModel = batch.mode.model === null
? this.defaultBackendModel
: batch.mode.model;
@@ -610,6 +711,17 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
await applyCursorAcpMode(backend, acpSessionId, batch.mode.permissionMode as PermissionMode);
this.applyDisplayMode(batch.mode.permissionMode as PermissionMode);
+ this.lastUserMessage = batch.message;
+ this.lastTurnMode = batch.mode;
+
+ // applyLiveModel / applyCursorAcpMode can take time after dequeue.
+ if (this.bridgingForEventId !== null && this.session.queue.hasPendingNonBridgeTurn()) {
+ this.bridgingForEventId = null;
+ this.bridgingSource = null;
+ this.markModelErrorSupersededByUserTurn();
+ continue;
+ }
+
const specialCommand = parseCursorSpecialCommand(batch.message);
if (specialCommand.type === 'pass-through') {
messageBuffer.addMessage(cursorPassThroughStatusMessage(specialCommand.command), 'status');
@@ -624,13 +736,26 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
}];
session.onThinkingChange(true);
+ this.turnHasModelError = false;
+ this.userAbortRequested = false;
+ this.lastAssistantText = null;
+ this.pendingTextFailure = null;
+ this.pendingStderrFailure = null;
this.promptInFlight = true;
session.client.updateAgentState?.((state) => ({ ...state, steeringActive: true }));
this.activePromptModeHash = batch.hash;
-
try {
- this.promptInFlight = true;
- this.userAbortRequested = false;
+ const settleFailure = (error: unknown) => {
+ const rpcFailure = classifyAcpRpcRejection(error);
+ const genericRpcFailure = rpcFailure !== null && (
+ rpcFailure.kind === 'transport_closed'
+ || rpcFailure.kind === 'agent_crashed'
+ || rpcFailure.kind === 'prompt_failed'
+ );
+ return genericRpcFailure
+ ? this.pendingStderrFailure ?? rpcFailure ?? this.pendingTextFailure
+ : rpcFailure ?? this.pendingStderrFailure ?? this.pendingTextFailure;
+ };
for (let retryAttempt = 0; retryAttempt <= CURSOR_AUTO_RETRY_LIMIT; retryAttempt += 1) {
this.pendingRetryableError = null;
this.pendingRetryableFromStderr = false;
@@ -638,15 +763,30 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
this.attemptProducedToolActivity = false;
let turnCompleted = false;
try {
- await backend.prompt(acpSessionId, promptContent, (message) => {
+ const sent = await backend.prompt(acpSessionId, promptContent, (message) => {
if (message.type === 'turn_complete') turnCompleted = true;
this.handleAgentMessage(message);
+ }, {
+ shouldSend: () => !(this.bridgingForEventId !== null
+ && this.session.queue.hasPendingNonBridgeTurn())
});
+ if (sent === false) {
+ this.bridgingForEventId = null;
+ this.bridgingSource = null;
+ this.markModelErrorSupersededByUserTurn();
+ break;
+ }
if (this.userAbortRequested) break;
if (turnCompleted && this.pendingRetryableFromStderr && !this.pendingInlineRetryableError) {
this.pendingRetryableError = null;
}
if (!this.pendingRetryableError) {
+ const settled = this.pendingStderrFailure ?? this.pendingTextFailure;
+ if (settled && !this.turnHasModelError) {
+ this.recordModelError(settled);
+ }
+ this.pendingStderrFailure = null;
+ this.pendingTextFailure = null;
void backend.refreshSessionInfo(acpSessionId, session.path);
break;
}
@@ -655,6 +795,10 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
if (this.userAbortRequested) break;
if (!isRetryableCursorError(error)) {
this.surfacePromptFailure(error instanceof Error ? error.message : String(error));
+ const failure = settleFailure(error);
+ this.pendingStderrFailure = null;
+ this.pendingTextFailure = null;
+ if (failure) this.recordModelError(failure);
break;
}
this.pendingRetryableError = error instanceof Error ? error.message : String(error);
@@ -662,6 +806,12 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
if (this.attemptProducedToolActivity) {
this.surfacePromptFailure('Cursor connection interrupted after tool activity; the prompt was not retried.');
+ const failure = settleFailure(
+ this.pendingRetryableError ?? 'Cursor connection interrupted after tool activity'
+ );
+ this.pendingStderrFailure = null;
+ this.pendingTextFailure = null;
+ if (failure) this.recordModelError(failure);
break;
}
if (retryAttempt < CURSOR_AUTO_RETRY_LIMIT) {
@@ -669,6 +819,12 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
continue;
}
this.surfacePromptFailure(`Cursor Agent failed after ${CURSOR_AUTO_RETRY_LIMIT} retries.`);
+ const exhaustedFailure = settleFailure(
+ this.pendingRetryableError ?? 'Cursor Agent failed after retries'
+ );
+ this.pendingStderrFailure = null;
+ this.pendingTextFailure = null;
+ if (exhaustedFailure) this.recordModelError(exhaustedFailure);
}
} finally {
this.promptInFlight = false;
@@ -701,9 +857,22 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
this.pendingRetryableFromStderr = false;
this.pendingInlineRetryableError = false;
this.attemptProducedToolActivity = false;
+ this.pendingStderrFailure = null;
+ this.pendingTextFailure = null;
session.onThinkingChange(false);
await this.permissionAdapter?.cancelAll('Prompt finished');
await this.extensionAdapter?.cancelAll('Prompt finished');
+ if (
+ !this.userAbortRequested
+ && !this.turnHasModelError
+ && this.bridgingForEventId !== null
+ ) {
+ const eventId = this.bridgingForEventId;
+ const source = this.bridgingSource ?? 'manual';
+ this.markModelErrorBridgeSucceeded(eventId, source);
+ this.bridgingForEventId = null;
+ this.bridgingSource = null;
+ }
if (session.queue.size() === 0 && !this.shouldExit) {
sendReady();
}
@@ -724,6 +893,10 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
try {
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
+ this.session.client.rpcHandlerManager.registerHandler(
+ RPC_METHODS.BridgeModelError,
+ async () => ({ ok: false, reason: 'session_ended' })
+ );
this.session.client.rpcHandlerManager.registerHandler(RPC_METHODS.SteerQueuedMessage, async () => ({
steered: false,
error: 'Session ending'
@@ -776,7 +949,18 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
}
return;
}
+ // Setup/load remap consumes "Cannot use this model" stderr without
+ // promoting to modelError. During an active prompt, the same
+ // signature must become model_not_found (mapper would otherwise
+ // be unreachable behind this early return).
+ const failure = mapAcpStderrToFailure(error);
if (error.type === 'model_not_found' && extractCannotUseThisModelMessage(hint)) {
+ // Setup/load remap may reject a stale spawn model then succeed
+ // after remap — never promote that to a turn alert. Only defer
+ // during an active prompt so RPC precedence still wins.
+ if (this.promptInFlight && failure) {
+ this.pendingStderrFailure ??= failure;
+ }
return;
}
const converted = convertAgentMessage({ type: 'error', message: error.message });
@@ -784,6 +968,22 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
session.sendAgentMessage(converted);
}
messageBuffer.addMessage(error.message, 'status');
+ // STRUCTURAL signal: route typed stderr into the modelError pipeline
+ // (rate_limited / quota_exhausted / auth_failed / model_not_found)
+ // without text matching. Generic `unknown` stderr stays status-only —
+ // ACP treats stderr as logging, and the transport labels any
+ // "error"/"failed"/"exception" line as unknown.
+ // While prompt is in flight, defer so RPC rejection keeps precedence.
+ // Idle stderr can still surface a banner/notify, but must not be
+ // Bridgeable — stdout/stderr are independent, so a strong rate-limit
+ // line can arrive after a turn already succeeded.
+ if (failure) {
+ if (this.promptInFlight) {
+ this.pendingStderrFailure ??= failure;
+ } else {
+ this.recordModelError(failure, { bridgeable: false });
+ }
+ }
});
}
@@ -862,6 +1062,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
switch (message.type) {
case 'text':
this.messageBuffer.addMessage(message.text, 'assistant');
+ this.handleTextMessageClassification(message.text);
break;
case 'reasoning':
break;
@@ -906,6 +1107,364 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
this.messageBuffer.addMessage(message, 'status');
}
+ private handleTextMessageClassification(text: string): void {
+ // FALLBACK PATH ONLY — deferred until prompt settles so structural
+ // RPC / stderr can win. If a structural signal already classified
+ // this turn, keep lastAssistantText for priorAssistantClaimsDone.
+ if (this.turnHasModelError) {
+ this.lastAssistantText = text;
+ return;
+ }
+ const failure = classifyCursorAgentMessage(text);
+ if (failure) {
+ this.pendingTextFailure ??= failure;
+ } else {
+ this.lastAssistantText = text;
+ }
+ }
+
+ /**
+ * Single source of truth for emitting modelError. Structural paths
+ * (RPC catch / stderr) record immediately; text fallback is deferred
+ * via pendingTextFailure until prompt settles, then flushed here.
+ * First recorded signal wins for the turn.
+ */
+ private recordModelError(
+ failure: CursorAgentStreamFailure,
+ opts?: { bridgeable?: boolean }
+ ): void {
+ if (this.turnHasModelError) {
+ logger.debug(
+ `[cursor-acp] modelError already recorded for this turn, dropping ${failure.source}/${failure.kind}`
+ );
+ return;
+ }
+ // Abort/Exit/Switch can settle as canceled *or* transport/process-close
+ // shapes (ACP process exited, WritableIterable closed, …). Never promote
+ // those into lastModelError / notify / auto-bridge after a deliberate stop.
+ if (this.userAbortRequested) {
+ logger.debug(
+ `[cursor-acp] dropping modelError after user abort kind=${failure.kind}`
+ );
+ this.pendingTextFailure = null;
+ this.pendingStderrFailure = null;
+ return;
+ }
+ this.turnHasModelError = true;
+ this.pendingTextFailure = null;
+ this.pendingStderrFailure = null;
+
+ const bridgedFailure = this.bridgingForEventId !== null;
+ if (bridgedFailure) {
+ this.bridgingForEventId = null;
+ this.bridgingSource = null;
+ }
+
+ // A newer displayed error invalidates any not-yet-started bridge.
+ this.cancelPendingBridge();
+
+ // Same-message case: Cursor often appends `Error: T: ...` onto the
+ // assistant block that already claimed "Done." — lastAssistantText is
+ // still null because we classify before storing. Check failure.raw too.
+ const priorAssistantClaimsDone = (this.lastAssistantText !== null
+ && isCompletionClaim(this.lastAssistantText))
+ || (failure.source === 'text' && isCompletionClaim(failure.raw));
+ const rawSnippet = rawSnippetForFailure(failure);
+ const eventId = randomUUID();
+ const atTs = Date.now();
+ // Fail closed on silently truncated prompts — Bridge must replay exact text.
+ const fullMessage = this.lastUserMessage ?? '';
+ const fitsBridgeLimit = fullMessage.length <= MAX_LAST_USER_MESSAGE_CHARS;
+ const isPassThroughCommand = parseCursorSpecialCommand(fullMessage).type === 'pass-through';
+ const bridgeable = opts?.bridgeable !== false && fitsBridgeLimit && !isPassThroughCommand;
+ const lastUserMessage = bridgeable ? fullMessage : '';
+
+ logger.debug(
+ `[cursor-acp] modelError recorded source=${failure.source} kind=${failure.kind} transient=${failure.transient}${bridgedFailure ? ' (bridge failed)' : ''}${!bridgeable ? ' (not bridgeable)' : ''}`
+ );
+
+ this.lastRecordedModelError = {
+ eventId,
+ atTs,
+ kind: failure.kind,
+ transient: failure.transient,
+ rawSnippet,
+ priorAssistantClaimsDone,
+ lastUserMessage,
+ ...(bridgeable ? {} : { bridgeable: false }),
+ ...(bridgedFailure ? { retriedAndFailed: true } : {})
+ };
+
+ this.session.client.updateMetadata((metadata) => ({
+ ...metadata,
+ lastModelError: this.lastRecordedModelError!
+ }));
+
+ this.session.sendSessionEvent({
+ type: 'modelError',
+ kind: failure.kind,
+ transient: failure.transient,
+ rawSnippet,
+ priorAssistantClaimsDone
+ });
+
+ if (
+ !bridgedFailure
+ && bridgeable
+ && failure.transient
+ && getAutoBridgeTransientModelErrors()
+ ) {
+ this.tryEnqueueModelErrorBridge('auto');
+ }
+ }
+
+ private async handleBridgeModelErrorRpc(payload: unknown): Promise<{ ok: boolean; reason?: string }> {
+ if (!payload || typeof payload !== 'object') {
+ return this.tryEnqueueModelErrorBridge('manual');
+ }
+
+ const record = payload as Record;
+ const snapshot = {
+ eventId: typeof record.eventId === 'string' ? record.eventId : undefined,
+ atTs: typeof record.atTs === 'number' ? record.atTs : undefined,
+ kind: typeof record.kind === 'string' ? record.kind : undefined,
+ rawSnippet: typeof record.rawSnippet === 'string' ? record.rawSnippet : undefined,
+ lastUserMessage: typeof record.lastUserMessage === 'string' ? record.lastUserMessage : undefined,
+ priorAssistantClaimsDone: record.priorAssistantClaimsDone === true,
+ transient: typeof record.transient === 'boolean'
+ ? record.transient
+ : (this.lastRecordedModelError?.transient ?? false),
+ bridgedForEventId: typeof record.bridgedForEventId === 'string'
+ ? record.bridgedForEventId
+ : undefined,
+ retriedAndFailed: record.retriedAndFailed === true,
+ supersededByUserTurn: record.supersededByUserTurn === true,
+ bridgeable: record.bridgeable === false ? false : undefined
+ };
+
+ if (snapshot.eventId !== undefined) {
+ // Bind to the displayed error — refuse if a newer local error won.
+ if (
+ this.lastRecordedModelError
+ && this.lastRecordedModelError.eventId !== snapshot.eventId
+ ) {
+ return { ok: false, reason: 'model_error_changed' };
+ }
+ // Merge hub snapshot into local gate state — never clobber
+ // bridgedForEventId / retriedAndFailed / supersededByUserTurn /
+ // bridgeable=false with undefined/false from a stale hub payload.
+ const gates = mergeBridgeGateFields(this.lastRecordedModelError, {
+ bridgedForEventId: snapshot.bridgedForEventId,
+ retriedAndFailed: snapshot.retriedAndFailed,
+ supersededByUserTurn: snapshot.supersededByUserTurn,
+ bridgeable: snapshot.bridgeable
+ });
+ this.lastRecordedModelError = {
+ eventId: snapshot.eventId,
+ atTs: snapshot.atTs ?? this.lastRecordedModelError?.atTs ?? Date.now(),
+ kind: snapshot.kind ?? this.lastRecordedModelError?.kind ?? 'unknown',
+ rawSnippet: snapshot.rawSnippet ?? this.lastRecordedModelError?.rawSnippet ?? '',
+ priorAssistantClaimsDone: snapshot.priorAssistantClaimsDone,
+ lastUserMessage: snapshot.lastUserMessage
+ ?? this.lastRecordedModelError?.lastUserMessage
+ ?? this.lastUserMessage
+ ?? '',
+ transient: snapshot.transient,
+ bridgedForEventId: gates.bridgedForEventId,
+ retriedAndFailed: gates.retriedAndFailed,
+ supersededByUserTurn: gates.supersededByUserTurn,
+ bridgeable: gates.bridgeable
+ };
+ }
+
+ return this.tryEnqueueModelErrorBridge('manual');
+ }
+
+ private tryEnqueueModelErrorBridge(source: 'auto' | 'manual'): { ok: boolean; reason?: string } {
+ const metadataError = this.lastRecordedModelError;
+
+ if (!metadataError) {
+ return { ok: false, reason: 'no_model_error' };
+ }
+
+ // Manual Bridge during a newer in-flight turn would front-queue a stale
+ // retry that still runs if that turn succeeds (no superseding modelError).
+ // Auto-bridge may still fire while settling the failed turn itself.
+ if (source === 'manual' && this.promptInFlight) {
+ return { ok: false, reason: 'prompt_in_flight' };
+ }
+
+ if (metadataError.supersededByUserTurn) {
+ return { ok: false, reason: 'superseded_by_newer_turn' };
+ }
+
+ // Fail closed while a bridge for this eventId is pending or active.
+ if (
+ this.bridgingForEventId === metadataError.eventId
+ || this.pendingBridgeEventId === metadataError.eventId
+ ) {
+ return { ok: false, reason: 'not_bridgeable' };
+ }
+
+ const bridgeInput = {
+ kind: metadataError.kind,
+ rawSnippet: metadataError.rawSnippet,
+ priorAssistantClaimsDone: metadataError.priorAssistantClaimsDone,
+ lastUserMessage: metadataError.lastUserMessage ?? this.lastUserMessage ?? ''
+ };
+
+ if (!bridgeInput.lastUserMessage.trim()) {
+ return { ok: false, reason: 'missing_last_user_message' };
+ }
+
+ if (!canBridgeModelError({
+ transient: metadataError.transient,
+ eventId: metadataError.eventId,
+ bridgedForEventId: metadataError.bridgedForEventId,
+ retriedAndFailed: metadataError.retriedAndFailed,
+ supersededByUserTurn: metadataError.supersededByUserTurn,
+ bridgeable: metadataError.bridgeable
+ })) {
+ return { ok: false, reason: 'not_bridgeable' };
+ }
+
+ const prompt = buildModelErrorBridgePrompt({
+ kind: bridgeInput.kind,
+ rawSnippet: bridgeInput.rawSnippet,
+ lastUserMessage: bridgeInput.lastUserMessage,
+ priorAssistantClaimsDone: bridgeInput.priorAssistantClaimsDone
+ });
+
+ const bridgedEventId = metadataError.eventId;
+
+ // Never overtake newer user intent already waiting in the queue.
+ // supersededByUserTurn is only stamped when a normal batch starts; if we
+ // unshift Bridge ahead of that batch we replay the old prompt first.
+ if (this.session.queue.hasPendingNonBridgeTurn()) {
+ this.markModelErrorSupersededByUserTurn();
+ return { ok: false, reason: 'superseded_by_newer_turn' };
+ }
+
+ // Drop any stale pending bridge for a different event before enqueue.
+ if (this.pendingBridgeEventId && this.pendingBridgeEventId !== bridgedEventId) {
+ this.cancelPendingBridge();
+ }
+
+ // Attribution arms when the bridge batch starts, not at enqueue.
+ this.pendingBridgeEventId = bridgedEventId;
+ this.pendingBridgeSource = source;
+
+ const mode = this.lastTurnMode ?? {
+ permissionMode: this.session.getPermissionMode() as PermissionMode,
+ model: this.currentBackendModel ?? this.session.model ?? undefined
+ };
+
+ // Front of queue only when no newer user turn is waiting.
+ // Provenance is queue-owned (`internal`). Omit synthetic localId so
+ // dequeue cannot ACK a client prompt that reused `bridge:${eventId}`.
+ this.session.queue.unshiftIsolated(
+ prompt,
+ mode,
+ undefined,
+ { kind: 'model-error-bridge', eventId: bridgedEventId }
+ );
+ logger.debug(`[cursor-acp] modelError bridge enqueued for eventId=${bridgedEventId} source=${source}`);
+
+ return { ok: true };
+ }
+
+ /** Drop not-yet-started bridge queue entries and clear the retry gate. */
+ private cancelPendingBridge(): void {
+ const eventId = this.pendingBridgeEventId;
+ if (eventId) {
+ this.session.queue.cancelModelErrorBridge(eventId);
+ } else {
+ // Gate/queue desync: scrub any pending queue-owned bridge rows.
+ for (const item of this.session.queue.queue) {
+ if (item.internal?.kind === 'model-error-bridge') {
+ this.session.queue.cancelModelErrorBridge(item.internal.eventId);
+ }
+ }
+ }
+ this.pendingBridgeEventId = null;
+ this.pendingBridgeSource = null;
+ }
+
+ private markModelErrorBridgeSucceeded(eventId: string, source: 'auto' | 'manual'): void {
+ const current = this.lastRecordedModelError;
+ if (!current || current.eventId !== eventId) {
+ return;
+ }
+ this.lastRecordedModelError = {
+ ...current,
+ bridgedForEventId: eventId
+ };
+ this.session.client.updateMetadata((metadata) => {
+ const err = metadata.lastModelError;
+ if (!err || err.eventId !== eventId) {
+ return metadata;
+ }
+ return {
+ ...metadata,
+ lastModelError: {
+ ...err,
+ bridgedForEventId: eventId
+ }
+ };
+ });
+ // Chat-visible recovery marker only. Not an AGENT_NOTIFY_SUMMARY.
+ this.session.sendSessionEvent({
+ type: 'modelErrorBridged',
+ kind: current.kind,
+ auto: source === 'auto',
+ eventId
+ });
+ }
+
+ /** Durable invalidation after a newer normal (non-bridge) turn starts. */
+ private markModelErrorSupersededByUserTurn(): void {
+ const current = this.lastRecordedModelError;
+ if (!current || current.supersededByUserTurn) {
+ return;
+ }
+ this.lastRecordedModelError = {
+ ...current,
+ supersededByUserTurn: true
+ };
+ this.session.client.updateMetadata((metadata) => {
+ const err = metadata.lastModelError;
+ if (!err || err.eventId !== current.eventId) {
+ return metadata;
+ }
+ return {
+ ...metadata,
+ lastModelError: {
+ ...err,
+ supersededByUserTurn: true
+ }
+ };
+ });
+ }
+
+ /** Load durable lastModelError from hub metadata after CLI restart/resume. */
+ private hydrateLastRecordedModelErrorFromMetadata(): void {
+ if (this.lastRecordedModelError) {
+ return;
+ }
+ const getMetadata = this.session.client.getMetadata;
+ if (typeof getMetadata !== 'function') {
+ return;
+ }
+ const persistedError = getMetadata.call(this.session.client)?.lastModelError;
+ if (!persistedError || typeof persistedError.eventId !== 'string') {
+ return;
+ }
+ this.lastRecordedModelError = {
+ ...persistedError,
+ lastUserMessage: persistedError.lastUserMessage ?? ''
+ };
+ }
+
private installLiveSessionConfigSync(
backend: AcpSdkBackend,
acpSessionId: string,
@@ -1086,7 +1645,13 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
}
private async handleAbort(): Promise {
+ // Mark + clear bridge gates BEFORE any await. Otherwise a settling
+ // bridge prompt can race markModelErrorBridgeSucceeded and falsely
+ // persist bridgedForEventId / modelErrorBridged after the operator canceled.
this.userAbortRequested = true;
+ this.cancelPendingBridge();
+ this.bridgingForEventId = null;
+ this.bridgingSource = null;
const backend = this.backend;
const sessionId = this.acpSessionId ?? this.session.sessionId;
if (backend && sessionId) {
diff --git a/cli/src/cursor/cursorAgentMessageClassifier.test.ts b/cli/src/cursor/cursorAgentMessageClassifier.test.ts
new file mode 100644
index 0000000000..e3bcf7ee53
--- /dev/null
+++ b/cli/src/cursor/cursorAgentMessageClassifier.test.ts
@@ -0,0 +1,557 @@
+import { describe, it, expect } from 'vitest'
+import {
+ classifyAcpRpcRejection,
+ classifyCursorAgentMessage,
+ isCompletionClaim,
+ mapAcpStderrToFailure,
+ rawSnippetForFailure
+} from './cursorAgentMessageClassifier'
+
+describe('classifyCursorAgentMessage', () => {
+ it('classifies resource_exhausted', () => {
+ const result = classifyCursorAgentMessage('Error: T: [resource_exhausted] quota exceeded')
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('quota_exhausted')
+ expect(result?.transient).toBe(false)
+ })
+
+ it('classifies canceled', () => {
+ const result = classifyCursorAgentMessage('Error: T: [canceled] the request was cancelled')
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('canceled')
+ expect(result?.transient).toBe(true)
+ })
+
+ it('classifies deadline_exceeded', () => {
+ const result = classifyCursorAgentMessage('Error: T: [deadline_exceeded]')
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('deadline_exceeded')
+ expect(result?.transient).toBe(true)
+ })
+
+ it('classifies unavailable', () => {
+ const result = classifyCursorAgentMessage('Error: T: [unavailable] service is down')
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('unavailable')
+ expect(result?.transient).toBe(true)
+ })
+
+ it('classifies connection_stalled', () => {
+ const result = classifyCursorAgentMessage('Error: T: Connection stalled after 30s')
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('connection_stalled')
+ expect(result?.transient).toBe(true)
+ })
+
+ it('classifies context_window', () => {
+ const result = classifyCursorAgentMessage(
+ 'Gemini prompt failed: token count exceeds the model limit'
+ )
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('context_window')
+ expect(result?.transient).toBe(false)
+ })
+
+ it('classifies capacity_exhausted', () => {
+ const result = classifyCursorAgentMessage(
+ 'Gemini prompt failed: you have exhausted your capacity for today'
+ )
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('capacity_exhausted')
+ expect(result?.transient).toBe(false)
+ })
+
+ it('classifies unknown_t_prefix for unrecognised Error: T: variants', () => {
+ const result = classifyCursorAgentMessage('Error: T: [some_new_error] weird thing happened')
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('unknown_t_prefix')
+ expect(result?.transient).toBe(false)
+ })
+
+ it('preserves raw text', () => {
+ const raw = 'Error: T: [canceled] something something'
+ const result = classifyCursorAgentMessage(raw)
+ expect(result?.raw).toBe(raw)
+ })
+
+ it('returns null for benign messages', () => {
+ expect(classifyCursorAgentMessage("Here's the diff:")).toBeNull()
+ expect(classifyCursorAgentMessage('Done.')).toBeNull()
+ expect(classifyCursorAgentMessage('All done.')).toBeNull()
+ expect(classifyCursorAgentMessage('I found 3 files.')).toBeNull()
+ expect(classifyCursorAgentMessage('Successfully updated the config.')).toBeNull()
+ })
+
+ it('returns null for empty string', () => {
+ expect(classifyCursorAgentMessage('')).toBeNull()
+ })
+
+ it('is case-insensitive for Error: T: patterns', () => {
+ const result = classifyCursorAgentMessage('error: t: [resource_exhausted]')
+ expect(result?.kind).toBe('quota_exhausted')
+ })
+
+ it('does not match Error: T: patterns in the middle of text', () => {
+ // These patterns are anchored at the start
+ expect(classifyCursorAgentMessage('Partial text before Error: T: [canceled]')).toBeNull()
+ })
+
+ it('does not classify prose that describes the pattern', () => {
+ // Regression: 2026-06-12 self-own. The classifier matched an
+ // assistant message that *described* the patterns it looks for,
+ // because the original Gemini patterns used unanchored "contains".
+ // Real cursor-agent error emits come as the whole message body,
+ // not embedded in narrative prose. Anchored patterns reject prose.
+ const proseDescribingPatterns =
+ "Yes. In the soup since 23:51:24 BST.\n\n" +
+ "Triggers on:\n" +
+ " - Error: T: [resource_exhausted]\n" +
+ " - Error: T: Connection stalled\n" +
+ " - Gemini prompt failed: .*token count exceeds\n" +
+ " - Gemini prompt failed: .*exhausted your capacity\n"
+ expect(classifyCursorAgentMessage(proseDescribingPatterns)).toBeNull()
+
+ // Same idea, single line embedding the literal description.
+ expect(
+ classifyCursorAgentMessage(
+ 'The classifier looks for "Gemini prompt failed: .*token count exceeds" specifically.'
+ )
+ ).toBeNull()
+ })
+
+ it('ignores Error: T: / RetriableError examples inside markdown fences', () => {
+ const fencedQuota =
+ 'Here is what the wire looks like:\n' +
+ '```\n' +
+ 'Error: T: [resource_exhausted] capacity exceeded\n' +
+ '```\n' +
+ 'Do not treat that quote as a live failure.'
+ expect(classifyCursorAgentMessage(fencedQuota)).toBeNull()
+
+ const fencedRetriable =
+ 'Example:\n' +
+ '~~~\n' +
+ 'Error: RetriableError: Connection stalled after 30s\n' +
+ '~~~\n'
+ expect(classifyCursorAgentMessage(fencedRetriable)).toBeNull()
+
+ // Four-backtick outer fence quoting a triple-backtick example must
+ // not close early on the inner ``` and leak Error: T: into classify.
+ const nestedFence =
+ 'Docs sample:\n' +
+ '````markdown\n' +
+ '```\n' +
+ 'Error: T: [resource_exhausted] capacity exceeded\n' +
+ '```\n' +
+ '````\n'
+ expect(classifyCursorAgentMessage(nestedFence)).toBeNull()
+
+ // Unclosed fence + failure tail must still classify (truncated generation).
+ const truncatedFence =
+ '```ts\n' +
+ 'partial code\n' +
+ '\n' +
+ 'Error: T: [resource_exhausted] Error\n'
+ expect(classifyCursorAgentMessage(truncatedFence)?.kind).toBe('quota_exhausted')
+
+ // Same-length marker WITH info string is not a closer — keep fenced.
+ const fakeCloser =
+ '```\n' +
+ 'quoted example\n' +
+ '```ts\n' +
+ 'Error: T: [resource_exhausted] capacity exceeded\n' +
+ '```\n'
+ expect(classifyCursorAgentMessage(fakeCloser)).toBeNull()
+
+ // CRLF closers must still close (otherwise fenced Error: T: leaks).
+ const crlfFence =
+ '```\r\n' +
+ 'Error: T: [resource_exhausted] capacity exceeded\r\n' +
+ '```\r\n'
+ expect(classifyCursorAgentMessage(crlfFence)).toBeNull()
+
+ // CommonMark indented code (4+ spaces, or 0–3 spaces + tab) is not live.
+ expect(
+ classifyCursorAgentMessage(' Error: T: [resource_exhausted] capacity exceeded')
+ ).toBeNull()
+ expect(
+ classifyCursorAgentMessage('\tError: RetriableError: Connection stalled after 30s')
+ ).toBeNull()
+ expect(
+ classifyCursorAgentMessage(' \tError: T: [resource_exhausted] capacity exceeded')
+ ).toBeNull()
+ })
+
+ it('still classifies real Gemini errors when they ARE the message body', () => {
+ // Whitespace prefix is OK (trimStart handles it) but prose prefix is not.
+ expect(
+ classifyCursorAgentMessage(
+ ' Gemini prompt failed: token count exceeds limit'
+ )?.kind
+ ).toBe('context_window')
+ expect(
+ classifyCursorAgentMessage(
+ 'Gemini prompt failed: you have exhausted your capacity'
+ )?.kind
+ ).toBe('capacity_exhausted')
+ })
+
+ it('classifies Error: T: patterns with leading whitespace (real wire format)', () => {
+ // Regression: 2026-06-12 session b52b9117. Cursor ACP transport
+ // emitted "\n\nError: T: WritableIterable is closed" — leading
+ // newlines made the unanchored-tolerant `^Error: T:` regex miss
+ // it because JS `^` matches start of string, not start of line
+ // (without the `m` flag). trimStart() before the test fixes it.
+ const realWireFormat = '\n\nError: T: WritableIterable is closed'
+ const result = classifyCursorAgentMessage(realWireFormat)
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('unknown_t_prefix')
+ expect(result?.transient).toBe(false)
+ // Raw text preserved as-is (leading newlines included) so the
+ // banner can show the operator exactly what arrived.
+ expect(result?.raw).toBe(realWireFormat)
+ })
+
+ it('classifies leading-whitespace variants of all Error: T: kinds', () => {
+ expect(classifyCursorAgentMessage('\n\nError: T: [resource_exhausted]')?.kind).toBe('quota_exhausted')
+ expect(classifyCursorAgentMessage(' Error: T: [canceled]')?.kind).toBe('canceled')
+ // Tab-indented = CommonMark code (stripped). Light space pad is wire OK.
+ expect(classifyCursorAgentMessage(' Error: T: [deadline_exceeded]')?.kind).toBe('deadline_exceeded')
+ expect(classifyCursorAgentMessage('\n\n Error: T: [unavailable]')?.kind).toBe('unavailable')
+ expect(classifyCursorAgentMessage('\nError: T: Connection stalled after 30s')?.kind).toBe('connection_stalled')
+ })
+
+ it('classifies RetriableError prefix from cursor session (real session 0e04ebe7)', () => {
+ // Regression: 2026-06-20 session 0e04ebe7 ("teams structure").
+ // Cursor ACP session (metadata.flavor=cursor) but HAPI persists
+ // agent text in a codex-shaped envelope via convertAgentMessage —
+ // that is NOT the Codex runner. The inline error used RetriableError
+ // instead of Error: T: and was followed immediately by ready.
+ const realWireFormat = '\n\nError: RetriableError: [canceled] http/2 stream closed with error code CANCEL (0x8)'
+ const result = classifyCursorAgentMessage(realWireFormat)
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('canceled')
+ expect(result?.transient).toBe(true)
+ expect(result?.source).toBe('text')
+ })
+
+ it('marks RetriableError resource_exhausted as transient for auto-bridge', () => {
+ // Operator ask 2026-07-14: Cursor emits this exact wire string.
+ // It already classified as quota_exhausted, but with transient:false —
+ // auto-bridge only retries transient:true. RetriableError means the
+ // agent labeled the failure retriable; keep Error: T: / stderr quota
+ // paths non-transient.
+ const exactWire = 'Error: RetriableError: [resource_exhausted] Error'
+ const result = classifyCursorAgentMessage(exactWire)
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('quota_exhausted')
+ expect(result?.transient).toBe(true)
+ expect(result?.source).toBe('text')
+ expect(result?.raw).toBe(exactWire)
+ })
+
+ it('classifies error appended to in-flight agent text (real session e7d9b44b)', () => {
+ // Regression: 2026-06-13 session e7d9b44b. cursor-agent appended
+ // a gRPC stringification to the END of a normal narrative output
+ // rather than rejecting the prompt. The structural signals
+ // (RPC rejection / stderr) didn't fire because the agent never
+ // crashed - it just dumped the error into its own text stream.
+ // Start-of-string anchor missed it (text starts with prose);
+ // multiline `^` (start-of-line after the `\n\n`) catches it.
+ const realWireFormat =
+ "Three of the four hit Codex's usage limit (#151, #153, #155) " +
+ "- no code review delivered. Only #157 actually got reviewed. " +
+ "Let me pull the inline comments to see Codex's specific suggestions:\n\n" +
+ "Error: T: [resource_exhausted] Error"
+ const result = classifyCursorAgentMessage(realWireFormat)
+ expect(result).not.toBeNull()
+ expect(result?.kind).toBe('quota_exhausted')
+ expect(result?.transient).toBe(false)
+ })
+
+ it('still rejects bullet-listed pattern descriptions (no false positive)', () => {
+ // Lines indented with whitespace+dash do NOT start with "Error:" -
+ // multiline `^` requires the line to literally begin with the
+ // pattern. Self-own from 2026-06-12 stays prevented.
+ const proseDescribingPatterns =
+ "Yes. In the soup since 23:51:24 BST.\n\n" +
+ "Triggers on:\n" +
+ " - Error: T: [resource_exhausted]\n" +
+ " - Error: T: Connection stalled\n" +
+ " - Gemini prompt failed: .*token count exceeds\n" +
+ " - Gemini prompt failed: .*exhausted your capacity\n"
+ expect(classifyCursorAgentMessage(proseDescribingPatterns)).toBeNull()
+ })
+
+ it('catches all gRPC kinds when appended after prose+newlines', () => {
+ const prefix = "Working on the task. Got partial results before failure:\n\n"
+ expect(classifyCursorAgentMessage(prefix + 'Error: T: [resource_exhausted] Error')?.kind).toBe('quota_exhausted')
+ expect(classifyCursorAgentMessage(prefix + 'Error: T: [canceled] Operation aborted')?.kind).toBe('canceled')
+ expect(classifyCursorAgentMessage(prefix + 'Error: T: [deadline_exceeded]')?.kind).toBe('deadline_exceeded')
+ expect(classifyCursorAgentMessage(prefix + 'Error: T: [unavailable] Service down')?.kind).toBe('unavailable')
+ expect(classifyCursorAgentMessage(prefix + 'Error: T: Connection stalled')?.kind).toBe('connection_stalled')
+ expect(classifyCursorAgentMessage(prefix + 'Error: T: WritableIterable is closed')?.kind).toBe('unknown_t_prefix')
+ expect(classifyCursorAgentMessage(prefix + 'Gemini prompt failed: token count exceeds 1M')?.kind).toBe('context_window')
+ expect(classifyCursorAgentMessage(prefix + 'Gemini prompt failed: exhausted your capacity')?.kind).toBe('capacity_exhausted')
+ })
+
+ it("tags text-classifier results with source='text'", () => {
+ const result = classifyCursorAgentMessage('Error: T: [canceled]')
+ expect(result?.source).toBe('text')
+ })
+})
+
+describe('mapAcpStderrToFailure (structural stderr signal)', () => {
+ it('maps rate_limit -> rate_limited (transient)', () => {
+ const out = mapAcpStderrToFailure({ type: 'rate_limit', raw: 'status 429 ratelimitexceeded' })
+ expect(out).not.toBeNull()
+ expect(out!.kind).toBe('rate_limited')
+ expect(out!.transient).toBe(true)
+ expect(out!.source).toBe('stderr')
+ expect(out!.raw).toBe('status 429 ratelimitexceeded')
+ })
+
+ it('maps quota_exceeded -> quota_exhausted (non-transient)', () => {
+ const out = mapAcpStderrToFailure({ type: 'quota_exceeded', raw: 'resource exhausted' })
+ expect(out).not.toBeNull()
+ expect(out!.kind).toBe('quota_exhausted')
+ expect(out!.transient).toBe(false)
+ expect(out!.source).toBe('stderr')
+ })
+
+ it('maps authentication -> auth_failed (non-transient)', () => {
+ const out = mapAcpStderrToFailure({ type: 'authentication', raw: 'status 401 unauthenticated' })
+ expect(out).not.toBeNull()
+ expect(out!.kind).toBe('auth_failed')
+ expect(out!.transient).toBe(false)
+ expect(out!.source).toBe('stderr')
+ })
+
+ it('maps model_not_found -> model_not_found (non-transient)', () => {
+ const out = mapAcpStderrToFailure({ type: 'model_not_found', raw: 'status 404 model not found' })
+ expect(out).not.toBeNull()
+ expect(out!.kind).toBe('model_not_found')
+ expect(out!.transient).toBe(false)
+ })
+
+ it('maps Cursor Cannot use this model rejection', () => {
+ const out = mapAcpStderrToFailure({
+ type: 'model_not_found',
+ raw: 'Cannot use this model: grok-4.5[fast=true]. Available models: auto'
+ })
+ expect(out?.kind).toBe('model_not_found')
+ })
+
+ it('ignores unknown stderr (status-only; do not promote to modelError)', () => {
+ // Transport emits type:unknown for any stderr containing error/failed/
+ // exception — too broad for urgent model-error alerts.
+ const out = mapAcpStderrToFailure({ type: 'unknown', raw: 'unexpected exception in agent' })
+ expect(out).toBeNull()
+ })
+
+ it('ignores weak typed stderr that only matches transport substrings', () => {
+ expect(mapAcpStderrToFailure({
+ type: 'authentication',
+ raw: 'authentication provider initialized'
+ })).toBeNull()
+ expect(mapAcpStderrToFailure({
+ type: 'rate_limit',
+ raw: 'checking rate limit configuration'
+ })).toBeNull()
+ expect(mapAcpStderrToFailure({
+ type: 'quota_exceeded',
+ raw: 'quota tracker warmed up'
+ })).toBeNull()
+ expect(mapAcpStderrToFailure({
+ type: 'model_not_found',
+ raw: 'catalog refresh skipped for unknown models'
+ })).toBeNull()
+ expect(mapAcpStderrToFailure({
+ type: 'authentication',
+ raw: 'failed to read config: permission denied'
+ })).toBeNull()
+ expect(mapAcpStderrToFailure({
+ type: 'model_not_found',
+ raw: 'plugin cache entry not_found'
+ })).toBeNull()
+ })
+})
+
+describe('classifyAcpRpcRejection (structural RPC signal)', () => {
+ it('classifies WritableIterable is closed -> transport_closed (non-bridgeable)', () => {
+ // Real session b52b9117: the agent rejected sendRequest with this
+ // exact message after the writable side of the ACP transport died.
+ const err = new Error('WritableIterable is closed')
+ const out = classifyAcpRpcRejection(err)
+ expect(out).not.toBeNull()
+ expect(out?.kind).toBe('transport_closed')
+ expect(out?.transient).toBe(false)
+ expect(out?.source).toBe('rpc')
+ })
+
+ it('classifies ACP transport closed -> transport_closed (non-bridgeable)', () => {
+ const err = new Error('ACP transport is closed')
+ const out = classifyAcpRpcRejection(err)
+ expect(out?.kind).toBe('transport_closed')
+ expect(out?.transient).toBe(false)
+ })
+
+ it('classifies process exit -> transport_closed (rejectAllPending pathway)', () => {
+ // markClosed wraps the process-exit message in a new Error; pending
+ // sendRequests reject with that error.
+ const err = new Error('ACP process exited (code=137, signal=SIGKILL)')
+ const out = classifyAcpRpcRejection(err)
+ expect(out?.kind).toBe('transport_closed')
+ expect(out?.transient).toBe(false)
+ })
+
+ it('classifies spawn failure -> agent_crashed (non-bridgeable)', () => {
+ const err = new Error('Failed to spawn cursor-agent: ENOENT. Is it installed and on PATH?')
+ const out = classifyAcpRpcRejection(err)
+ expect(out?.kind).toBe('agent_crashed')
+ expect(out?.transient).toBe(false)
+ })
+
+ it('classifies request timeout -> rpc_timeout', () => {
+ const err = new Error("ACP request 'session/prompt' timed out after 120000ms")
+ const out = classifyAcpRpcRejection(err)
+ expect(out?.kind).toBe('rpc_timeout')
+ expect(out?.transient).toBe(true)
+ })
+
+ it('returns null for user cancellations (NOT model errors)', () => {
+ expect(classifyAcpRpcRejection(new Error('Aborted by user'))).toBeNull()
+ expect(classifyAcpRpcRejection(new Error('user cancelled the request'))).toBeNull()
+ expect(classifyAcpRpcRejection(new Error('user canceled the request'))).toBeNull()
+ })
+
+ it('passes through gRPC-status RPC errors with source=rpc', () => {
+ // Cursor-agent sometimes returns the gRPC status as a JSON-RPC
+ // error.message rather than emitting it as a text message.
+ const err = new Error('Error: T: [resource_exhausted] quota exceeded')
+ const out = classifyAcpRpcRejection(err)
+ expect(out?.kind).toBe('quota_exhausted')
+ expect(out?.transient).toBe(false)
+ // The structural source overrides text classification's source tag.
+ expect(out?.source).toBe('rpc')
+ })
+
+ it('classifies [canceled] Operation aborted via text path before user-abort filter', () => {
+ // Bare "aborted" must not null out real gRPC canceled shapes that
+ // happen to include that word in the status message.
+ const err = new Error('Error: T: [canceled] Operation aborted')
+ const out = classifyAcpRpcRejection(err)
+ expect(out).not.toBeNull()
+ expect(out?.kind).toBe('canceled')
+ expect(out?.source).toBe('rpc')
+ })
+
+ it('classifies plain Rate limit exceeded RPC messages as transient', () => {
+ const out = classifyAcpRpcRejection(new Error('Rate limit exceeded'))
+ expect(out?.kind).toBe('rate_limited')
+ expect(out?.transient).toBe(true)
+ expect(out?.source).toBe('rpc')
+ })
+
+ it('classifies status 429 RPC messages as transient', () => {
+ const out = classifyAcpRpcRejection(new Error('status 429 ratelimitexceeded'))
+ expect(out?.kind).toBe('rate_limited')
+ expect(out?.transient).toBe(true)
+ expect(out?.source).toBe('rpc')
+ })
+
+ it('falls through to prompt_failed for unrecognised RPC errors', () => {
+ const err = new Error('Some weird internal SDK assertion failed')
+ const out = classifyAcpRpcRejection(err)
+ expect(out?.kind).toBe('prompt_failed')
+ expect(out?.transient).toBe(false)
+ expect(out?.source).toBe('rpc')
+ })
+
+ it('handles non-Error rejection values', () => {
+ const out = classifyAcpRpcRejection('plain string rejection')
+ expect(out?.kind).toBe('prompt_failed')
+ expect(out?.raw).toBe('plain string rejection')
+ })
+})
+
+describe('isCompletionClaim', () => {
+ it('matches Done', () => expect(isCompletionClaim('Done.')).toBe(true))
+ it('matches All done', () => expect(isCompletionClaim('All done. The PR is filed.')).toBe(true))
+ it('matches Committed', () => expect(isCompletionClaim('Committed all changes.')).toBe(true))
+ it('matches Successfully completed', () => expect(isCompletionClaim('Successfully completed the migration.')).toBe(true))
+ it('matches Successfully fixed', () => expect(isCompletionClaim('Successfully fixed the bug.')).toBe(true))
+ it('matches Fixed', () => expect(isCompletionClaim('Fixed the bug.')).toBe(true))
+ it('matches Complete', () => expect(isCompletionClaim('Complete.')).toBe(true))
+ it('matches Completed', () => expect(isCompletionClaim('Completed.')).toBe(true))
+ it('is case-insensitive', () => {
+ expect(isCompletionClaim('DONE everything')).toBe(true)
+ expect(isCompletionClaim('all done')).toBe(true)
+ })
+ it('matches Done when Error: T is appended to the same block', () => {
+ // Cursor free-text path: completion claim + gRPC error in one message.
+ expect(isCompletionClaim(
+ 'Done.\n\nError: T: [canceled] Operation aborted'
+ )).toBe(true)
+ })
+ it('does not match non-completion phrases', () => {
+ expect(isCompletionClaim("Here's the plan")).toBe(false)
+ expect(isCompletionClaim("I'm working on it")).toBe(false)
+ })
+ it('does not match Completely… as a completion claim', () => {
+ expect(isCompletionClaim('Completely unable to finish the task.')).toBe(false)
+ })
+ it('does not match Successfully reproduced… as a completion claim', () => {
+ expect(isCompletionClaim('Successfully reproduced the issue, but the fix is pending.')).toBe(false)
+ })
+ it('handles empty string', () => {
+ expect(isCompletionClaim('')).toBe(false)
+ })
+})
+
+describe('rawSnippetForFailure', () => {
+ it('slices from Error: T marker when prose exceeds 400 chars', () => {
+ const prose = 'A'.repeat(450)
+ const failure = {
+ kind: 'canceled' as const,
+ transient: true,
+ raw: `${prose}\n\nError: T: [canceled] Operation aborted`,
+ source: 'text' as const
+ }
+ const snippet = rawSnippetForFailure(failure)
+ expect(snippet.startsWith('Error: T:')).toBe(true)
+ expect(snippet).toContain('[canceled]')
+ expect(snippet.length).toBeLessThanOrEqual(400)
+ })
+
+ it('keeps start-of-string for non-text sources', () => {
+ const failure = {
+ kind: 'rate_limited' as const,
+ transient: true,
+ raw: 'status 429 ratelimitexceeded',
+ source: 'stderr' as const
+ }
+ expect(rawSnippetForFailure(failure)).toBe('status 429 ratelimitexceeded')
+ })
+
+ it('prefers the last Error: T marker over an earlier fenced example', () => {
+ const filler = 'x'.repeat(450)
+ const raw = [
+ 'Here is an example of a failure:',
+ '```',
+ 'Error: T: [canceled] example only',
+ '```',
+ filler,
+ 'Error: T: [resource_exhausted] real failure'
+ ].join('\n')
+ const failure = {
+ kind: 'quota_exhausted' as const,
+ transient: true,
+ raw,
+ source: 'text' as const
+ }
+ const snippet = rawSnippetForFailure(failure)
+ expect(snippet.startsWith('Error: T: [resource_exhausted]')).toBe(true)
+ expect(snippet).not.toContain('example only')
+ })
+})
diff --git a/cli/src/cursor/cursorAgentMessageClassifier.ts b/cli/src/cursor/cursorAgentMessageClassifier.ts
new file mode 100644
index 0000000000..561dcadf63
--- /dev/null
+++ b/cli/src/cursor/cursorAgentMessageClassifier.ts
@@ -0,0 +1,390 @@
+/**
+ * Structural-first classifier for cursor-agent failure signals.
+ *
+ * Sources, in priority order:
+ * 1. JSON-RPC rejection on session/prompt (structural: thrown Error from
+ * `@zed-industries/agent-client-protocol` SDK or transport close).
+ * 2. Stderr lines parsed by AcpStdioTransport into typed AcpStderrError.
+ * 3. Text in agent/message events (last-resort fallback, brittle by nature
+ * because cursor-agent stringifies internal errors as plain prose).
+ *
+ * The text fallback (1)-(3) only fires when no structural signal already
+ * classified the turn. Each source carries a `source` tag so the operator
+ * (and tests) can tell where the classification came from.
+ */
+export type CursorAgentStreamFailureKind =
+ // --- text/agent-message classifier kinds (legacy, fallback) ---
+ | 'quota_exhausted'
+ | 'canceled'
+ | 'deadline_exceeded'
+ | 'unavailable'
+ | 'connection_stalled'
+ | 'context_window'
+ | 'capacity_exhausted'
+ | 'unknown_t_prefix'
+ // --- structural kinds ---
+ | 'transport_closed' // ACP transport closed mid-turn (WritableIterable, etc.)
+ | 'rpc_timeout' // sendRequest timeout
+ | 'rpc_error' // JSON-RPC error response from the agent
+ | 'agent_crashed' // process exit during in-flight prompt
+ | 'rate_limited' // stderr-derived rate limit
+ | 'auth_failed' // stderr-derived authentication failure
+ | 'model_not_found' // stderr-derived model-not-found
+ | 'unknown_stderr' // stderr line classified as error but not typed
+ | 'prompt_failed' // catch-all for prompt rejections
+
+export type CursorAgentStreamFailureSource = 'rpc' | 'stderr' | 'text'
+
+export type CursorAgentStreamFailure = {
+ kind: CursorAgentStreamFailureKind
+ transient: boolean
+ raw: string
+ source: CursorAgentStreamFailureSource
+}
+
+type Pattern = {
+ test: (text: string) => boolean
+ kind: CursorAgentStreamFailureKind
+ transient: boolean
+}
+
+// Patterns are anchored to the start of A LINE (multiline `m` flag), not
+// just start-of-string. Three real cursor-agent failure shapes drove this:
+//
+// 1. Whole-body emit (session b52b9117, 2026-06-12):
+// "\n\nError: T: WritableIterable is closed"
+// -- start of message, after leading whitespace.
+//
+// 2. Mid-stream append (session e7d9b44b, 2026-06-13):
+// "Three of the four hit Codex's usage limit (#151, #153, #155) -
+// no code review delivered. Only #157 actually got reviewed.
+// Let me pull the inline comments to see Codex's specific
+// suggestions:\n\nError: T: [resource_exhausted] Error"
+// -- cursor-agent appended the gRPC status to the END of an in-flight
+// text stream rather than rejecting the prompt. Start-of-line `m`
+// anchor catches this: the `\n\n` separator means `Error: T:` is at
+// the start of a new line. Pure start-of-string anchoring missed it.
+//
+// 3. Prose that DESCRIBES the patterns (2026-06-12 self-own):
+// "Triggers on:\n - Error: T: [resource_exhausted]\n - ..."
+// -- each bullet line starts with whitespace+dash, NOT with "Error:".
+// Multiline `^Error:` rejects it.
+//
+// 4. RetriableError prefix (session 0e04ebe7, 2026-06-20):
+// "\n\nError: RetriableError: [canceled] http/2 stream closed..."
+// -- same gRPC bracket notation as `Error: T:` but cursor-agent
+// sometimes stringifies via RetriableError instead. Still a Cursor
+// ACP session; HAPI persists agent messages in a codex-shaped
+// envelope (`convertAgentMessage`) which is NOT the Codex runner.
+//
+// The diagnostic strength is in the `[snake_case]` bracket form (gRPC's
+// stable status notation) and `Error: T:` prefix - both characteristic of
+// cursor-agent's runtime stringification, rare in genuine prose. The
+// catch-all `Error: T:` is intentionally narrow enough that benign mention
+// would have to literally start a line with "Error: T:" - documented
+// trade-off, the false-positive surface is small and recoverable
+// (operator dismisses banner). The miss surface (this exact failure
+// class going unflagged) is much worse.
+// `^[ \t]*` allows horizontal whitespace before the marker (covers
+// session b52b9117's " Error: T: [canceled]" wire format with leading
+// spaces). It does NOT allow `\n` consumption, so multi-line strings
+// only match where the marker actually sits at the start of a line.
+// Bullet-list prose like " - Error: T: ..." still rejects: after
+// `[ \t]*` consumes the spaces, the next char is `-`, not `Error`.
+const PATTERNS: Pattern[] = [
+ // RetriableError resource_exhausted is auto-bridgeable: cursor-agent
+ // labeled the failure RetriableError, so mark transient:true. Keep the
+ // `Error: T:` form (and stderr quota_exceeded mapping) non-transient —
+ // those are harder quota / capacity stops, not hiccups.
+ {
+ test: (t) => /^[ \t]*Error: RetriableError: \[resource_exhausted\]/im.test(t),
+ kind: 'quota_exhausted',
+ transient: true
+ },
+ {
+ test: (t) => /^[ \t]*Error: T: \[resource_exhausted\]/im.test(t),
+ kind: 'quota_exhausted',
+ transient: false
+ },
+ {
+ test: (t) => /^[ \t]*Error: T: \[canceled\]/im.test(t)
+ || /^[ \t]*Error: RetriableError: \[canceled\]/im.test(t),
+ kind: 'canceled',
+ transient: true
+ },
+ {
+ test: (t) => /^[ \t]*Error: T: \[deadline_exceeded\]/im.test(t)
+ || /^[ \t]*Error: RetriableError: \[deadline_exceeded\]/im.test(t),
+ kind: 'deadline_exceeded',
+ transient: true
+ },
+ {
+ test: (t) => /^[ \t]*Error: T: \[unavailable\]/im.test(t)
+ || /^[ \t]*Error: RetriableError: \[unavailable\]/im.test(t),
+ kind: 'unavailable',
+ transient: true
+ },
+ {
+ test: (t) => /^[ \t]*Error: T: Connection stalled/im.test(t)
+ || /^[ \t]*Error: RetriableError: Connection stalled/im.test(t),
+ kind: 'connection_stalled',
+ transient: true
+ },
+ {
+ test: (t) => /^[ \t]*Gemini prompt failed:.*token count exceeds/im.test(t),
+ kind: 'context_window',
+ transient: false
+ },
+ {
+ test: (t) => /^[ \t]*Gemini prompt failed:.*exhausted your capacity/im.test(t),
+ kind: 'capacity_exhausted',
+ transient: false
+ },
+ // catch-all for unknown `Error: T:` / `Error: RetriableError:` prefixes — placed last
+ {
+ test: (t) => /^[ \t]*Error: T:/im.test(t)
+ || /^[ \t]*Error: RetriableError:/im.test(t),
+ kind: 'unknown_t_prefix',
+ transient: false
+ }
+]
+
+/**
+ * Returns a failure descriptor when the message text matches a known
+ * cursor-agent inline model error pattern, or null for benign messages.
+ *
+ * NOTE: this is the LAST-RESORT fallback. Prefer structural signals
+ * (classifyAcpRpcRejection, mapAcpStderrToFailure) — they fire before
+ * this path runs and are not subject to false positives from prose that
+ * happens to match the pattern shape.
+ */
+/** Drop closed markdown fenced regions so quoted Error: T: examples are not
+ * classified. Tracks opener char + length (four-backtick outer ≠ closed by
+ * inner ```). Unclosed fences are restored at EOF so a truncated generation
+ * that ends with Error: T: is still classifiable. */
+function stripMarkdownFences(text: string): string {
+ const visible: string[] = []
+ let fence: { char: '`' | '~'; length: number; buffered: string[] } | null = null
+ for (const line of text.split(/\r?\n/)) {
+ const match = line.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
+ if (!match) {
+ if (fence) {
+ fence.buffered.push(line)
+ } else {
+ visible.push(line)
+ }
+ continue
+ }
+ const marker = match[1]!
+ const char = marker[0]! as '`' | '~'
+ if (!fence) {
+ fence = { char, length: marker.length, buffered: [line] }
+ continue
+ }
+ fence.buffered.push(line)
+ // Closers are bare markers only (no info string like ```ts).
+ const close = line.match(/^[ \t]{0,3}(`{3,}|~{3,})[ \t]*$/)
+ if (
+ close
+ && close[1]![0] === fence.char
+ && close[1]!.length >= fence.length
+ ) {
+ fence = null
+ }
+ }
+ if (fence) {
+ visible.push(...fence.buffered)
+ }
+ return visible.join('\n')
+}
+
+/** Drop CommonMark indented-code lines so quoted Error: T: examples are not
+ * treated as live wire failures. Tab stops of 4: 4+ spaces, or 0–3 spaces
+ * then a tab. Keep bare 1–3 space prefixes — real ACP emits sometimes pad. */
+function stripIndentedCode(text: string): string {
+ return text
+ .split(/\r?\n/)
+ .filter((line) => !/^(?: {4,}| {0,3}\t)/.test(line))
+ .join('\n')
+}
+
+export function classifyCursorAgentMessage(text: string): CursorAgentStreamFailure | null {
+ const candidate = stripIndentedCode(stripMarkdownFences(text))
+ for (const pattern of PATTERNS) {
+ if (pattern.test(candidate)) {
+ return { kind: pattern.kind, transient: pattern.transient, raw: text, source: 'text' }
+ }
+ }
+ return null
+}
+
+/**
+ * Strong failure signatures required before promoting a typed ACP stderr
+ * event to modelError. The transport assigns rate_limit / authentication /
+ * quota_exceeded from bare substrings ("rate limit", "authentication",
+ * "quota"), so a line like "authentication provider initialized" becomes
+ * type authentication. We re-check raw text here.
+ */
+const STRONG_STDERR_PATTERNS = {
+ rate_limit: /status 429|ratelimitexceeded|rate limit (?:exceeded|reached|hit)/i,
+ model_not_found: /Cannot use this model:\s*\S|status 404[^\n]*\bmodel\b|model (?:is )?not found/i,
+ authentication: /status (?:401|403)|\bunauthenticated\b|authentication (?:failed|required|expired)|(?:token|credential)[^\n]{0,80}permission denied/i,
+ quota_exceeded: /quota (?:exceeded|exhausted|limit reached)|resource ?exhausted/i
+} as const
+
+/**
+ * Maps an AcpStderrError (typed by AcpStdioTransport.parseStderrError) to
+ * a CursorAgentStreamFailure when the raw text also carries a strong
+ * failure signature. Accepts a minimal AcpStderrError shape so this module
+ * stays decoupled from the transport package.
+ *
+ * Returns null for:
+ * - `unknown` (transport labels any "error"/"failed"/"exception" line)
+ * - typed events whose raw text is only a weak substring match
+ *
+ * ACP treats stderr as logging (`Clients MAY capture, forward, or ignore`);
+ * urgent model-error alerts need a definitive failure signature.
+ */
+export function mapAcpStderrToFailure(error: {
+ type: 'rate_limit' | 'model_not_found' | 'authentication' | 'quota_exceeded' | 'unknown'
+ raw: string
+}): CursorAgentStreamFailure | null {
+ if (error.type === 'unknown') {
+ return null
+ }
+
+ if (!STRONG_STDERR_PATTERNS[error.type].test(error.raw)) {
+ return null
+ }
+
+ switch (error.type) {
+ case 'rate_limit':
+ return { kind: 'rate_limited', transient: true, raw: error.raw, source: 'stderr' }
+ case 'model_not_found':
+ return { kind: 'model_not_found', transient: false, raw: error.raw, source: 'stderr' }
+ case 'authentication':
+ return { kind: 'auth_failed', transient: false, raw: error.raw, source: 'stderr' }
+ case 'quota_exceeded':
+ return { kind: 'quota_exhausted', transient: false, raw: error.raw, source: 'stderr' }
+ default:
+ return null
+ }
+}
+
+/**
+ * Inspects an Error thrown by `backend.prompt(...)` (i.e. the JSON-RPC
+ * `session/prompt` call) and classifies it.
+ *
+ * The error sources are STRUCTURAL: thrown by either the
+ * `@zed-industries/agent-client-protocol` SDK on transport close, or by
+ * `AcpStdioTransport.markClosed` -> `rejectAllPending` on process exit /
+ * stream close, or by the JSON-RPC layer on `error.message` from a typed
+ * agent-side error response. We do match against `error.message` strings
+ * here — but those strings are emitted by code we control or vendor, not
+ * by free-text agent prose. The blast radius for false positives is the
+ * library's stable error vocabulary, which is much smaller than "any
+ * message a cursor-agent might emit."
+ *
+ * Returns null if the error doesn't look like a model-side failure
+ * (e.g. user cancellation, programmer error in our own code) — the
+ * caller should still log/surface the error but not fire modelError.
+ */
+export function classifyAcpRpcRejection(error: unknown): CursorAgentStreamFailure | null {
+ const raw = error instanceof Error ? error.message : String(error)
+ const lower = raw.toLowerCase()
+
+ // gRPC / agent text shapes can arrive as JSON-RPC error.message (including
+ // "... [canceled] Operation aborted"). Classify those BEFORE the user-abort
+ // filter — a bare `aborted` substring must not swallow real model cancels.
+ const textMatch = classifyCursorAgentMessage(raw)
+ if (textMatch) {
+ return { ...textMatch, source: 'rpc' }
+ }
+
+ // Programmer/user signals that should NOT fire modelError.
+ if (
+ lower.includes('aborted by user')
+ || lower.includes('user cancelled')
+ || lower.includes('user canceled')
+ ) {
+ return null
+ }
+
+ // Transport-level closure: WritableIterable closed, ACP transport closed,
+ // or process exited. All come through markClosed() -> rejectAllPending().
+ // Not bridgeable: Bridge reuses the same backend.prompt path, and a closed
+ // AcpStdioTransport rejects every subsequent sendRequest until reconnect.
+ if (
+ lower.includes('writableiterable is closed') ||
+ lower.includes('acp transport is closed') ||
+ lower.includes('acp transport closed') ||
+ lower.includes('acp process exited')
+ ) {
+ return { kind: 'transport_closed', transient: false, raw, source: 'rpc' }
+ }
+
+ // Process error during spawn / IO failure during write.
+ // Same as transport_closed: no reconnect/reload yet, so Bridge cannot recover.
+ if (lower.includes('failed to spawn') || lower.includes('epipe') || lower.includes('ecanceled')) {
+ return { kind: 'agent_crashed', transient: false, raw, source: 'rpc' }
+ }
+
+ // Request-level timeout (DEFAULT_TIMEOUT_MS in AcpStdioTransport).
+ if (lower.includes('timed out after') || lower.includes('timeout')) {
+ return { kind: 'rpc_timeout', transient: true, raw, source: 'rpc' }
+ }
+
+ // Plain JSON-RPC error.message (no [rate_limit] brackets). Same signatures
+ // as stderr promotion so Bridge can retry structural 429s.
+ if (STRONG_STDERR_PATTERNS.rate_limit.test(raw)) {
+ return { kind: 'rate_limited', transient: true, raw, source: 'rpc' }
+ }
+
+ // Catch-all: the prompt rejected for SOME reason. Conservative:
+ // fire modelError as 'prompt_failed' (non-transient) so the operator
+ // sees the turn was degraded.
+ return { kind: 'prompt_failed', transient: false, raw, source: 'rpc' }
+}
+
+const PRIOR_DONE_PATTERNS = [
+ /^done(?:[.!:]|\s|$)/,
+ /^all done(?:[.!:]|\s|$)/,
+ /^committed(?:[.!:]|\s|$)/,
+ /^fixed(?:[.!:]|\s|$)/,
+ /^complete(?:d)?(?:[.!:]|\s|$)/,
+ /^successfully\s+(?:completed|fixed|committed)\b/
+]
+
+/**
+ * Returns true when the text looks like the agent claimed task completion
+ * (e.g. "Done.", "All done.", "Successfully completed.").
+ * Word-boundary patterns — not startsWith — so "Completely unable…" and
+ * "Successfully reproduced…" do not falsely set priorAssistantClaimsDone.
+ */
+export function isCompletionClaim(text: string): boolean {
+ const lower = text.trim().toLowerCase()
+ return PRIOR_DONE_PATTERNS.some((pattern) => pattern.test(lower))
+}
+
+const TEXT_ERROR_MARKER = /^[ \t]*(?:Error: (?:T|RetriableError):|Gemini prompt failed:)/gim
+
+/**
+ * Prefer the matched error marker when slicing rawSnippet so "View raw error"
+ * shows the failure, not the preceding assistant prose when Error: T: is
+ * appended after a long response.
+ *
+ * Use the LAST marker: classifyCursorAgentMessage strips closed fences /
+ * indented code before matching, so an earlier quoted example in raw text
+ * must not steal the snippet from the real appended failure.
+ */
+export function rawSnippetForFailure(failure: CursorAgentStreamFailure, maxLen = 400): string {
+ let start = 0
+ if (failure.source === 'text') {
+ const matches = Array.from(failure.raw.matchAll(TEXT_ERROR_MARKER))
+ const last = matches.at(-1)
+ if (typeof last?.index === 'number') {
+ start = last.index
+ }
+ }
+ return failure.raw.slice(start, start + maxLen)
+}
diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts
index a273423f3d..0abe95d31b 100644
--- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts
+++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts
@@ -20,6 +20,16 @@ import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
import type { CursorStreamEvent } from './utils/cursorLegacyEventConverter';
import { parseCursorEvent, convertCursorEventToAgentMessage } from './utils/cursorLegacyEventConverter';
import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands';
+// NOTE: model-error detection (cursorAgentMessageClassifier) is intentionally
+// NOT wired into the legacy launcher. The hub auto-migrates legacy stream-json
+// sessions to ACP at resume time via maybeAutoMigrateLegacyCursorSession (PR
+// #844). The legacy launcher is reached only when migration soft-fails — a
+// degraded fallback path, not a supported flow. Carrying duplicate
+// model-error logic here would (a) double the surface for bugs in the
+// model-error contract, (b) imply legacy is a real path that needs feature
+// parity, and (c) be dead code in practice. New cursor sessions are ACP from
+// inception. If you find yourself wanting model-error detection on this
+// path, fix the migration failure case instead.
// Transient `agent` failures (auth expiry, rate limits, transient network) come back
// as exit code 1 with a recognisable stderr signature. We requeue and retry instead
diff --git a/cli/src/cursor/cursorModelErrorBridge.test.ts b/cli/src/cursor/cursorModelErrorBridge.test.ts
new file mode 100644
index 0000000000..264a6593e4
--- /dev/null
+++ b/cli/src/cursor/cursorModelErrorBridge.test.ts
@@ -0,0 +1,149 @@
+import { describe, expect, it } from 'vitest';
+import {
+ buildModelErrorBridgePrompt,
+ canBridgeModelError,
+ mergeBridgeGateFields,
+ MODEL_ERROR_BRIDGE_HEADER,
+ MAX_LAST_USER_MESSAGE_CHARS,
+ truncateLastUserMessage
+} from './cursorModelErrorBridge';
+
+describe('buildModelErrorBridgePrompt', () => {
+ it('wraps the last user message with bridge context', () => {
+ const prompt = buildModelErrorBridgePrompt({
+ kind: 'transport_closed',
+ rawSnippet: 'WritableIterable is closed',
+ lastUserMessage: 'Fix the login bug in auth.ts',
+ priorAssistantClaimsDone: false
+ });
+
+ expect(prompt).toContain(MODEL_ERROR_BRIDGE_HEADER);
+ expect(prompt).toContain('transport_closed: WritableIterable is closed');
+ expect(prompt).toContain('Re-sending your last message below.');
+ expect(prompt).toContain('---');
+ expect(prompt.endsWith('Fix the login bug in auth.ts')).toBe(true);
+ expect(prompt).not.toContain('verify what is actually done');
+ });
+
+ it('adds completion verification when priorAssistantClaimsDone is true', () => {
+ const prompt = buildModelErrorBridgePrompt({
+ kind: 'rpc_timeout',
+ rawSnippet: 'request timed out after 30000ms',
+ lastUserMessage: 'ship the feature',
+ priorAssistantClaimsDone: true
+ });
+
+ expect(prompt).toContain('verify what is actually done before proceeding.');
+ });
+});
+
+describe('canBridgeModelError', () => {
+ const base = {
+ transient: true,
+ eventId: 'evt-1000'
+ };
+
+ it('allows transient errors that have not been bridged', () => {
+ expect(canBridgeModelError(base)).toBe(true);
+ });
+
+ it('blocks non-transient errors', () => {
+ expect(canBridgeModelError({ ...base, transient: false })).toBe(false);
+ });
+
+ it('blocks quota and auth style failures via transient=false', () => {
+ expect(canBridgeModelError({
+ ...base,
+ transient: false
+ })).toBe(false);
+ });
+
+ it('dedupes when this eventId was already bridged', () => {
+ expect(canBridgeModelError({
+ ...base,
+ bridgedForEventId: 'evt-1000'
+ })).toBe(false);
+ });
+
+ it('allows a new error after a prior bridge on a different eventId', () => {
+ expect(canBridgeModelError({
+ ...base,
+ eventId: 'evt-2000',
+ bridgedForEventId: 'evt-1000'
+ })).toBe(true);
+ });
+
+ it('blocks when bridge already failed', () => {
+ expect(canBridgeModelError({
+ ...base,
+ retriedAndFailed: true
+ })).toBe(false);
+ });
+
+ it('blocks when a newer normal turn superseded the error', () => {
+ expect(canBridgeModelError({
+ ...base,
+ supersededByUserTurn: true
+ })).toBe(false);
+ });
+
+ it('blocks when bridgeable is explicitly false', () => {
+ expect(canBridgeModelError({
+ ...base,
+ bridgeable: false
+ })).toBe(false);
+ });
+});
+
+describe('mergeBridgeGateFields', () => {
+ it('keeps prior bridgedForEventId when hub snapshot omits it', () => {
+ const merged = mergeBridgeGateFields(
+ { bridgedForEventId: 'evt-1000', retriedAndFailed: false },
+ { bridgedForEventId: undefined, retriedAndFailed: false }
+ );
+ expect(merged.bridgedForEventId).toBe('evt-1000');
+ expect(canBridgeModelError({
+ transient: true,
+ eventId: 'evt-1000',
+ ...merged
+ })).toBe(false);
+ });
+
+ it('preserves retriedAndFailed when hub sends false', () => {
+ const merged = mergeBridgeGateFields(
+ { bridgedForEventId: 'evt-1000', retriedAndFailed: true },
+ { bridgedForEventId: undefined, retriedAndFailed: false }
+ );
+ expect(merged.retriedAndFailed).toBe(true);
+ });
+
+ it('preserves supersededByUserTurn when hub omits it', () => {
+ const merged = mergeBridgeGateFields(
+ { supersededByUserTurn: true },
+ { bridgedForEventId: undefined, retriedAndFailed: false, supersededByUserTurn: undefined }
+ );
+ expect(merged.supersededByUserTurn).toBe(true);
+ expect(canBridgeModelError({
+ transient: true,
+ eventId: 'evt-1000',
+ ...merged
+ })).toBe(false);
+ });
+});
+
+describe('truncateLastUserMessage', () => {
+ it('passes through short messages unchanged', () => {
+ expect(truncateLastUserMessage('hello')).toBe('hello');
+ });
+
+ it('caps very long messages', () => {
+ const long = 'x'.repeat(40_000);
+ expect(truncateLastUserMessage(long).length).toBe(MAX_LAST_USER_MESSAGE_CHARS);
+ });
+});
+
+describe('MAX_LAST_USER_MESSAGE_CHARS', () => {
+ it('is the bridge fail-closed limit', () => {
+ expect(MAX_LAST_USER_MESSAGE_CHARS).toBe(32_000);
+ });
+});
diff --git a/cli/src/cursor/cursorModelErrorBridge.ts b/cli/src/cursor/cursorModelErrorBridge.ts
new file mode 100644
index 0000000000..1c75af08be
--- /dev/null
+++ b/cli/src/cursor/cursorModelErrorBridge.ts
@@ -0,0 +1,91 @@
+export const MODEL_ERROR_BRIDGE_HEADER = '[HAPI bridge — transient model error]';
+
+export const MAX_LAST_USER_MESSAGE_CHARS = 32_000;
+const MAX_EXCERPT_CHARS = 120;
+
+export type ModelErrorBridgeInput = {
+ kind: string;
+ rawSnippet: string;
+ lastUserMessage: string;
+ priorAssistantClaimsDone: boolean;
+};
+
+export type ModelErrorBridgeGate = {
+ transient: boolean;
+ eventId: string;
+ bridgedForEventId?: string;
+ retriedAndFailed?: boolean;
+ supersededByUserTurn?: boolean;
+ /** Explicit false blocks Bridge (e.g. idle stderr after a successful turn). */
+ bridgeable?: boolean;
+};
+
+export function truncateLastUserMessage(message: string): string {
+ if (message.length <= MAX_LAST_USER_MESSAGE_CHARS) {
+ return message;
+ }
+ return message.slice(0, MAX_LAST_USER_MESSAGE_CHARS);
+}
+
+export function buildModelErrorBridgePrompt(input: ModelErrorBridgeInput): string {
+ const excerpt = input.rawSnippet
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, MAX_EXCERPT_CHARS);
+
+ const lines = [
+ MODEL_ERROR_BRIDGE_HEADER,
+ '',
+ `The previous turn failed before completing (${input.kind}: ${excerpt}).`,
+ '',
+ 'Re-sending your last message below. Continue the task from where you left off.',
+ 'Do not repeat work you already finished unless the error invalidated it.'
+ ];
+
+ if (input.priorAssistantClaimsDone) {
+ lines.push('');
+ lines.push('You may have reported completion before this error — verify what is actually done before proceeding.');
+ }
+
+ lines.push('');
+ lines.push('---');
+ lines.push(input.lastUserMessage);
+
+ return lines.join('\n');
+}
+
+export function canBridgeModelError(gate: ModelErrorBridgeGate): boolean {
+ if (!gate.transient) {
+ return false;
+ }
+ if (gate.bridgeable === false) {
+ return false;
+ }
+ if (gate.retriedAndFailed) {
+ return false;
+ }
+ if (gate.supersededByUserTurn) {
+ return false;
+ }
+ if (gate.bridgedForEventId === gate.eventId) {
+ return false;
+ }
+ return true;
+}
+
+/** Merge hub RPC snapshot gates into local state without clobbering. */
+export function mergeBridgeGateFields(
+ prior: Pick | null | undefined,
+ incoming: Pick
+): Pick {
+ return {
+ bridgedForEventId: incoming.bridgedForEventId ?? prior?.bridgedForEventId,
+ retriedAndFailed: incoming.retriedAndFailed === true || prior?.retriedAndFailed === true,
+ supersededByUserTurn: incoming.supersededByUserTurn === true
+ || prior?.supersededByUserTurn === true,
+ // false wins — never re-open Bridge from a stale hub omit.
+ bridgeable: incoming.bridgeable === false || prior?.bridgeable === false
+ ? false
+ : (incoming.bridgeable ?? prior?.bridgeable)
+ };
+}
diff --git a/cli/src/cursor/cursorModelErrorBridgePrefs.ts b/cli/src/cursor/cursorModelErrorBridgePrefs.ts
new file mode 100644
index 0000000000..0c5697c9fa
--- /dev/null
+++ b/cli/src/cursor/cursorModelErrorBridgePrefs.ts
@@ -0,0 +1,9 @@
+let autoBridgeTransientModelErrors = false;
+
+export function setAutoBridgeTransientModelErrors(enabled: boolean): void {
+ autoBridgeTransientModelErrors = enabled;
+}
+
+export function getAutoBridgeTransientModelErrors(): boolean {
+ return autoBridgeTransientModelErrors;
+}
diff --git a/cli/src/cursor/cursorUserMessageQueue.test.ts b/cli/src/cursor/cursorUserMessageQueue.test.ts
index a80c7cf1b8..830544eeca 100644
--- a/cli/src/cursor/cursorUserMessageQueue.test.ts
+++ b/cli/src/cursor/cursorUserMessageQueue.test.ts
@@ -7,6 +7,23 @@ import type { EnhancedMode } from './loop';
const mode: EnhancedMode = { permissionMode: 'default' };
describe('enqueueCursorUserMessage', () => {
+ it('preserves caller bridge: localId but keeps the turn non-bridge provenance', async () => {
+ const consumed: string[] = [];
+ const queue = new MessageQueue2((m) => m.permissionMode);
+ queue.onBatchConsumed = (localIds) => {
+ consumed.push(...localIds);
+ };
+ enqueueCursorUserMessage(queue, 'please continue', mode, 'bridge:evt-1');
+ expect(queue.queue).toHaveLength(1);
+ expect(queue.queue[0]?.localId).toBe('bridge:evt-1');
+ expect(queue.queue[0]?.internal).toBeUndefined();
+ expect(queue.hasPendingNonBridgeTurn()).toBe(true);
+
+ const batch = await queue.waitForMessagesAndGetAsString();
+ expect(batch?.items[0]?.internal).toBeUndefined();
+ expect(consumed).toEqual(['bridge:evt-1']);
+ });
+
it('isolates /compress from a following same-mode prompt', async () => {
const queue = new MessageQueue2((m) => m.permissionMode);
enqueueCursorUserMessage(queue, '/compress keep recap', mode, 'a');
diff --git a/cli/src/cursor/cursorUserMessageQueue.ts b/cli/src/cursor/cursorUserMessageQueue.ts
index 28ae72425f..c39f8577f2 100644
--- a/cli/src/cursor/cursorUserMessageQueue.ts
+++ b/cli/src/cursor/cursorUserMessageQueue.ts
@@ -12,6 +12,9 @@ export function enqueueCursorUserMessage(
enhancedMode: EnhancedMode,
localId?: string
): void {
+ // Preserve caller localId for messages-consumed / cancel. Bridge provenance
+ // is queue-owned (`internal.kind === 'model-error-bridge'`), so a forged
+ // `bridge:*` localId cannot impersonate a Bridge turn.
const specialCommand = parseCursorSpecialCommand(formattedText);
if (specialCommand.type !== null) {
messageQueue.pushIsolated(formattedText.trim(), enhancedMode, localId);
diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts
index 0cf4636781..1871af7837 100644
--- a/cli/src/cursor/runCursor.ts
+++ b/cli/src/cursor/runCursor.ts
@@ -16,6 +16,7 @@ import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { getInvokedCwd } from '@/utils/invokedCwd';
import { enqueueCursorUserMessage } from './cursorUserMessageQueue';
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
+import { setAutoBridgeTransientModelErrors } from './cursorModelErrorBridgePrefs';
const formatFailureReason = (message: string): string => {
const maxLength = 200;
@@ -123,16 +124,24 @@ export async function runCursor(opts: {
permissionMode?: unknown;
model?: unknown;
modelReasoningEffort?: unknown;
+ autoBridgeTransientModelErrors?: unknown;
};
const applied: {
permissionMode?: PermissionMode;
model?: string | null;
+ autoBridgeTransientModelErrors?: boolean;
} = {};
if (config.modelReasoningEffort !== undefined) {
throw new Error('Invalid model reasoning effort');
}
+ if (config.autoBridgeTransientModelErrors !== undefined) {
+ const enabled = config.autoBridgeTransientModelErrors === true;
+ setAutoBridgeTransientModelErrors(enabled);
+ applied.autoBridgeTransientModelErrors = enabled;
+ }
+
const nextPermissionMode = config.permissionMode !== undefined
? resolveSessionConfigPermissionMode(config.permissionMode, 'cursor')
: undefined;
diff --git a/cli/src/utils/MessageQueue2.test.ts b/cli/src/utils/MessageQueue2.test.ts
index d1dee25e20..ae4e6ed110 100644
--- a/cli/src/utils/MessageQueue2.test.ts
+++ b/cli/src/utils/MessageQueue2.test.ts
@@ -486,6 +486,29 @@ describe('MessageQueue2', () => {
expect(queue.size()).toBe(0);
});
+ it('does not ACK a later forged bridge: localId when dequeuing a synthetic Bridge', async () => {
+ const queue = new MessageQueue2(mode => mode);
+ const received: string[][] = [];
+ queue.onBatchConsumed = (localIds) => { received.push(localIds); };
+
+ const eventId = 'evt-ack';
+ queue.unshiftIsolated(
+ 'bridge retry',
+ 'local',
+ undefined,
+ { kind: 'model-error-bridge', eventId }
+ );
+ queue.push('real prompt', 'local', `bridge:${eventId}`);
+
+ const batch1 = await queue.waitForMessagesAndGetAsString();
+ expect(batch1?.message).toBe('bridge retry');
+ expect(received).toEqual([]);
+
+ const batch2 = await queue.waitForMessagesAndGetAsString();
+ expect(batch2?.message).toBe('real prompt');
+ expect(received).toEqual([[`bridge:${eventId}`]]);
+ });
+
it('should skip onBatchConsumed when batch has no localIds', async () => {
const queue = new MessageQueue2(mode => mode);
let called = false;
diff --git a/cli/src/utils/MessageQueue2.ts b/cli/src/utils/MessageQueue2.ts
index 6c724ba1ae..8465eaa698 100644
--- a/cli/src/utils/MessageQueue2.ts
+++ b/cli/src/utils/MessageQueue2.ts
@@ -1,5 +1,8 @@
import { logger } from "@/ui/logger";
+export type QueueItemInternal =
+ | { kind: 'model-error-bridge'; eventId: string };
+
export interface QueueItem {
message: string;
mode: T;
@@ -8,6 +11,8 @@ export interface QueueItem {
isolate?: boolean; // If true, this message must be processed alone
/** Stable FIFO key used when an async reservation is restored later. */
enqueueOrder?: number;
+ /** Queue-owned provenance — never inferred from caller localId. */
+ internal?: QueueItemInternal;
}
export type QueueReservation = {
@@ -21,6 +26,12 @@ export type QueueReservation = {
state: 'reserved' | 'dispatching' | 'indeterminate' | 'cancelled';
};
+export type CollectedQueueItem = {
+ message: string
+ localId?: string
+ internal?: QueueItemInternal
+};
+
/**
* A mode-aware message queue that stores messages with their modes.
* Returns consistent batches of messages with the same mode.
@@ -261,7 +272,12 @@ export class MessageQueue2 {
* that failed transiently and must retry without batching against sibling
* prompts).
*/
- unshiftIsolated(message: string, mode: T, localId?: string): void {
+ unshiftIsolated(
+ message: string,
+ mode: T,
+ localId?: string,
+ internal?: QueueItemInternal
+ ): void {
if (this.closed) {
throw new Error('Cannot unshift to closed queue');
}
@@ -275,6 +291,7 @@ export class MessageQueue2 {
modeHash,
localId,
isolate: true,
+ internal,
enqueueOrder: this.previousEnqueueOrder--
};
Object.defineProperty(item, 'enqueueOrder', { value: item.enqueueOrder, enumerable: false, writable: true });
@@ -293,6 +310,23 @@ export class MessageQueue2 {
logger.debug(`[MessageQueue2] unshiftIsolated() completed. Queue size: ${this.queue.length}`);
}
+ /** True when a non-bridge user/API turn is already waiting. */
+ hasPendingNonBridgeTurn(): boolean {
+ return this.queue.some((item) => item.internal?.kind !== 'model-error-bridge');
+ }
+
+ /** Drop a pending model-error bridge by its eventId (queue-owned provenance). */
+ cancelModelErrorBridge(eventId: string): boolean {
+ if (!eventId) return false;
+ const idx = this.queue.findIndex(
+ (item) => item.internal?.kind === 'model-error-bridge'
+ && item.internal.eventId === eventId
+ );
+ if (idx === -1) return false;
+ this.queue.splice(idx, 1);
+ return true;
+ }
+
/**
* Remove the first queued message that matches the given localId.
* Returns true if a message was removed, false if not found.
@@ -551,7 +585,7 @@ export class MessageQueue2 {
* Wait for messages and return all messages with the same mode as a single string
* Returns { message: string, mode: T } or null if aborted/closed
*/
- async waitForMessagesAndGetAsString(abortSignal?: AbortSignal): Promise<{ message: string, mode: T, isolate: boolean, hash: string, items: Array<{ message: string, localId?: string }> } | null> {
+ async waitForMessagesAndGetAsString(abortSignal?: AbortSignal): Promise<{ message: string, mode: T, isolate: boolean, hash: string, items: CollectedQueueItem[] } | null> {
// If we have messages, return them immediately
if (this.queue.length > 0) {
return this.collectBatch();
@@ -575,7 +609,7 @@ export class MessageQueue2 {
/**
* Collect a batch of messages with the same mode, respecting isolation requirements
*/
- private collectBatch(): { message: string, mode: T, hash: string, isolate: boolean, items: Array<{ message: string, localId?: string }> } | null {
+ private collectBatch(): { message: string, mode: T, hash: string, isolate: boolean, items: CollectedQueueItem[] } | null {
if (this.queue.length === 0) {
return null;
}
@@ -587,7 +621,7 @@ export class MessageQueue2 {
// `message` string below so callers that need to requeue individual
// messages (e.g. restoring a failed batch with each item's own
// localId intact) don't have to re-split an already-joined string.
- const items: Array<{ message: string, localId?: string }> = [];
+ const items: CollectedQueueItem[] = [];
let mode = firstItem.mode;
let isolate = firstItem.isolate ?? false;
const targetModeHash = firstItem.modeHash;
@@ -596,7 +630,7 @@ export class MessageQueue2 {
if (firstItem.isolate) {
const item = this.queue.shift()!;
sameModeMessages.push(item.message);
- items.push({ message: item.message, localId: item.localId });
+ items.push({ message: item.message, localId: item.localId, internal: item.internal });
if (item.localId) consumedLocalIds.push(item.localId);
logger.debug(`[MessageQueue2] Collected isolated message with mode hash: ${targetModeHash}`);
} else {
@@ -606,7 +640,7 @@ export class MessageQueue2 {
!this.queue[0].isolate) {
const item = this.queue.shift()!;
sameModeMessages.push(item.message);
- items.push({ message: item.message, localId: item.localId });
+ items.push({ message: item.message, localId: item.localId, internal: item.internal });
if (item.localId) consumedLocalIds.push(item.localId);
}
logger.debug(`[MessageQueue2] Collected batch of ${sameModeMessages.length} messages with mode hash: ${targetModeHash}`);
diff --git a/docs/api/native-companion-contract.md b/docs/api/native-companion-contract.md
index c7621c4b81..5c7a6f518c 100644
--- a/docs/api/native-companion-contract.md
+++ b/docs/api/native-companion-contract.md
@@ -61,16 +61,17 @@ namespace to avoid duplicate OS notifications.
| Key | Example | Purpose |
|-----|---------|---------|
-| `type` | `ready` | `ready`, `permission-request`, `task-notification` |
+| `type` | `ready` | `ready`, `permission-request`, `task-notification`, `model-error` |
| `sessionId` | uuid | Target session |
| `sessionName` | string | Display name (`agent - project`) |
| `url` | `/sessions/{id}` | Deep link path |
| `requestId` | uuid | Permission only - approve/deny |
| `title` | string | Notification title |
| `body` | string | Notification body |
-| `severity` | `info` | `info` (ready), `warning` (permission), `success` / `error` (task) |
+| `severity` | `info` | `info` (ready), `warning` (permission), `success` / `error` (task), `error` (`model-error`) |
| `contractVersion` | `1` | Present on every message; see [Versioning](#versioning) |
| `notifySummary` | JSON string | Only on `ready`: parsed `AGENT_NOTIFY_SUMMARY` line from agent text, when present |
+| `tag` | `model-error--` | Coalescing identity. Required for `model-error` so distinct errors do not overwrite. Clients must prefer `tag` over reconstructing `type-`. |
Native apps **must** handle `data` for Wear; notification block is for display.
@@ -156,9 +157,10 @@ decrypts `hapi.e` with the Keychain `pushKey` and replaces title/body with
the real content; `hapi.v` is the envelope version (currently `1`).
Delivery headers: `apns-push-type: alert`, `apns-priority: 10`,
-`apns-expiration: 0`, `apns-collapse-id: "-"` (truncated to
-64 bytes) - so newer notifications for the same session/type replace older
-ones.
+`apns-expiration: 0`, `apns-collapse-id: "-"` (or the
+payload `tag` when present, e.g. `model-error--`),
+truncated to 64 bytes - so newer notifications for the same coalescing
+identity replace older ones.
### Transports: self-host (direct APNs) vs official relay
diff --git a/hub/src/config/autoBridgeTransientModelErrors.ts b/hub/src/config/autoBridgeTransientModelErrors.ts
new file mode 100644
index 0000000000..956bd491c3
--- /dev/null
+++ b/hub/src/config/autoBridgeTransientModelErrors.ts
@@ -0,0 +1,36 @@
+import {
+ getSettingsFile,
+ readSettingsOrThrow,
+ updateSettings,
+ type Settings
+} from './settings'
+
+/**
+ * Hub-persisted opt-in for Cursor auto-bridge after transient model errors.
+ * Default is off (undefined / false). Applied to CLI processes on session
+ * create/get so unattended / restarted sessions honor Settings.
+ */
+export function isAutoBridgeTransientModelErrorsSettingEnabled(settings: Settings): boolean {
+ return settings.autoBridgeTransientModelErrors === true
+}
+
+export async function readAutoBridgeTransientModelErrorsEnabled(dataDir: string): Promise {
+ const settings = await readSettingsOrThrow(getSettingsFile(dataDir))
+ return isAutoBridgeTransientModelErrorsSettingEnabled(settings)
+}
+
+export async function writeAutoBridgeTransientModelErrorsEnabled(
+ dataDir: string,
+ enabled: boolean
+): Promise {
+ return updateSettings(getSettingsFile(dataDir), (current) => {
+ const settings = {
+ ...current,
+ autoBridgeTransientModelErrors: enabled
+ }
+ return {
+ settings,
+ result: settings.autoBridgeTransientModelErrors === true
+ }
+ })
+}
diff --git a/hub/src/config/settings.ts b/hub/src/config/settings.ts
index 0db04271a2..5b0113c354 100644
--- a/hub/src/config/settings.ts
+++ b/hub/src/config/settings.ts
@@ -45,6 +45,11 @@ export interface Settings {
* Default off: render/copy strip the footer; store stays raw.
*/
sessionSummaryInChat?: boolean
+ /**
+ * When true, Cursor CLI sessions auto-enqueue a one-shot bridge after a
+ * transient model error. Default off. Survives hub/CLI restarts.
+ */
+ autoBridgeTransientModelErrors?: boolean
/**
* Hub-side provider API keys / endpoints managed from Settings.
* Env vars still win when set at process start (ops override).
diff --git a/hub/src/fcm/fcmNotificationChannel.test.ts b/hub/src/fcm/fcmNotificationChannel.test.ts
index 50c00e54c7..32e5206e12 100644
--- a/hub/src/fcm/fcmNotificationChannel.test.ts
+++ b/hub/src/fcm/fcmNotificationChannel.test.ts
@@ -592,4 +592,112 @@ describe('FcmNotificationChannel', () => {
expect(sent[0].body).toContain('...')
expect(sent[0].body.length).toBeLessThan(350)
})
+
+ it('sendModelError fires FCM with severity=error even when PWA is foreground', async () => {
+ const sent: FcmSendPayload[] = []
+ const toasts: unknown[] = []
+ const channel = new FcmNotificationChannel(
+ {
+ sendToNamespace: async (_namespace: string, payload: FcmSendPayload) => {
+ sent.push(payload)
+ }
+ } as never,
+ {
+ sendToast: async (_namespace: string, event: unknown) => {
+ toasts.push(event)
+ return 1
+ }
+ } as never,
+ {
+ hasVisibleConnection: () => true
+ } as never
+ )
+
+ await channel.sendModelError(createSession(), {
+ eventId: 'evt-1710000000000',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'You have hit your usage limit',
+ priorAssistantClaimsDone: true,
+ atTs: 1710000000000
+ })
+
+ expect(sent).toHaveLength(1)
+ expect(toasts).toHaveLength(0)
+ expect(sent[0].data.type).toBe('model-error')
+ expect(sent[0].data.severity).toBe('error')
+ expect(sent[0].tag).toBe('model-error-session-ready-evt-1710000000000')
+ expect(sent[0].data.tag).toBe('model-error-session-ready-evt-1710000000000')
+ expect(sent[0].title).toBe('Quota exhausted')
+ expect(sent[0].body).toContain('Codex')
+ expect(sent[0].body).toContain('Demo')
+ })
+
+ it('sendModelError uses distinct tags per eventId so errors do not collapse', async () => {
+ const sent: FcmSendPayload[] = []
+ const channel = new FcmNotificationChannel(
+ { sendToNamespace: async (_n: string, p: FcmSendPayload) => { sent.push(p) } } as never,
+ { sendToast: async () => 0 } as never,
+ { hasVisibleConnection: () => false } as never
+ )
+
+ const base = {
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'slow down',
+ priorAssistantClaimsDone: false
+ }
+
+ await channel.sendModelError(createSession(), { ...base, eventId: 'evt-1', atTs: 1 })
+ await channel.sendModelError(createSession(), { ...base, eventId: 'evt-2', atTs: 2 })
+
+ expect(sent[0].tag).toBe('model-error-session-ready-evt-1')
+ expect(sent[1].tag).toBe('model-error-session-ready-evt-2')
+ expect(sent[0].data.type).toBe('model-error')
+ expect(sent[1].data.severity).toBe('error')
+ })
+
+ it('sendModelError sets nativeGate.sent when FCM delivers', async () => {
+ const gate = { sent: false }
+ const channel = new FcmNotificationChannel(
+ {
+ sendToNamespace: async () => ({ sent: 1, failed: 0, invalidTokens: [] })
+ } as never,
+ { sendToast: async () => 0 } as never,
+ { hasVisibleConnection: () => false } as never
+ )
+
+ await channel.sendModelError(createSession(), {
+ eventId: 'evt-9',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'limit',
+ priorAssistantClaimsDone: false,
+ atTs: 9
+ }, { nativeGate: gate })
+
+ expect(gate.sent).toBe(true)
+ })
+
+ it('sendModelError leaves nativeGate.sent false when FCM sends zero', async () => {
+ const gate = { sent: false }
+ const channel = new FcmNotificationChannel(
+ {
+ sendToNamespace: async () => ({ sent: 0, failed: 1, invalidTokens: [] })
+ } as never,
+ { sendToast: async () => 0 } as never,
+ { hasVisibleConnection: () => false } as never
+ )
+
+ await channel.sendModelError(createSession(), {
+ eventId: 'evt-9',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'limit',
+ priorAssistantClaimsDone: false,
+ atTs: 9
+ }, { nativeGate: gate })
+
+ expect(gate.sent).toBe(false)
+ })
})
diff --git a/hub/src/fcm/fcmNotificationChannel.ts b/hub/src/fcm/fcmNotificationChannel.ts
index 06c1331372..77b93a8a43 100644
--- a/hub/src/fcm/fcmNotificationChannel.ts
+++ b/hub/src/fcm/fcmNotificationChannel.ts
@@ -1,7 +1,9 @@
import type { Session } from '../sync/syncEngine'
-import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes'
+import type { ModelErrorNotification, ModelErrorSendOutcome, NotificationChannel, TaskNotification } from '../notifications/notificationTypes'
import type { NotificationSendContext } from '../notifications/notificationSendContext'
import { NATIVE_CONTRACT_VERSION, NativeNotificationComposer, type ComposedNativeNotification } from '../notifications/nativeNotificationComposer'
+import { formatModelErrorBody, formatModelErrorTitle } from '../notifications/modelErrorCopy'
+import { getAgentName, getSessionName } from '../notifications/sessionInfo'
import type { Store } from '../store'
import type { SSEManager } from '../sse/sseManager'
import type { VisibilityTracker } from '../visibility/visibilityTracker'
@@ -43,6 +45,47 @@ export class FcmNotificationChannel implements NotificationChannel {
await this.deliver(session, this.toFcmPayload(this.composer.composeTask(session, notification)), ctx)
}
+ async sendModelError(
+ session: Session,
+ notification: ModelErrorNotification,
+ ctx?: NotificationSendContext
+ ): Promise {
+ // No active-session guard: NotificationHub only starts dispatch for
+ // active sessions, but a bounded backoff retry must still deliver if
+ // the session went inactive before the timer fired.
+
+ const agentName = getAgentName(session)
+ const sessionName = getSessionName(session)
+ const title = formatModelErrorTitle(notification.kind)
+ const body = formatModelErrorBody(notification, { agentName, sessionName })
+ const tag = `model-error-${session.id}-${notification.eventId}`
+
+ const result = await this.deliver(session, {
+ title,
+ body,
+ tag,
+ data: {
+ type: 'model-error',
+ sessionId: session.id,
+ sessionName,
+ url: `/sessions/${session.id}`,
+ title,
+ body,
+ contractVersion: NATIVE_CONTRACT_VERSION,
+ severity: 'error',
+ tag
+ }
+ }, ctx)
+ if ((result?.sent ?? 0) > 0) {
+ return 'delivered'
+ }
+ // No devices registered for this namespace - not a hard failure.
+ if ((result?.failed ?? 0) === 0) {
+ return 'unavailable'
+ }
+ return 'failed'
+ }
+
private toFcmPayload(composed: ComposedNativeNotification): FcmSendPayload {
return {
title: composed.title,
@@ -63,7 +106,7 @@ export class FcmNotificationChannel implements NotificationChannel {
}
}
- private async deliver(session: Session, payload: FcmSendPayload, ctx?: NotificationSendContext): Promise {
+ private async deliver(session: Session, payload: FcmSendPayload, ctx?: NotificationSendContext) {
// Native companion is the canonical surface: always fire FCM when the
// hub asks us to. The previous SSE-toast shortcut here meant that
// when the operator had the PWA open in foreground, the watch got
@@ -77,5 +120,6 @@ export class FcmNotificationChannel implements NotificationChannel {
if ((result?.sent ?? 0) > 0 && ctx?.nativeGate) {
ctx.nativeGate.sent = true
}
+ return result
}
}
diff --git a/hub/src/fcm/fcmService.test.ts b/hub/src/fcm/fcmService.test.ts
index d2526a47d0..fd302123b1 100644
--- a/hub/src/fcm/fcmService.test.ts
+++ b/hub/src/fcm/fcmService.test.ts
@@ -60,6 +60,26 @@ describe('FcmService.sendToNamespace', () => {
globalThis.fetch = originalFetch
})
+ it('serializes payload.tag onto message.data.tag in the HTTP body', async () => {
+ const store = makeStore([
+ { namespace: 'default', token: 'tok-1', platform: 'phone', deviceId: 'p1' }
+ ])
+ let requestBody: unknown = null
+ globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
+ requestBody = JSON.parse(String(init?.body ?? '{}'))
+ return new Response('{}', { status: 200 })
+ }) as unknown as typeof fetch
+
+ const svc = new FcmService('proj-id', { client_email: 'x', private_key: 'y' }, store as never)
+ await svc.sendToNamespace('default', {
+ ...makePayload({ type: 'model-error', severity: 'error' }),
+ tag: 'model-error-sess-1-1710000000000'
+ })
+
+ const message = (requestBody as { message?: { data?: Record } }).message
+ expect(message?.data?.tag).toBe('model-error-sess-1-1710000000000')
+ })
+
it('removes the device row when FCM returns 404 UNREGISTERED (token rotated)', async () => {
const store = makeStore([
{ namespace: 'default', token: 'rotated-token', platform: 'phone', deviceId: 'p1' }
diff --git a/hub/src/fcm/fcmService.ts b/hub/src/fcm/fcmService.ts
index 8fcec571ec..d78baab1ca 100644
--- a/hub/src/fcm/fcmService.ts
+++ b/hub/src/fcm/fcmService.ts
@@ -29,6 +29,11 @@ export type FcmDataPayload = {
* when the agent did not emit a summary.
*/
notifySummary?: string
+ /**
+ * Coalescing identity. Present on `model-error` as
+ * `model-error--` so distinct errors do not overwrite.
+ */
+ tag?: string
}
export type FcmSendPayload = {
@@ -178,6 +183,11 @@ export class FcmService {
if (payload.data.notifySummary) {
dataRecord.notifySummary = payload.data.notifySummary
}
+ if (payload.tag) {
+ dataRecord.tag = payload.tag
+ } else if (payload.data.tag) {
+ dataRecord.tag = payload.data.tag
+ }
// Data-only: if we also send `notification`, Android does not call
// onMessageReceived while backgrounded — Wear relay never runs.
diff --git a/hub/src/notifications/modelErrorCopy.test.ts b/hub/src/notifications/modelErrorCopy.test.ts
new file mode 100644
index 0000000000..752a15aad7
--- /dev/null
+++ b/hub/src/notifications/modelErrorCopy.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'bun:test'
+import type { ModelErrorNotification } from './notificationTypes'
+import { formatModelErrorBody, formatModelErrorTitle } from './modelErrorCopy'
+
+const baseNotification = (overrides: Partial = {}): ModelErrorNotification => ({
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'Error: T: [resource_exhausted] capacity exceeded for the day',
+ priorAssistantClaimsDone: false,
+ eventId: 'evt-copy-1',
+ atTs: 1700000000000,
+ ...overrides
+})
+
+describe('formatModelErrorTitle', () => {
+ it('returns kind-specific titles for known kinds', () => {
+ expect(formatModelErrorTitle('quota_exhausted')).toBe('Quota exhausted')
+ expect(formatModelErrorTitle('rate_limited')).toBe('Rate limited')
+ expect(formatModelErrorTitle('transport_closed')).toBe('Agent transport closed')
+ expect(formatModelErrorTitle('agent_crashed')).toBe('Agent crashed')
+ expect(formatModelErrorTitle('rpc_timeout')).toBe('Agent request timed out')
+ expect(formatModelErrorTitle('context_window')).toBe('Context window exceeded')
+ })
+
+ it('falls back to generic Model error for unknown kinds', () => {
+ expect(formatModelErrorTitle('unknown_t_prefix')).toBe('Model error')
+ expect(formatModelErrorTitle('unknown_stderr')).toBe('Model error')
+ expect(formatModelErrorTitle('something_we_have_not_seen')).toBe('Model error')
+ })
+})
+
+describe('formatModelErrorBody', () => {
+ const ctx = { agentName: 'Cursor', sessionName: 'feature-x' }
+
+ it('leads with the lying-completion warning when priorAssistantClaimsDone', () => {
+ const body = formatModelErrorBody(
+ baseNotification({ priorAssistantClaimsDone: true }),
+ ctx
+ )
+ const firstLine = body.split('\n')[0]
+ expect(firstLine).toContain('claimed completion')
+ expect(firstLine).toContain('INCOMPLETE')
+ })
+
+ it('omits the warning line when prior claim is false', () => {
+ const body = formatModelErrorBody(
+ baseNotification({ priorAssistantClaimsDone: false }),
+ ctx
+ )
+ expect(body).not.toContain('claimed completion')
+ expect(body).toContain('Cursor - feature-x')
+ })
+
+ it('appends the transient hint when transient', () => {
+ const body = formatModelErrorBody(
+ baseNotification({ transient: true }),
+ ctx
+ )
+ expect(body).toContain('(transient - safe to retry)')
+ })
+
+ it('omits the transient hint when not transient', () => {
+ const body = formatModelErrorBody(
+ baseNotification({ transient: false }),
+ ctx
+ )
+ expect(body).not.toContain('transient')
+ })
+
+ it('omits rawSnippet from external notification bodies', () => {
+ const secretish = 'Error: T: [auth_failed] Bearer sk-live-DO-NOT-LEAK path=/home/op/.secrets'
+ const body = formatModelErrorBody(
+ baseNotification({ rawSnippet: secretish }),
+ ctx
+ )
+ expect(body).not.toContain(secretish)
+ expect(body).not.toContain('sk-live')
+ expect(body).not.toContain('resource_exhausted')
+ expect(body).toContain('Cursor - feature-x')
+ })
+
+ it('still omits raw even when it is long or multiline', () => {
+ const body = formatModelErrorBody(
+ baseNotification({ rawSnippet: `line 1\n\n ${'A'.repeat(500)}` }),
+ ctx
+ )
+ expect(body).not.toContain('AAAA')
+ expect(body).not.toContain('line 1')
+ expect(body.split('\n')).toEqual(['Cursor - feature-x'])
+ })
+})
diff --git a/hub/src/notifications/modelErrorCopy.ts b/hub/src/notifications/modelErrorCopy.ts
new file mode 100644
index 0000000000..3180a75846
--- /dev/null
+++ b/hub/src/notifications/modelErrorCopy.ts
@@ -0,0 +1,63 @@
+import type { ModelErrorNotification } from './notificationTypes'
+
+/**
+ * Map model-error kinds to human-readable titles. Shared by all
+ * notification channels (FCM / Web Push / Telegram) so the wrist
+ * glance, browser toast, and chat message all read the same.
+ *
+ * Title is the GLANCE line: short, scannable, kind-specific. The body
+ * (separate) carries the priorAssistantClaimsDone alert and agent +
+ * session names. Raw provider/RPC text stays in the authenticated web
+ * banner only — never in push/Telegram (prompt text, paths, auth crumbs).
+ *
+ * Unknown kinds fall through to "Model error" so we never ship a
+ * notification with `[object Object]` or an internal kind string.
+ */
+export function formatModelErrorTitle(kind: string): string {
+ switch (kind) {
+ case 'quota_exhausted': return 'Quota exhausted'
+ case 'rate_limited': return 'Rate limited'
+ case 'capacity_exhausted': return 'Capacity exhausted'
+ case 'context_window': return 'Context window exceeded'
+ case 'auth_failed': return 'Authentication failed'
+ case 'model_not_found': return 'Model not found'
+ case 'transport_closed': return 'Agent transport closed'
+ case 'agent_crashed': return 'Agent crashed'
+ case 'rpc_timeout': return 'Agent request timed out'
+ case 'connection_stalled': return 'Connection stalled'
+ case 'deadline_exceeded': return 'Deadline exceeded'
+ case 'unavailable': return 'Service unavailable'
+ case 'canceled': return 'Agent canceled'
+ case 'prompt_failed': return 'Prompt failed'
+ case 'unknown_stderr':
+ case 'unknown_t_prefix':
+ default: return 'Model error'
+ }
+}
+
+/**
+ * Body line strategy (external channels only — Web Push / Telegram / FCM):
+ * - If priorAssistantClaimsDone, lead with the lying-completion warning
+ * ("agent claimed completion before this error -- work likely
+ * INCOMPLETE"). This is the high-value disambiguator from the
+ * operator's POV: an "all done" green dot followed by an error means
+ * the agent walked away from a half-finished task.
+ * - Append agent/session context.
+ * - Do NOT append rawSnippet: that text is provider/RPC stderr and can
+ * contain prompt fragments, paths, or auth material. Operators see the
+ * full excerpt in the authenticated ModelErrorBanner.
+ */
+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}`)
+ if (notification.transient) {
+ lines.push('(transient - safe to retry)')
+ }
+ return lines.join('\n')
+}
diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts
index c28443882f..69a3ca9afe 100644
--- a/hub/src/notifications/notificationHub.test.ts
+++ b/hub/src/notifications/notificationHub.test.ts
@@ -1,7 +1,12 @@
import { describe, expect, it } from 'bun:test'
import type { Session, SyncEvent, SyncEventListener, SyncEngine } from '../sync/syncEngine'
import type { SessionEndReason } from '@hapi/protocol'
-import type { NotificationChannel, TaskNotification } from './notificationTypes'
+import type {
+ ModelErrorNotification,
+ ModelErrorSendOutcome,
+ NotificationChannel,
+ TaskNotification
+} from './notificationTypes'
import { NotificationHub } from './notificationHub'
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -9,6 +14,9 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
class FakeSyncEngine {
private readonly listeners: Set = new Set()
private readonly sessions: Map = new Map()
+ readonly modelErrorNotifiedMarks: Array<{ sessionId: string; eventId: string }> = []
+ /** When > 0, next N markModelErrorNotified calls throw before succeeding. */
+ markModelErrorNotifiedFailuresRemaining = 0
subscribe(listener: SyncEventListener): () => void {
this.listeners.add(listener)
@@ -19,10 +27,37 @@ class FakeSyncEngine {
return this.sessions.get(sessionId)
}
+ getSessions(): Session[] {
+ return Array.from(this.sessions.values())
+ }
+
setSession(session: Session): void {
this.sessions.set(session.id, session)
}
+ async markModelErrorNotified(sessionId: string, eventId: string): Promise {
+ if (this.markModelErrorNotifiedFailuresRemaining > 0) {
+ this.markModelErrorNotifiedFailuresRemaining -= 1
+ throw new Error('version conflict')
+ }
+ this.modelErrorNotifiedMarks.push({ sessionId, eventId })
+ const session = this.sessions.get(sessionId)
+ const err = session?.metadata?.lastModelError
+ if (!session || !err || err.eventId !== eventId) {
+ return
+ }
+ this.sessions.set(sessionId, {
+ ...session,
+ metadata: {
+ ...session.metadata!,
+ lastModelError: {
+ ...err,
+ notifiedAt: Date.now()
+ }
+ }
+ })
+ }
+
emit(event: SyncEvent): void {
for (const listener of this.listeners) {
listener(event)
@@ -35,6 +70,7 @@ class StubChannel implements NotificationChannel {
readonly permissionSessions: Session[] = []
readonly taskNotifications: Array<{ session: Session; notification: TaskNotification }> = []
readonly sessionCompletions: Session[] = []
+ readonly modelErrors: Array<{ session: Session; notification: ModelErrorNotification }> = []
async sendReady(session: Session): Promise {
this.readySessions.push(session)
@@ -51,6 +87,11 @@ class StubChannel implements NotificationChannel {
async sendSessionCompletion(session: Session): Promise {
this.sessionCompletions.push(session)
}
+
+ async sendModelError(session: Session, notification: ModelErrorNotification): Promise {
+ this.modelErrors.push({ session, notification })
+ return 'delivered'
+ }
}
function createSession(overrides: Partial = {}): Session {
@@ -245,4 +286,660 @@ describe('NotificationHub', () => {
hub.stop()
})
+
+ it('fires model-error notification when lastModelError.atTs advances', async () => {
+ const engine = new FakeSyncEngine()
+ const channel = new StubChannel()
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel])
+
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-1000',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'Error: T: [resource_exhausted] capacity exceeded',
+ atTs: 1000,
+ priorAssistantClaimsDone: true
+ }
+ } as Session['metadata']
+ })
+
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(5)
+
+ expect(channel.modelErrors).toHaveLength(1)
+ const fired = channel.modelErrors[0]?.notification
+ expect(fired?.kind).toBe('quota_exhausted')
+ expect(fired?.transient).toBe(false)
+ expect(fired?.priorAssistantClaimsDone).toBe(true)
+ expect(fired?.atTs).toBe(1000)
+
+ hub.stop()
+ })
+
+ it('dedupes model-error notifications across repeat session-updated events', async () => {
+ const engine = new FakeSyncEngine()
+ const channel = new StubChannel()
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel])
+
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-2000',
+ kind: 'transport_closed',
+ transient: true,
+ rawSnippet: 'WritableIterable is closed',
+ atTs: 2000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(5)
+
+ expect(channel.modelErrors).toHaveLength(1)
+
+ hub.stop()
+ })
+
+ it('fires again when a NEW lastModelError replaces an older one', async () => {
+ const engine = new FakeSyncEngine()
+ const channel = new StubChannel()
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel])
+
+ const firstSession = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-1000',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'first',
+ atTs: 1000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(firstSession)
+ engine.emit({ type: 'session-updated', sessionId: firstSession.id })
+ await sleep(5)
+ expect(channel.modelErrors).toHaveLength(1)
+
+ const secondSession = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-2000',
+ kind: 'transport_closed',
+ transient: true,
+ rawSnippet: 'second',
+ atTs: 2000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(secondSession)
+ engine.emit({ type: 'session-updated', sessionId: secondSession.id })
+ await sleep(5)
+
+ expect(channel.modelErrors).toHaveLength(2)
+ expect(channel.modelErrors[1]?.notification.atTs).toBe(2000)
+
+ hub.stop()
+ })
+
+ it('rehydrates undelivered model-error for inactive sessions on hub construct', async () => {
+ const engine = new FakeSyncEngine()
+ const channel = new StubChannel()
+ const session = createSession({
+ active: false,
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-4500',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'Error: T: [resource_exhausted]',
+ atTs: 4500,
+ priorAssistantClaimsDone: true
+ }
+ } as Session['metadata']
+ })
+ // Seed before hub exists — mirrors SyncEngine.reloadAll() before
+ // NotificationHub is constructed in startHub.
+ engine.setSession(session)
+
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel])
+ await sleep(10)
+
+ expect(channel.modelErrors).toHaveLength(1)
+ expect(channel.modelErrors[0]?.notification.atTs).toBe(4500)
+ expect(engine.modelErrorNotifiedMarks).toEqual([{ sessionId: session.id, eventId: 'evt-4500' }])
+ hub.stop()
+ })
+
+ it('persists notifiedAt after successful delivery and skips after hub restart', async () => {
+ const engine = new FakeSyncEngine()
+ const channel = new StubChannel()
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel])
+
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-4000',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'Error: T: [resource_exhausted]',
+ atTs: 4000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(10)
+
+ expect(channel.modelErrors).toHaveLength(1)
+ expect(engine.modelErrorNotifiedMarks).toEqual([{ sessionId: session.id, eventId: 'evt-4000' }])
+ expect(engine.getSession(session.id)?.metadata?.lastModelError?.notifiedAt).toEqual(
+ expect.any(Number)
+ )
+ hub.stop()
+
+ // Fresh hub = lost in-memory watermark; durable notifiedAt must gate.
+ const channel2 = new StubChannel()
+ const hub2 = new NotificationHub(engine as unknown as SyncEngine, [channel2])
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(10)
+ expect(channel2.modelErrors).toHaveLength(0)
+ hub2.stop()
+ })
+
+ it('does not fire model-error for already-acknowledged errors', async () => {
+ const engine = new FakeSyncEngine()
+ const channel = new StubChannel()
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel])
+
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-1000',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'first',
+ atTs: 1000,
+ priorAssistantClaimsDone: false,
+ acknowledgedAt: 1500
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(5)
+
+ expect(channel.modelErrors).toHaveLength(0)
+
+ hub.stop()
+ })
+
+ it('skips model-error dispatch when no channels implement it', async () => {
+ const engine = new FakeSyncEngine()
+ // Channel WITHOUT sendModelError -- should silently skip, no throw.
+ const minimalChannel: NotificationChannel = {
+ async sendReady() {},
+ async sendPermissionRequest() {},
+ async sendTaskNotification() {}
+ }
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [minimalChannel])
+
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-3000',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'no-channel',
+ atTs: 3000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(5)
+
+ // No assertions other than "didn't throw"; the channel has no
+ // recording surface. Test passes if hub.stop() returns cleanly.
+ hub.stop()
+ })
+
+ it('shares nativeGate across model-error channels so later channels can defer', async () => {
+ const engine = new FakeSyncEngine()
+ const fcmCalls: ModelErrorNotification[] = []
+ const pushCalls: ModelErrorNotification[] = []
+
+ const fcmChannel: NotificationChannel = {
+ async sendReady() {},
+ async sendPermissionRequest() {},
+ async sendTaskNotification() {},
+ async sendModelError(_session, notification, ctx) {
+ fcmCalls.push(notification)
+ if (ctx?.nativeGate) {
+ ctx.nativeGate.sent = true
+ }
+ return 'delivered'
+ }
+ }
+ const pushChannel: NotificationChannel = {
+ async sendReady() {},
+ async sendPermissionRequest() {},
+ async sendTaskNotification() {},
+ async sendModelError(_session, notification, ctx) {
+ if (ctx?.nativeGate?.sent) {
+ return 'unavailable'
+ }
+ pushCalls.push(notification)
+ return 'delivered'
+ }
+ }
+
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [fcmChannel, pushChannel])
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-4000',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'gate-test',
+ atTs: 4000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(5)
+
+ expect(fcmCalls).toHaveLength(1)
+ expect(pushCalls).toHaveLength(0)
+
+ hub.stop()
+ })
+
+ it('falls back to later model-error channels when native gate stays unset', async () => {
+ const engine = new FakeSyncEngine()
+ const pushCalls: ModelErrorNotification[] = []
+
+ const fcmChannel: NotificationChannel = {
+ async sendReady() {},
+ async sendPermissionRequest() {},
+ async sendTaskNotification() {},
+ async sendModelError() {
+ // Delivered zero - leave nativeGate.sent false
+ return 'failed'
+ }
+ }
+ const pushChannel: NotificationChannel = {
+ async sendReady() {},
+ async sendPermissionRequest() {},
+ async sendTaskNotification() {},
+ async sendModelError(_session, notification, ctx) {
+ if (ctx?.nativeGate?.sent) {
+ return 'unavailable'
+ }
+ pushCalls.push(notification)
+ return 'delivered'
+ }
+ }
+
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [fcmChannel, pushChannel])
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-5000',
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'fallback-test',
+ atTs: 5000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(5)
+
+ expect(pushCalls).toHaveLength(1)
+ expect(pushCalls[0]?.atTs).toBe(5000)
+
+ hub.stop()
+ })
+
+ it('schedules bounded backoff retry when every channel throws (keeps watermark)', async () => {
+ const engine = new FakeSyncEngine()
+ let attempts = 0
+ const channel: NotificationChannel = {
+ async sendReady() {},
+ async sendPermissionRequest() {},
+ async sendTaskNotification() {},
+ async sendModelError() {
+ attempts++
+ throw new Error('transient outage')
+ }
+ }
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], {
+ modelErrorRetryDelaysMs: [25]
+ })
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-6000',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'retry-me',
+ atTs: 6000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(10)
+ expect(attempts).toBe(1)
+
+ // Keepalive session-updated must NOT storm - watermark stays.
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(5)
+ expect(attempts).toBe(1)
+
+ // Backoff timer fires the retry.
+ await sleep(35)
+ expect(attempts).toBe(2)
+
+ hub.stop()
+ })
+
+ it('schedules backoff retry when channels resolve with failed (zero deliveries)', async () => {
+ const engine = new FakeSyncEngine()
+ let attempts = 0
+ const channel: NotificationChannel = {
+ async sendReady() {},
+ async sendPermissionRequest() {},
+ async sendTaskNotification() {},
+ async sendModelError() {
+ attempts++
+ return 'failed'
+ }
+ }
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], {
+ modelErrorRetryDelaysMs: [15]
+ })
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-6500',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'zero-sent',
+ atTs: 6500,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(5)
+ expect(attempts).toBe(1)
+
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(5)
+ expect(attempts).toBe(1)
+
+ await sleep(25)
+ expect(attempts).toBe(2)
+
+ hub.stop()
+ })
+
+ it('retries model-error for inactive sessions via backoff timer', async () => {
+ const engine = new FakeSyncEngine()
+ let attempts = 0
+ const channel: NotificationChannel = {
+ async sendReady() {},
+ async sendPermissionRequest() {},
+ async sendTaskNotification() {},
+ async sendModelError(session) {
+ attempts++
+ // Production channels used to return unavailable when !active —
+ // that marked retries "completed" and dropped the ping.
+ if (attempts === 1) {
+ expect(session.active).toBe(true)
+ return 'failed'
+ }
+ expect(session.active).toBe(false)
+ return 'delivered'
+ }
+ }
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], {
+ modelErrorRetryDelaysMs: [20]
+ })
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-6600',
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ atTs: 6600,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(5)
+ expect(attempts).toBe(1)
+
+ // Go inactive before the retry - timer must still fire AND deliver.
+ engine.setSession({ ...session, active: false })
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(30)
+ expect(attempts).toBe(2)
+
+ hub.stop()
+ })
+
+ it('does not let an obsolete retry schedule over a newer atTs', async () => {
+ const engine = new FakeSyncEngine()
+ const outcomes: number[] = []
+ const channel: NotificationChannel = {
+ async sendReady() {},
+ async sendPermissionRequest() {},
+ async sendTaskNotification() {},
+ async sendModelError(_session, notification) {
+ outcomes.push(notification.atTs)
+ // First error always fails; second succeeds.
+ return notification.atTs === 7000 ? 'failed' : 'delivered'
+ }
+ }
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], {
+ modelErrorRetryDelaysMs: [40]
+ })
+
+ const first = createSession({
+ id: 'session-1',
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-7000',
+ kind: 'canceled',
+ transient: true,
+ rawSnippet: 'first',
+ atTs: 7000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(first)
+ engine.emit({ type: 'session-updated', sessionId: first.id })
+ await sleep(5)
+ expect(outcomes).toEqual([7000])
+
+ // Newer error arrives while first retry is pending.
+ const second = {
+ ...first,
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-8000',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'second',
+ atTs: 8000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ }
+ engine.setSession(second)
+ engine.emit({ type: 'session-updated', sessionId: second.id })
+ await sleep(5)
+ expect(outcomes).toEqual([7000, 8000])
+
+ // Obsolete timer for 7000 must not steal / block retries for 8000.
+ // Force 8000 to fail once so it needs its own retry, then wait.
+ // (8000 already delivered above — verify stale timer is a no-op.)
+ await sleep(50)
+ expect(outcomes.filter((ts) => ts === 7000)).toHaveLength(1)
+ expect(outcomes.filter((ts) => ts === 8000)).toHaveLength(1)
+
+ hub.stop()
+ })
+
+ it('does not re-fire model-error after inactive/resume for the same atTs', async () => {
+ const engine = new FakeSyncEngine()
+ const channel = new StubChannel()
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel])
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-7000',
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ atTs: 7000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(10)
+ expect(channel.modelErrors).toHaveLength(1)
+
+ // Become inactive (clears timers but keeps watermark).
+ engine.setSession({ ...session, active: false })
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(5)
+
+ // Resume with same unacknowledged eventId - must not re-ping.
+ engine.setSession({ ...session, active: true })
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(10)
+ expect(channel.modelErrors).toHaveLength(1)
+
+ hub.stop()
+ })
+
+ it('fires a later eventId even when wall-clock atTs went backwards', async () => {
+ const engine = new FakeSyncEngine()
+ const channel = new StubChannel()
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel])
+
+ const first = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-clock-high',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'first',
+ atTs: 5_000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(first)
+ engine.emit({ type: 'session-updated', sessionId: first.id })
+ await sleep(5)
+ expect(channel.modelErrors).toHaveLength(1)
+
+ // NTP/sleep rewind: newer logical error has a lower atTs.
+ const second = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-clock-low',
+ kind: 'transport_closed',
+ transient: true,
+ rawSnippet: 'second',
+ atTs: 1_000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(second)
+ engine.emit({ type: 'session-updated', sessionId: second.id })
+ await sleep(5)
+
+ expect(channel.modelErrors).toHaveLength(2)
+ expect(channel.modelErrors[1]?.notification.eventId).toBe('evt-clock-low')
+ expect(channel.modelErrors[1]?.notification.atTs).toBe(1_000)
+
+ hub.stop()
+ })
+
+ it('retries notifiedAt persistence after contention without resending channels', async () => {
+ const engine = new FakeSyncEngine()
+ engine.markModelErrorNotifiedFailuresRemaining = 1
+ let sends = 0
+ const channel: NotificationChannel = {
+ async sendReady() {},
+ async sendPermissionRequest() {},
+ async sendTaskNotification() {},
+ async sendModelError() {
+ sends++
+ return 'delivered'
+ }
+ }
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], {
+ modelErrorRetryDelaysMs: [20]
+ })
+ const session = createSession({
+ metadata: {
+ lastModelError: {
+ eventId: 'evt-wm-1',
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'wm-retry',
+ atTs: 9000,
+ priorAssistantClaimsDone: false
+ }
+ } as Session['metadata']
+ })
+ engine.setSession(session)
+ engine.emit({ type: 'session-updated', sessionId: session.id })
+ await sleep(10)
+ expect(sends).toBe(1)
+ expect(engine.modelErrorNotifiedMarks).toHaveLength(0)
+
+ await sleep(40)
+ expect(sends).toBe(1)
+ expect(engine.modelErrorNotifiedMarks).toEqual([
+ { sessionId: session.id, eventId: 'evt-wm-1' }
+ ])
+ expect(engine.getSession(session.id)?.metadata?.lastModelError?.notifiedAt).toEqual(
+ expect.any(Number)
+ )
+
+ hub.stop()
+ })
})
diff --git a/hub/src/notifications/notificationHub.ts b/hub/src/notifications/notificationHub.ts
index f9d627e1e7..174b422d02 100644
--- a/hub/src/notifications/notificationHub.ts
+++ b/hub/src/notifications/notificationHub.ts
@@ -1,6 +1,11 @@
import type { Session, SyncEngine, SyncEvent } from '../sync/syncEngine'
import type { SessionEndReason } from '@hapi/protocol'
-import type { NotificationChannel, NotificationHubOptions, TaskNotification } from './notificationTypes'
+import type {
+ ModelErrorNotification,
+ NotificationChannel,
+ NotificationHubOptions,
+ TaskNotification
+} from './notificationTypes'
import type { NotificationSendContext } from './notificationSendContext'
import { extractMessageEventType, extractTaskNotification } from './eventParsing'
@@ -8,10 +13,27 @@ export class NotificationHub {
private readonly channels: NotificationChannel[]
private readonly readyCooldownMs: number
private readonly permissionDebounceMs: number
+ private readonly modelErrorRetryDelaysMs: number[]
private readonly lastKnownRequests: Map> = new Map()
private readonly notificationDebounce: Map = new Map()
private readonly lastReadyNotificationAt: Map = new Map()
+ /**
+ * sessionId -> the `eventId` of the last `lastModelError` we already
+ * notified for. Fire ONCE per distinct error event; subsequent
+ * `session-updated` storms must not re-trigger. Identity is eventId
+ * (CLI UUID), not wall-clock atTs — clocks can move backwards.
+ *
+ * Kept across failed deliveries while a bounded backoff retry timer
+ * is pending — do not delete it to "retry on next session-updated"
+ * (that storms on keepalive and misses inactive sessions).
+ */
+ private readonly lastModelErrorNotifiedId: Map = new Map()
+ private readonly modelErrorRetryTimers: Map = new Map()
+ private readonly modelErrorRetryAttempts: Map = new Map()
+ /** In-flight notifiedAt persistence tasks (delivery already succeeded). */
+ private readonly modelErrorWatermarkTasks: Map> = new Map()
private unsubscribeSyncEvents: (() => void) | null = null
+ private stopped = false
constructor(
private readonly syncEngine: SyncEngine,
@@ -21,12 +43,33 @@ export class NotificationHub {
this.channels = channels
this.readyCooldownMs = options?.readyCooldownMs ?? 5000
this.permissionDebounceMs = options?.permissionDebounceMs ?? 500
+ this.modelErrorRetryDelaysMs = options?.modelErrorRetryDelaysMs
+ ?? [5_000, 15_000, 45_000, 120_000]
this.unsubscribeSyncEvents = this.syncEngine.subscribe((event) => {
this.handleSyncEvent(event)
})
+ // SyncEngine.reloadAll() emits session-added before NotificationHub
+ // exists. Rehydrate any undelivered model-error alerts (including
+ // inactive sessions) so a hub restart cannot drop a pending page.
+ this.rehydrateUndeliveredModelErrors()
+ }
+
+ private rehydrateUndeliveredModelErrors(): void {
+ for (const session of this.syncEngine.getSessions()) {
+ const error = session.metadata?.lastModelError
+ if (
+ !error
+ || typeof error.acknowledgedAt === 'number'
+ || typeof error.notifiedAt === 'number'
+ ) {
+ continue
+ }
+ this.checkForModelErrorNotification(session)
+ }
}
stop(): void {
+ this.stopped = true
if (this.unsubscribeSyncEvents) {
this.unsubscribeSyncEvents()
this.unsubscribeSyncEvents = null
@@ -38,21 +81,41 @@ export class NotificationHub {
this.notificationDebounce.clear()
this.lastKnownRequests.clear()
this.lastReadyNotificationAt.clear()
+ this.lastModelErrorNotifiedId.clear()
+ for (const timer of this.modelErrorRetryTimers.values()) {
+ clearTimeout(timer)
+ }
+ this.modelErrorRetryTimers.clear()
+ this.modelErrorRetryAttempts.clear()
+ this.modelErrorWatermarkTasks.clear()
}
private handleSyncEvent(event: SyncEvent): void {
if ((event.type === 'session-updated' || event.type === 'session-added') && event.sessionId) {
const session = this.syncEngine.getSession(event.sessionId)
- if (!session || !session.active) {
- this.clearSessionState(event.sessionId)
+ if (!session) {
+ this.clearSessionState(event.sessionId, true)
+ return
+ }
+ if (!session.active) {
+ // Keep lastModelErrorNotifiedId across inactive/resume so the
+ // same eventId does not re-ping when the session comes back.
+ this.clearSessionState(event.sessionId, false)
+ // Still dispatch undelivered model-errors (no notifiedAt) —
+ // emergency pages must not wait for the session to become
+ // active again after a failed delivery + hub restart.
+ this.checkForModelErrorNotification(session)
return
}
this.checkForPermissionNotification(session)
+ // Model-error gating: fire when metadata.lastModelError.eventId
+ // differs from what we last notified for this session.
+ this.checkForModelErrorNotification(session)
return
}
if (event.type === 'session-removed' && event.sessionId) {
- this.clearSessionState(event.sessionId)
+ this.clearSessionState(event.sessionId, true)
return
}
@@ -82,7 +145,7 @@ export class NotificationHub {
}
}
- private clearSessionState(sessionId: string): void {
+ private clearSessionState(sessionId: string, removeModelErrorWatermark = false): void {
const existingTimer = this.notificationDebounce.get(sessionId)
if (existingTimer) {
clearTimeout(existingTimer)
@@ -90,6 +153,184 @@ export class NotificationHub {
}
this.lastKnownRequests.delete(sessionId)
this.lastReadyNotificationAt.delete(sessionId)
+ if (removeModelErrorWatermark) {
+ this.lastModelErrorNotifiedId.delete(sessionId)
+ this.clearModelErrorRetry(sessionId)
+ }
+ }
+
+ private clearModelErrorRetry(sessionId: string): void {
+ const timer = this.modelErrorRetryTimers.get(sessionId)
+ if (timer) {
+ clearTimeout(timer)
+ }
+ this.modelErrorRetryTimers.delete(sessionId)
+ this.modelErrorRetryAttempts.delete(sessionId)
+ }
+
+ private checkForModelErrorNotification(session: Session): void {
+ const lastModelError = session.metadata?.lastModelError
+ if (
+ !lastModelError
+ || typeof lastModelError.eventId !== 'string'
+ || typeof lastModelError.atTs !== 'number'
+ ) {
+ return
+ }
+ // Don't ping for already-acknowledged errors. The web UI sets
+ // acknowledgedAt when the operator dismisses the banner; if they
+ // dismissed and the row gets re-emitted (e.g. a different metadata
+ // field changed), we don't want to re-ring the wrist.
+ if (typeof lastModelError.acknowledgedAt === 'number') {
+ this.clearModelErrorRetry(session.id)
+ return
+ }
+ // Durable watermark (survives hub restart). Seed the in-memory map
+ // so concurrent session-updated storms stay quiet in this process.
+ if (typeof lastModelError.notifiedAt === 'number') {
+ this.lastModelErrorNotifiedId.set(session.id, lastModelError.eventId)
+ this.clearModelErrorRetry(session.id)
+ return
+ }
+ const lastNotifiedId = this.lastModelErrorNotifiedId.get(session.id)
+ if (lastNotifiedId === lastModelError.eventId) {
+ return
+ }
+ const eventId = lastModelError.eventId
+ const atTs = lastModelError.atTs
+ // New error supersedes any in-flight retry for an older eventId.
+ this.clearModelErrorRetry(session.id)
+ // Optimistic watermark: prevents concurrent session-updated storms
+ // from double-firing. On delivery failure we KEEP it and schedule a
+ // bounded backoff retry (do not rely on keepalive session-updated).
+ this.lastModelErrorNotifiedId.set(session.id, eventId)
+
+ const notification: ModelErrorNotification = {
+ kind: lastModelError.kind,
+ transient: lastModelError.transient,
+ rawSnippet: lastModelError.rawSnippet,
+ priorAssistantClaimsDone: Boolean(lastModelError.priorAssistantClaimsDone),
+ eventId,
+ atTs
+ }
+
+ void this.notifyModelError(session, notification).then(async (completed) => {
+ if (this.lastModelErrorNotifiedId.get(session.id) !== eventId) {
+ return
+ }
+ if (completed) {
+ this.persistModelErrorWatermark(session.id, eventId)
+ return
+ }
+ this.scheduleModelErrorRetry(session.id, eventId)
+ }).catch((error) => {
+ console.error('[NotificationHub] Failed to send model-error notification:', error)
+ if (this.lastModelErrorNotifiedId.get(session.id) === eventId) {
+ this.scheduleModelErrorRetry(session.id, eventId)
+ }
+ })
+ }
+
+ /**
+ * Delivery already succeeded — retry only the durable notifiedAt write so
+ * a hub restart cannot re-page. Never re-invokes channels.
+ */
+ private persistModelErrorWatermark(sessionId: string, eventId: string): void {
+ const task = this.runPersistModelErrorWatermark(sessionId, eventId)
+ this.modelErrorWatermarkTasks.set(sessionId, task)
+ void task.finally(() => {
+ if (this.modelErrorWatermarkTasks.get(sessionId) === task) {
+ this.modelErrorWatermarkTasks.delete(sessionId)
+ }
+ })
+ }
+
+ private async runPersistModelErrorWatermark(sessionId: string, eventId: string): Promise {
+ for (const delayMs of [0, ...this.modelErrorRetryDelaysMs]) {
+ if (delayMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
+ }
+ if (this.stopped) {
+ return
+ }
+ if (this.lastModelErrorNotifiedId.get(sessionId) !== eventId) {
+ return
+ }
+ try {
+ await this.syncEngine.markModelErrorNotified(sessionId, eventId)
+ return
+ } catch (error) {
+ console.error('[NotificationHub] Failed to persist model-error notifiedAt:', error)
+ }
+ }
+ }
+
+ private scheduleModelErrorRetry(sessionId: string, eventId: string): void {
+ if (this.modelErrorRetryTimers.has(sessionId)) {
+ return
+ }
+ const attempt = this.modelErrorRetryAttempts.get(sessionId) ?? 0
+ if (attempt >= this.modelErrorRetryDelaysMs.length) {
+ console.error(
+ `[NotificationHub] Exhausted model-error delivery retries for session=${sessionId} eventId=${eventId}`
+ )
+ return
+ }
+ const delayMs = this.modelErrorRetryDelaysMs[attempt] ?? 5_000
+ this.modelErrorRetryAttempts.set(sessionId, attempt + 1)
+ const timer = setTimeout(() => {
+ this.modelErrorRetryTimers.delete(sessionId)
+ void this.retryModelErrorNotification(sessionId, eventId)
+ }, delayMs)
+ this.modelErrorRetryTimers.set(sessionId, timer)
+ }
+
+ private async retryModelErrorNotification(sessionId: string, eventId: string): Promise {
+ const session = this.syncEngine.getSession(sessionId)
+ if (!session) {
+ this.clearModelErrorRetry(sessionId)
+ this.lastModelErrorNotifiedId.delete(sessionId)
+ return
+ }
+ const lastModelError = session.metadata?.lastModelError
+ if (
+ !lastModelError
+ || lastModelError.eventId !== eventId
+ || typeof lastModelError.acknowledgedAt === 'number'
+ ) {
+ this.clearModelErrorRetry(sessionId)
+ return
+ }
+
+ const notification: ModelErrorNotification = {
+ kind: lastModelError.kind,
+ transient: lastModelError.transient,
+ rawSnippet: lastModelError.rawSnippet,
+ priorAssistantClaimsDone: Boolean(lastModelError.priorAssistantClaimsDone),
+ eventId,
+ atTs: lastModelError.atTs
+ }
+
+ try {
+ const completed = await this.notifyModelError(session, notification)
+ // A newer eventId may have taken the watermark while we were in flight.
+ // Do not install an obsolete retry that blocks the latest error.
+ if (this.lastModelErrorNotifiedId.get(sessionId) !== eventId) {
+ return
+ }
+ if (completed) {
+ this.modelErrorRetryAttempts.delete(sessionId)
+ this.persistModelErrorWatermark(sessionId, eventId)
+ return
+ }
+ this.scheduleModelErrorRetry(sessionId, eventId)
+ } catch (error) {
+ console.error('[NotificationHub] Failed to retry model-error notification:', error)
+ if (this.lastModelErrorNotifiedId.get(sessionId) !== eventId) {
+ return
+ }
+ this.scheduleModelErrorRetry(sessionId, eventId)
+ }
}
private getNotifiableSession(sessionId: string): Session | null {
@@ -227,4 +468,30 @@ export class NotificationHub {
}
}
}
+
+ private async notifyModelError(session: Session, notification: ModelErrorNotification): Promise {
+ const ctx: NotificationSendContext = { nativeGate: { sent: false } }
+ let attempted = false
+ let delivered = false
+ for (const channel of this.channels) {
+ if (typeof channel.sendModelError !== 'function') {
+ continue
+ }
+ try {
+ const outcome = await channel.sendModelError(session, notification, ctx)
+ if (outcome !== 'unavailable') {
+ attempted = true
+ }
+ if (outcome === 'delivered') {
+ delivered = true
+ }
+ } catch (error) {
+ attempted = true
+ console.error('[NotificationHub] Failed to send model-error notification:', error)
+ }
+ }
+ // No implementers / all unavailable: keep watermark (avoid retry storm).
+ // At least one channel tried and none delivered: roll back for retry.
+ return !attempted || delivered
+ }
}
diff --git a/hub/src/notifications/notificationTypes.ts b/hub/src/notifications/notificationTypes.ts
index 6130cf30bd..f4565dae1f 100644
--- a/hub/src/notifications/notificationTypes.ts
+++ b/hub/src/notifications/notificationTypes.ts
@@ -7,14 +7,64 @@ export type TaskNotification = {
status?: string
}
+/**
+ * Model error notification: fires when cursor-agent (or another flavor's
+ * runtime) hits an internal model-side failure that HAPI detects either
+ * structurally (typed AcpStderrError, RPC rejection, transport close) or
+ * via the text-classifier fallback for stringified-into-prose errors.
+ *
+ * Higher urgency than ready/task: an operator who walks away from the
+ * web UI MUST get a phone-side / wrist-side ping for this, otherwise the
+ * "all done" green dot lies to them. Banner-only is opt-in (requires
+ * looking); notification is push (regardless of attention).
+ */
+export type ModelErrorNotification = {
+ kind: string // e.g. 'quota_exhausted', 'transport_closed'
+ transient: boolean // retryable hint (rate_limit / canceled / timeout)
+ rawSnippet: string // first 400 chars of the raw error text
+ priorAssistantClaimsDone: boolean // agent said "Done"/"Committed" right before the error
+ eventId: string // metadata.lastModelError.eventId — notify/ack identity
+ atTs: number // display / telemetry only (not monotonic identity)
+}
+
+/**
+ * Outcome of a model-error channel send. Used by NotificationHub to decide
+ * whether to keep or roll back the per-session watermark:
+ * - delivered: at least one destination accepted the ping
+ * - unavailable: channel had nothing to do (no subs, deferred to native, inactive)
+ * - failed: channel tried and every destination failed
+ */
+export type ModelErrorSendOutcome = 'delivered' | 'unavailable' | 'failed'
+
export type NotificationChannel = {
sendReady: (session: Session, ctx?: NotificationSendContext) => Promise
sendPermissionRequest: (session: Session, ctx?: NotificationSendContext) => Promise
sendTaskNotification: (session: Session, notification: TaskNotification, ctx?: NotificationSendContext) => Promise
sendSessionCompletion?: (session: Session, reason: SessionEndReason) => Promise
+ /**
+ * Optional. Channels that don't implement it just skip model-error
+ * pings (matches sendSessionCompletion's pattern). Wire this when
+ * the channel can render a higher-urgency error variant.
+ *
+ * Pass the same NotificationSendContext as ready/permission/task so
+ * FCM can set nativeGate.sent and Web Push can defer (one OS ping).
+ * Return a real delivery outcome so the hub watermark is not consumed
+ * when every destination failed.
+ */
+ sendModelError?: (
+ session: Session,
+ notification: ModelErrorNotification,
+ ctx?: NotificationSendContext
+ ) => Promise
}
export type NotificationHubOptions = {
readyCooldownMs?: number
permissionDebounceMs?: number
+ /**
+ * Backoff delays (ms) for model-error delivery retries after a failed
+ * dispatch. Empty / omitted uses the default ladder. Exhausted delays
+ * keep the watermark so session-updated storms do not re-fire forever.
+ */
+ modelErrorRetryDelaysMs?: number[]
}
diff --git a/hub/src/push-ios/iosPushChannel.test.ts b/hub/src/push-ios/iosPushChannel.test.ts
index 388d9e33f3..fcc4b27e50 100644
--- a/hub/src/push-ios/iosPushChannel.test.ts
+++ b/hub/src/push-ios/iosPushChannel.test.ts
@@ -116,4 +116,26 @@ describe('IosPushNotificationChannel', () => {
expect(payload.title).toBe('Task failed')
expect(payload.body).toContain('exploded')
})
+
+ it('sends model-error with event-specific tag and error severity', async () => {
+ const { channel, service } = makeChannel({ sent: 1, failed: 0, invalidTokens: [] })
+ const ctx: NotificationSendContext = { nativeGate: { sent: false } }
+
+ const outcome = await channel.sendModelError(createSession(), {
+ eventId: 'evt-1',
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'status 429',
+ priorAssistantClaimsDone: false,
+ atTs: 1710000000000
+ }, ctx)
+
+ expect(outcome).toBe('delivered')
+ expect(ctx.nativeGate?.sent).toBe(true)
+ const payload = service.calls[0].payload
+ expect(payload.type).toBe('model-error')
+ expect(payload.severity).toBe('error')
+ expect(payload.tag).toBe('model-error-session-1-evt-1')
+ expect(payload.contractVersion).toBe('1')
+ })
})
diff --git a/hub/src/push-ios/iosPushChannel.ts b/hub/src/push-ios/iosPushChannel.ts
index 639089613d..0452d0d1c1 100644
--- a/hub/src/push-ios/iosPushChannel.ts
+++ b/hub/src/push-ios/iosPushChannel.ts
@@ -1,7 +1,9 @@
import type { Session } from '../sync/syncEngine'
-import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes'
+import type { ModelErrorNotification, ModelErrorSendOutcome, NotificationChannel, TaskNotification } from '../notifications/notificationTypes'
import type { NotificationSendContext } from '../notifications/notificationSendContext'
import { NATIVE_CONTRACT_VERSION, NativeNotificationComposer, type ComposedNativeNotification } from '../notifications/nativeNotificationComposer'
+import { formatModelErrorBody, formatModelErrorTitle } from '../notifications/modelErrorCopy'
+import { getAgentName, getSessionName } from '../notifications/sessionInfo'
import type { Store } from '../store'
import type { IosPushNotificationPayload, IosPushService } from './iosPushService'
@@ -47,6 +49,40 @@ export class IosPushNotificationChannel implements NotificationChannel {
await this.deliver(session, this.composer.composeTask(session, notification), ctx)
}
+ async sendModelError(
+ session: Session,
+ notification: ModelErrorNotification,
+ ctx?: NotificationSendContext
+ ): Promise {
+ const agentName = getAgentName(session)
+ const sessionName = getSessionName(session)
+ const title = formatModelErrorTitle(notification.kind)
+ const body = formatModelErrorBody(notification, { agentName, sessionName })
+ const tag = `model-error-${session.id}-${notification.eventId}`
+
+ const result = await this.iosPushService.sendToNamespace(session.namespace, {
+ type: 'model-error',
+ sessionId: session.id,
+ sessionName,
+ url: `/sessions/${session.id}`,
+ title,
+ body,
+ contractVersion: NATIVE_CONTRACT_VERSION,
+ severity: 'error',
+ tag
+ })
+ if ((result?.sent ?? 0) > 0) {
+ if (ctx?.nativeGate) {
+ ctx.nativeGate.sent = true
+ }
+ return 'delivered'
+ }
+ if ((result?.failed ?? 0) === 0) {
+ return 'unavailable'
+ }
+ return 'failed'
+ }
+
private toPlaintextPayload(composed: ComposedNativeNotification): IosPushNotificationPayload {
return {
type: composed.type,
diff --git a/hub/src/push-ios/iosPushService.test.ts b/hub/src/push-ios/iosPushService.test.ts
index 63580f4283..9ef0be340e 100644
--- a/hub/src/push-ios/iosPushService.test.ts
+++ b/hub/src/push-ios/iosPushService.test.ts
@@ -186,6 +186,10 @@ describe('buildCollapseId', () => {
expect(buildCollapseId('ready', 's1')).toBe('ready-s1')
})
+ it('prefers an explicit tag so model-error events do not collapse', () => {
+ expect(buildCollapseId('model-error', 's1', 'model-error-s1-evt-2')).toBe('model-error-s1-evt-2')
+ })
+
it('truncates to 64 bytes', () => {
const longSession = 'x'.repeat(100)
const collapseId = buildCollapseId('permission-request', longSession)
diff --git a/hub/src/push-ios/iosPushService.ts b/hub/src/push-ios/iosPushService.ts
index ce0ede558e..b94442ddb9 100644
--- a/hub/src/push-ios/iosPushService.ts
+++ b/hub/src/push-ios/iosPushService.ts
@@ -19,6 +19,8 @@ export type IosPushNotificationPayload = {
contractVersion: string
requestId?: string
notifySummary?: string
+ /** Coalescing identity; when set, used as APNs collapse-id. */
+ tag?: string
}
export type IosPushSendResult = {
@@ -33,8 +35,8 @@ const APNS_COLLAPSE_ID_MAX_BYTES = 64
* APNs collapse id: `-`, truncated to 64 bytes on a UTF-8
* character boundary (APNs rejects oversized collapse ids outright).
*/
-export function buildCollapseId(type: string, sessionId: string): string {
- const raw = `${type}-${sessionId}`
+export function buildCollapseId(type: string, sessionId: string, tag?: string): string {
+ const raw = tag && tag.length > 0 ? tag : `${type}-${sessionId}`
if (Buffer.byteLength(raw, 'utf8') <= APNS_COLLAPSE_ID_MAX_BYTES) {
return raw
}
@@ -91,7 +93,7 @@ export class IosPushService {
}
const plaintext = canonicalJson(payload)
- const collapseId = buildCollapseId(payload.type, payload.sessionId)
+ const collapseId = buildCollapseId(payload.type, payload.sessionId, payload.tag)
const invalidTokens: string[] = []
let sent = 0
diff --git a/hub/src/push/pushNotificationChannel.test.ts b/hub/src/push/pushNotificationChannel.test.ts
index bd0d0c330b..4317ed94db 100644
--- a/hub/src/push/pushNotificationChannel.test.ts
+++ b/hub/src/push/pushNotificationChannel.test.ts
@@ -22,6 +22,7 @@ describe('PushNotificationChannel', () => {
{
sendToNamespace: async (namespace: string, payload: PushPayload) => {
pushed.push({ namespace, payload })
+ return { sent: 1, failed: 0, subscriptions: 1 }
}
} as never,
{
@@ -51,6 +52,7 @@ describe('PushNotificationChannel', () => {
{
sendToNamespace: async (namespace: string, payload: PushPayload) => {
pushed.push({ namespace, payload })
+ return { sent: 1, failed: 0, subscriptions: 1 }
}
} as never,
{
@@ -82,6 +84,7 @@ describe('PushNotificationChannel', () => {
{
sendToNamespace: async (namespace: string, payload: PushPayload) => {
pushed.push({ namespace, payload })
+ return { sent: 1, failed: 0, subscriptions: 1 }
}
} as never,
{
@@ -115,6 +118,7 @@ describe('PushNotificationChannel', () => {
{
sendToNamespace: async (namespace: string, payload: PushPayload) => {
pushed.push({ namespace, payload })
+ return { sent: 1, failed: 0, subscriptions: 1 }
}
} as never,
{
@@ -137,6 +141,7 @@ describe('PushNotificationChannel', () => {
{
sendToNamespace: async (namespace: string, payload: PushPayload) => {
pushed.push({ namespace, payload })
+ return { sent: 1, failed: 0, subscriptions: 1 }
}
} as never,
{
@@ -160,6 +165,7 @@ describe('PushNotificationChannel', () => {
{
sendToNamespace: async (namespace: string, payload: PushPayload) => {
pushed.push({ namespace, payload })
+ return { sent: 1, failed: 0, subscriptions: 1 }
}
} as never,
{
@@ -191,4 +197,57 @@ describe('PushNotificationChannel', () => {
expect(toasts).toHaveLength(0)
expect(pushed).toHaveLength(0)
})
+
+ it('sendModelError defers when nativeGate.sent is true', async () => {
+ const pushed: Array<{ namespace: string; payload: PushPayload }> = []
+ const channel = new PushNotificationChannel(
+ {
+ sendToNamespace: async (namespace: string, payload: PushPayload) => {
+ pushed.push({ namespace, payload })
+ return { sent: 1, failed: 0, subscriptions: 1 }
+ }
+ } as never,
+ { sendToast: async () => 0 } as never,
+ { hasVisibleConnection: () => false } as never,
+ ''
+ )
+
+ await channel.sendModelError(createSession(), {
+ eventId: 'evt-1',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'x',
+ priorAssistantClaimsDone: false,
+ atTs: 1
+ }, { nativeGate: { sent: true } })
+
+ expect(pushed).toHaveLength(0)
+ })
+
+ it('sendModelError fires web-push when nativeGate.sent is false', async () => {
+ const pushed: Array<{ namespace: string; payload: PushPayload }> = []
+ const channel = new PushNotificationChannel(
+ {
+ sendToNamespace: async (namespace: string, payload: PushPayload) => {
+ pushed.push({ namespace, payload })
+ return { sent: 1, failed: 0, subscriptions: 1 }
+ }
+ } as never,
+ { sendToast: async () => 0 } as never,
+ { hasVisibleConnection: () => false } as never,
+ ''
+ )
+
+ await channel.sendModelError(createSession(), {
+ eventId: 'evt-2',
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'y',
+ priorAssistantClaimsDone: false,
+ atTs: 2
+ }, { nativeGate: { sent: false } })
+
+ expect(pushed).toHaveLength(1)
+ expect(pushed[0]?.payload.data?.type).toBe('model-error')
+ })
})
diff --git a/hub/src/push/pushNotificationChannel.ts b/hub/src/push/pushNotificationChannel.ts
index bfe751f557..34d45ccb8d 100644
--- a/hub/src/push/pushNotificationChannel.ts
+++ b/hub/src/push/pushNotificationChannel.ts
@@ -1,7 +1,13 @@
import type { Session } from '../sync/syncEngine'
-import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes'
+import type {
+ ModelErrorNotification,
+ ModelErrorSendOutcome,
+ NotificationChannel,
+ TaskNotification
+} from '../notifications/notificationTypes'
import type { NotificationSendContext } from '../notifications/notificationSendContext'
import { getAgentName, getSessionName } from '../notifications/sessionInfo'
+import { formatModelErrorBody, formatModelErrorTitle } from '../notifications/modelErrorCopy'
import type { SSEManager } from '../sse/sseManager'
import type { VisibilityTracker } from '../visibility/visibilityTracker'
import type { PushPayload, PushService } from './pushService'
@@ -135,6 +141,57 @@ export class PushNotificationChannel implements NotificationChannel {
await this.pushService.sendToNamespace(session.namespace, payload)
}
+ async sendModelError(
+ session: Session,
+ notification: ModelErrorNotification,
+ ctx?: NotificationSendContext
+ ): Promise {
+ // No active-session guard: scheduled retries must still deliver after
+ // the session goes inactive (watermark + timer survive that transition).
+
+ if (ctx?.nativeGate?.sent) {
+ this.logBranch('model-error', session.namespace, 'defer-to-native', 'fcm-delivered-this-dispatch')
+ return 'unavailable'
+ }
+
+ const agentName = getAgentName(session)
+ const sessionName = getSessionName(session)
+ const title = formatModelErrorTitle(notification.kind)
+ const body = formatModelErrorBody(notification, { agentName, sessionName })
+ const url = this.buildSessionPath(session.id)
+
+ const payload: PushPayload = {
+ title,
+ body,
+ // Distinct tag from `ready-${id}` so the model-error ping never
+ // collapses into the prior "all done" notification on the same
+ // session. Tag keyed by eventId so distinct errors in the same
+ // session DON'T overwrite each other on the lock screen.
+ tag: `model-error-${session.id}-${notification.eventId}`,
+ data: {
+ type: 'model-error',
+ sessionId: session.id,
+ url
+ }
+ }
+
+ // Skip the in-page toast shortcut for model errors. Toasts are
+ // ephemeral and easy to miss; an error of this severity should
+ // ALWAYS surface as a real push so a backgrounded operator gets
+ // a system-tray ping. The web banner + pulsing-dot already
+ // cover the foreground case. Still defer when FCM already
+ // delivered this dispatch (nativeGate).
+ this.logBranch('model-error', session.namespace, 'web-push-fired')
+ const result = await this.pushService.sendToNamespace(session.namespace, payload)
+ if (result.sent > 0) {
+ return 'delivered'
+ }
+ if (result.subscriptions === 0) {
+ return 'unavailable'
+ }
+ return 'failed'
+ }
+
private buildSessionPath(sessionId: string): string {
return `/sessions/${sessionId}`
}
diff --git a/hub/src/push/pushService.ts b/hub/src/push/pushService.ts
index e44a8fd9ef..9975166a3c 100644
--- a/hub/src/push/pushService.ts
+++ b/hub/src/push/pushService.ts
@@ -38,23 +38,33 @@ export class PushService {
webPush.setVapidDetails(this.subject, this.vapidKeys.publicKey, this.vapidKeys.privateKey)
}
- async sendToNamespace(namespace: string, payload: PushPayload): Promise {
+ async sendToNamespace(namespace: string, payload: PushPayload): Promise<{ sent: number; failed: number; subscriptions: number }> {
const subscriptions = this.store.push.getPushSubscriptionsByNamespace(namespace)
if (subscriptions.length === 0) {
- return
+ return { sent: 0, failed: 0, subscriptions: 0 }
}
const body = JSON.stringify(payload)
- await Promise.all(subscriptions.map((subscription) => {
+ const results = await Promise.all(subscriptions.map((subscription) => {
return this.sendToSubscription(namespace, subscription, body)
}))
+ let sent = 0
+ let failed = 0
+ for (const result of results) {
+ if (result === 'sent') {
+ sent++
+ } else {
+ failed++
+ }
+ }
+ return { sent, failed, subscriptions: subscriptions.length }
}
private async sendToSubscription(
namespace: string,
subscription: StoredSubscription,
body: string
- ): Promise {
+ ): Promise<'sent' | 'failed'> {
const pushSubscription: PushSubscription = {
endpoint: subscription.endpoint,
keys: {
@@ -65,6 +75,7 @@ export class PushService {
try {
await webPush.sendNotification(pushSubscription, body)
+ return 'sent'
} catch (error) {
const statusCode = typeof (error as { statusCode?: unknown }).statusCode === 'number'
? (error as { statusCode: number }).statusCode
@@ -72,10 +83,11 @@ export class PushService {
if (statusCode === 410) {
this.store.push.removePushSubscription(namespace, subscription.endpoint)
- return
+ return 'failed'
}
console.error('[PushService] Failed to send notification:', error)
+ return 'failed'
}
}
}
diff --git a/hub/src/store/sessions.test.ts b/hub/src/store/sessions.test.ts
index 94762c86e5..7996e95f33 100644
--- a/hub/src/store/sessions.test.ts
+++ b/hub/src/store/sessions.test.ts
@@ -488,6 +488,150 @@ describe('updateSessionMetadata: protocol resume token preservation', () => {
expect(metadata?.lifecycleState).toBe('archived')
})
+ it('preserves lastModelError across sparse archive (durable alert state)', () => {
+ const store = makeStore()
+ const lastModelError = {
+ eventId: 'evt-1700000000000',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'Error: T: [resource_exhausted]',
+ atTs: 1_700_000_000_000,
+ priorAssistantClaimsDone: false
+ }
+ const session = store.sessions.getOrCreateSession(
+ 'cursor-model-error-survives',
+ {
+ path: '/tmp/project',
+ host: 'example',
+ flavor: 'cursor',
+ cursorSessionId: 'err-uuid',
+ lastModelError
+ },
+ null,
+ 'default'
+ )
+
+ store.sessions.updateSessionMetadata(
+ session.id,
+ {
+ lifecycleState: 'archived',
+ archivedBy: 'cli',
+ archiveReason: 'Session crashed'
+ },
+ session.metadataVersion,
+ 'default'
+ )
+
+ const metadata = getMetadata(store, session.id) as Record | null
+ expect(metadata?.lastModelError).toEqual(lastModelError)
+ expect(metadata?.lifecycleState).toBe('archived')
+ })
+
+ it('preserves lastModelError.acknowledgedAt against stale CLI rewrite of same eventId', () => {
+ const store = makeStore()
+ const eventId = 'evt-ack-1700000000111'
+ const atTs = 1_700_000_000_111
+ const session = store.sessions.getOrCreateSession(
+ 'cursor-model-error-ack-survives',
+ {
+ path: '/tmp/project',
+ host: 'example',
+ flavor: 'cursor',
+ cursorSessionId: 'ack-uuid',
+ lastModelError: {
+ eventId,
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'Error: T: [resource_exhausted]',
+ atTs,
+ priorAssistantClaimsDone: false,
+ acknowledgedAt: 1_700_000_000_222
+ }
+ },
+ null,
+ 'default'
+ )
+
+ // CLI local snapshot still has the same error without acknowledgedAt.
+ store.sessions.updateSessionMetadata(
+ session.id,
+ {
+ path: '/tmp/project',
+ host: 'example',
+ flavor: 'cursor',
+ cursorSessionId: 'ack-uuid',
+ lastModelError: {
+ eventId,
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'Error: T: [resource_exhausted]',
+ atTs,
+ priorAssistantClaimsDone: false
+ }
+ },
+ session.metadataVersion,
+ 'default'
+ )
+
+ const metadata = getMetadata(store, session.id) as {
+ lastModelError?: { atTs?: number; acknowledgedAt?: number }
+ } | null
+ expect(metadata?.lastModelError?.atTs).toBe(atTs)
+ expect(metadata?.lastModelError?.acknowledgedAt).toBe(1_700_000_000_222)
+ })
+
+ it('preserves lastModelError.notifiedAt against stale CLI rewrite of same eventId', () => {
+ const store = makeStore()
+ const eventId = 'evt-notified-1700000000333'
+ const atTs = 1_700_000_000_333
+ const session = store.sessions.getOrCreateSession(
+ 'cursor-model-error-notified-survives',
+ {
+ path: '/tmp/project',
+ host: 'example',
+ flavor: 'cursor',
+ cursorSessionId: 'notify-uuid',
+ lastModelError: {
+ eventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'Error: T: [resource_exhausted]',
+ atTs,
+ priorAssistantClaimsDone: false,
+ notifiedAt: 1_700_000_000_444
+ }
+ },
+ null,
+ 'default'
+ )
+
+ store.sessions.updateSessionMetadata(
+ session.id,
+ {
+ path: '/tmp/project',
+ host: 'example',
+ flavor: 'cursor',
+ cursorSessionId: 'notify-uuid',
+ lastModelError: {
+ eventId,
+ kind: 'rate_limited',
+ transient: true,
+ rawSnippet: 'Error: T: [resource_exhausted]',
+ atTs,
+ priorAssistantClaimsDone: false
+ }
+ },
+ session.metadataVersion,
+ 'default'
+ )
+
+ const metadata = getMetadata(store, session.id) as {
+ lastModelError?: { atTs?: number; notifiedAt?: number }
+ } | null
+ expect(metadata?.lastModelError?.atTs).toBe(atTs)
+ expect(metadata?.lastModelError?.notifiedAt).toBe(1_700_000_000_444)
+ })
+
it('does not invent path or host when prior had none', () => {
const store = makeStore()
// create with minimal raw metadata (path is technically required by
diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts
index c0d23236de..b4aaa1d247 100644
--- a/hub/src/store/sessions.ts
+++ b/hub/src/store/sessions.ts
@@ -36,6 +36,11 @@ import { updateVersionedField } from './versionedUpdates'
// write-once-keep semantics. Mirror of pickExistingSessionMetadata
// in cli/src/agent/sessionFactory.ts.
//
+// - ALERT_STATE_FIELDS: durable operator-facing alert state that must
+// survive sparse metadata writes (e.g. archive). Without this,
+// lastModelError (banner / amber dot / ack) vanishes when a write
+// omits it — the user never dismissed the error.
+//
// `cursorSessionProtocol` is paired with `cursorSessionId`: protocol is
// tied to a specific chat id, so a write that explicitly sets a new
// `cursorSessionId` must drop a stale prior protocol. Handled in
@@ -65,6 +70,8 @@ const SIMPLE_RESUME_TOKENS = [
'piSessionId'
] as const
+const ALERT_STATE_FIELDS = ['lastModelError'] as const
+
function isPlainObject(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -120,6 +127,67 @@ function preserveCursorProtocolPair(
return merged
}
+/**
+ * Hub-owned fields on lastModelError (ack + delivery watermark) must survive
+ * stale CLI metadata rewrites of the same eventId. Web ack / NotificationHub
+ * write these on the hub copy; the CLI's local snapshot often lacks them.
+ * Identity is eventId — wall-clock atTs is display-only.
+ */
+function preserveModelErrorHubFields(
+ prior: Record,
+ next: Record,
+ merged: Record | null
+): Record | null {
+ const oldError = isPlainObject(prior.lastModelError) ? prior.lastModelError : null
+ const newError = isPlainObject(next.lastModelError) ? next.lastModelError : null
+ if (
+ !oldError
+ || !newError
+ || typeof oldError.eventId !== 'string'
+ || oldError.eventId !== newError.eventId
+ ) {
+ return merged
+ }
+
+ const preserved: Record = { ...newError }
+ let changed = false
+ if (typeof oldError.acknowledgedAt === 'number' && newError.acknowledgedAt === undefined) {
+ preserved.acknowledgedAt = oldError.acknowledgedAt
+ changed = true
+ }
+ if (typeof oldError.notifiedAt === 'number' && newError.notifiedAt === undefined) {
+ preserved.notifiedAt = oldError.notifiedAt
+ changed = true
+ }
+ if (typeof oldError.bridgedForEventId === 'string' && newError.bridgedForEventId === undefined) {
+ preserved.bridgedForEventId = oldError.bridgedForEventId
+ changed = true
+ }
+ if (oldError.retriedAndFailed === true && newError.retriedAndFailed !== true) {
+ preserved.retriedAndFailed = true
+ changed = true
+ }
+ if (oldError.supersededByUserTurn === true && newError.supersededByUserTurn !== true) {
+ preserved.supersededByUserTurn = true
+ changed = true
+ }
+ if (oldError.bridgeable === false && newError.bridgeable !== false) {
+ preserved.bridgeable = false
+ changed = true
+ }
+ if (typeof oldError.lastUserMessage === 'string' && newError.lastUserMessage === undefined) {
+ preserved.lastUserMessage = oldError.lastUserMessage
+ changed = true
+ }
+ if (!changed) {
+ return merged
+ }
+
+ const result = merged ?? { ...next }
+ result.lastModelError = preserved
+ return result
+}
+
export function mergeSessionMetadata(prior: unknown, next: unknown): unknown {
if (!isPlainObject(prior) || !isPlainObject(next)) {
return next
@@ -128,7 +196,9 @@ export function mergeSessionMetadata(prior: unknown, next: unknown): unknown {
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)
+ merged = preserveModelErrorHubFields(prior, next, merged)
return merged ?? next
}
diff --git a/hub/src/sync/autoBridgeReconcile.test.ts b/hub/src/sync/autoBridgeReconcile.test.ts
new file mode 100644
index 0000000000..ba1f5e9965
--- /dev/null
+++ b/hub/src/sync/autoBridgeReconcile.test.ts
@@ -0,0 +1,364 @@
+import { afterEach, describe, expect, it } from 'bun:test'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Store } from '../store'
+import { RpcRegistry } from '../socket/rpcRegistry'
+import {
+ readAutoBridgeTransientModelErrorsEnabled,
+ writeAutoBridgeTransientModelErrorsEnabled
+} from '../config/autoBridgeTransientModelErrors'
+import { SyncEngine } from './syncEngine'
+
+const directories: string[] = []
+
+afterEach(async () => {
+ await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })))
+})
+
+async function waitUntil(
+ predicate: () => boolean,
+ label: string,
+ timeoutMs = 2_000
+): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (Date.now() < deadline) {
+ if (predicate()) {
+ return
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ }
+ throw new Error(`timed out waiting for ${label}`)
+}
+
+describe('cursor auto-bridge reconcile on session-ready', () => {
+ async function setup(opts?: { namespace?: string; flavor?: string }) {
+ const dataDir = await mkdtemp(join(tmpdir(), 'hapi-auto-bridge-reconcile-'))
+ directories.push(dataDir)
+
+ const store = new Store(':memory:')
+ const engine = new SyncEngine(
+ store,
+ {} as never,
+ new RpcRegistry(),
+ { broadcast() {} } as never
+ )
+ engine.setSettingsDataDirForTests(dataDir)
+
+ const session = engine.getOrCreateSession(
+ 'session-cursor-bootstrapping',
+ {
+ path: '/tmp/project',
+ host: 'localhost',
+ machineId: 'machine-1',
+ flavor: opts?.flavor ?? 'cursor'
+ },
+ null,
+ opts?.namespace ?? 'default'
+ )
+
+ const configCalls: Array<{ sessionId: string; config: Record }> = []
+ ;(engine as unknown as {
+ rpcGateway: {
+ requestSessionConfig: (
+ sessionId: string,
+ config: Record
+ ) => Promise
+ }
+ }).rpcGateway.requestSessionConfig = async (sessionId, config) => {
+ configCalls.push({ sessionId, config })
+ return { applied: config }
+ }
+
+ return { engine, session, dataDir, configCalls }
+ }
+
+ it('pushes enable after session-ready when CLI fetched while inactive', async () => {
+ const { engine, session, dataDir, configCalls } = await setup()
+ // Simulate Settings toggle while the row is still inactive (create/get
+ // already handed the CLI the previous false default).
+ expect(session.active).toBe(false)
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, true)
+
+ engine.handleSessionReady({ sid: session.id, time: Date.now() })
+ await waitUntil(() => configCalls.length >= 1, 'session-ready enable fanout')
+
+ expect(configCalls).toEqual([
+ {
+ sessionId: session.id,
+ config: { autoBridgeTransientModelErrors: true }
+ }
+ ])
+ })
+
+ it('pushes disable after session-ready so a stale CLI cannot keep auto-bridging', async () => {
+ const { engine, session, dataDir, configCalls } = await setup()
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, true)
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, false)
+
+ engine.handleSessionReady({ sid: session.id, time: Date.now() })
+ await waitUntil(() => configCalls.length >= 1, 'session-ready disable fanout')
+
+ expect(configCalls).toEqual([
+ {
+ sessionId: session.id,
+ config: { autoBridgeTransientModelErrors: false }
+ }
+ ])
+ })
+
+ it('skips tenant namespaces and non-cursor flavors', async () => {
+ const tenant = await setup({ namespace: 'tenant-a' })
+ await writeAutoBridgeTransientModelErrorsEnabled(tenant.dataDir, true)
+ tenant.engine.handleSessionReady({ sid: tenant.session.id, time: Date.now() })
+ // Fire-and-forget skip path: wait long enough for a mistaken RPC, then assert none.
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ expect(tenant.configCalls).toEqual([])
+
+ const claude = await setup({ flavor: 'claude' })
+ await writeAutoBridgeTransientModelErrorsEnabled(claude.dataDir, true)
+ claude.engine.handleSessionReady({ sid: claude.session.id, time: Date.now() })
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ expect(claude.configCalls).toEqual([])
+ })
+
+ it('reconciles on first inactive → active transition after a toggle while inactive', async () => {
+ const { engine, session, dataDir, configCalls } = await setup()
+ expect(session.active).toBe(false)
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, true)
+
+ engine.handleSessionAlive({ sid: session.id, time: Date.now() })
+ await waitUntil(() => configCalls.length >= 1, 'first-active enable fanout')
+
+ expect(configCalls).toEqual([
+ {
+ sessionId: session.id,
+ config: { autoBridgeTransientModelErrors: true }
+ }
+ ])
+ })
+
+ it('serializes settings fanout with an in-flight session-ready reconcile', async () => {
+ const { engine, session, dataDir, configCalls } = await setup()
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, false)
+
+ const firstRpc = { release: null as (() => void) | null }
+ ;(engine as unknown as {
+ rpcGateway: {
+ requestSessionConfig: (
+ sessionId: string,
+ config: Record
+ ) => Promise
+ }
+ }).rpcGateway.requestSessionConfig = async (sessionId, config) => {
+ configCalls.push({ sessionId, config })
+ if (configCalls.length === 1) {
+ await new Promise((resolve) => {
+ firstRpc.release = resolve
+ })
+ }
+ return { applied: config }
+ }
+
+ const reconcilePromise = engine.reconcileCursorAutoBridgeSetting(session.id)
+ await waitUntil(() => typeof firstRpc.release === 'function', 'first RPC hold')
+
+ // Become active while the first reconcile still holds the lock, then
+ // toggle + fanout so the serialized tail sees the new value.
+ engine.handleSessionAlive({ sid: session.id, time: Date.now() })
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, true)
+ const fanoutPromise = engine.fanoutAutoBridgeTransientModelErrors(true)
+
+ firstRpc.release?.()
+ await Promise.all([reconcilePromise, fanoutPromise])
+ await waitUntil(
+ () => configCalls.at(-1)?.config?.autoBridgeTransientModelErrors === true,
+ 'serialized fanout true'
+ )
+
+ expect(configCalls.some((call) => call.config.autoBridgeTransientModelErrors === true)).toBe(true)
+ expect(configCalls.at(-1)?.config).toEqual({ autoBridgeTransientModelErrors: true })
+ })
+
+ it('heartbeats repair a CLI left enabled after partial fanout + failed rollback', async () => {
+ const dataDir = await mkdtemp(join(tmpdir(), 'hapi-auto-bridge-reconcile-'))
+ directories.push(dataDir)
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, false)
+
+ const store = new Store(':memory:')
+ const engine = new SyncEngine(
+ store,
+ {} as never,
+ new RpcRegistry(),
+ { broadcast() {} } as never
+ )
+ engine.setSettingsDataDirForTests(dataDir)
+
+ const sessionA = engine.getOrCreateSession(
+ 'session-cursor-a',
+ { path: '/tmp/a', host: 'localhost', machineId: 'm1', flavor: 'cursor' },
+ null,
+ 'default'
+ )
+ const sessionB = engine.getOrCreateSession(
+ 'session-cursor-b',
+ { path: '/tmp/b', host: 'localhost', machineId: 'm1', flavor: 'cursor' },
+ null,
+ 'default'
+ )
+
+ const configCalls: Array<{ sessionId: string; config: Record }> = []
+ // Fail B on enable (partial forward) and A on the first post-forward disable
+ // (failed rollback). Later heartbeats must repair A to persisted false.
+ let sawForwardEnableForA = false
+ let applyFinished = false
+ ;(engine as unknown as {
+ rpcGateway: {
+ requestSessionConfig: (
+ sessionId: string,
+ config: Record
+ ) => Promise
+ }
+ }).rpcGateway.requestSessionConfig = async (sessionId, config) => {
+ configCalls.push({ sessionId, config })
+ const enabled = config.autoBridgeTransientModelErrors === true
+ if (enabled && sessionId === sessionA.id) {
+ sawForwardEnableForA = true
+ }
+ if (!applyFinished && enabled && sessionId === sessionB.id) {
+ throw new Error('forward fanout B failed')
+ }
+ if (
+ !applyFinished
+ && sawForwardEnableForA
+ && !enabled
+ && sessionId === sessionA.id
+ ) {
+ throw new Error('rollback fanout A failed')
+ }
+ return { applied: config }
+ }
+
+ engine.handleSessionAlive({ sid: sessionA.id, time: Date.now() })
+ engine.handleSessionAlive({ sid: sessionB.id, time: Date.now() })
+ await waitUntil(
+ () => configCalls.filter((call) => call.sessionId === sessionA.id).length >= 1
+ && configCalls.filter((call) => call.sessionId === sessionB.id).length >= 1,
+ 'first-active reconciles'
+ )
+
+ await expect(
+ engine.applyAutoBridgeTransientModelErrorsSetting(dataDir, true)
+ ).rejects.toThrow('Failed to update every active Cursor session')
+ applyFinished = true
+ expect(await readAutoBridgeTransientModelErrorsEnabled(dataDir)).toBe(false)
+ expect(configCalls.some((call) => (
+ call.sessionId === sessionA.id
+ && call.config.autoBridgeTransientModelErrors === true
+ ))).toBe(true)
+
+ const beforeRepair = configCalls.length
+ // Already active — heartbeat must still retry pending reconcile.
+ engine.handleSessionAlive({ sid: sessionA.id, time: Date.now() + 1 })
+ await waitUntil(
+ () => configCalls.slice(beforeRepair).some((call) => (
+ call.sessionId === sessionA.id
+ && call.config.autoBridgeTransientModelErrors === false
+ )),
+ 'heartbeat repair to false'
+ )
+ })
+
+ it('serializes concurrent apply so a failed PUT cannot clobber a later success', async () => {
+ const { engine, session, dataDir, configCalls } = await setup()
+ engine.handleSessionAlive({ sid: session.id, time: Date.now() })
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, false)
+
+ const failHold = { release: null as (() => void) | null }
+ let failArmed = true
+ ;(engine as unknown as {
+ rpcGateway: {
+ requestSessionConfig: (
+ sessionId: string,
+ config: Record
+ ) => Promise
+ }
+ }).rpcGateway.requestSessionConfig = async (sessionId, config) => {
+ configCalls.push({ sessionId, config })
+ if (failArmed && config.autoBridgeTransientModelErrors === true) {
+ failArmed = false
+ await new Promise((resolve) => {
+ failHold.release = resolve
+ })
+ throw new Error('simulated fanout failure')
+ }
+ return { applied: config }
+ }
+
+ const failing = engine.applyAutoBridgeTransientModelErrorsSetting(dataDir, true)
+ await waitUntil(() => typeof failHold.release === 'function', 'failing apply hold')
+
+ const succeeding = engine.applyAutoBridgeTransientModelErrorsSetting(dataDir, false)
+ failHold.release?.()
+
+ await expect(failing).rejects.toThrow('Failed to update every active Cursor session')
+ await succeeding
+
+ expect(await readAutoBridgeTransientModelErrorsEnabled(dataDir)).toBe(false)
+ expect(configCalls.at(-1)?.config).toEqual({ autoBridgeTransientModelErrors: false })
+ })
+
+ it('reconciles an already-active stored Cursor row on first heartbeat after hub restart', async () => {
+ const dataDir = await mkdtemp(join(tmpdir(), 'hapi-auto-bridge-reconcile-'))
+ directories.push(dataDir)
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, false)
+
+ const store = new Store(':memory:')
+ const boot = new SyncEngine(
+ store,
+ {} as never,
+ new RpcRegistry(),
+ { broadcast() {} } as never
+ )
+ const session = boot.getOrCreateSession(
+ 'session-cursor-restart',
+ { path: '/tmp/restart', host: 'localhost', machineId: 'm1', flavor: 'cursor' },
+ null,
+ 'default'
+ )
+ boot.handleSessionAlive({ sid: session.id, time: Date.now() })
+ store.sessions.setSessionActive(session.id, true, Date.now(), 'default')
+ boot.stop()
+
+ const restarted = new SyncEngine(
+ store,
+ {} as never,
+ new RpcRegistry(),
+ { broadcast() {} } as never
+ )
+ restarted.setSettingsDataDirForTests(dataDir)
+ const configCalls: Array<{ sessionId: string; config: Record }> = []
+ ;(restarted as unknown as {
+ rpcGateway: {
+ requestSessionConfig: (
+ sessionId: string,
+ config: Record
+ ) => Promise
+ }
+ }).rpcGateway.requestSessionConfig = async (sessionId, config) => {
+ configCalls.push({ sessionId, config })
+ return { applied: config }
+ }
+
+ expect(restarted.getSession(session.id)?.active).toBe(true)
+ restarted.handleSessionAlive({ sid: session.id, time: Date.now() + 1 })
+ await waitUntil(
+ () => configCalls.some((call) => (
+ call.sessionId === session.id
+ && call.config.autoBridgeTransientModelErrors === false
+ )),
+ 'post-restart heartbeat reconcile'
+ )
+ restarted.stop()
+ })
+})
diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts
index 656da1dda9..2d9f70fc98 100644
--- a/hub/src/sync/rpcGateway.ts
+++ b/hub/src/sync/rpcGateway.ts
@@ -145,6 +145,7 @@ export class RpcGateway {
effort?: string | null
collaborationMode?: CodexCollaborationMode
copilotAgentMode?: CopilotAgentMode
+ autoBridgeTransientModelErrors?: boolean
}
): Promise {
return await this.sessionRpc(sessionId, RPC_METHODS.SetSessionConfig, config)
@@ -165,6 +166,28 @@ export class RpcGateway {
await this.sessionRpc(sessionId, RPC_METHODS.HandoffLocal, {})
}
+ async bridgeModelError(
+ sessionId: string,
+ payload: {
+ eventId: string
+ atTs: number
+ kind: string
+ rawSnippet: string
+ lastUserMessage?: string
+ priorAssistantClaimsDone: boolean
+ transient: boolean
+ bridgedForEventId?: string
+ retriedAndFailed?: boolean
+ supersededByUserTurn?: boolean
+ bridgeable?: boolean
+ }
+ ): Promise<{ ok: boolean; reason?: string }> {
+ return await this.sessionRpc(sessionId, RPC_METHODS.BridgeModelError, payload) as {
+ ok: boolean
+ reason?: string
+ }
+ }
+
async spawnSession(
machineId: string,
directory: string,
diff --git a/hub/src/sync/sessionCache-merge-scratchlist.test.ts b/hub/src/sync/sessionCache-merge-scratchlist.test.ts
index 7d1bae8d31..ab323a50ef 100644
--- a/hub/src/sync/sessionCache-merge-scratchlist.test.ts
+++ b/hub/src/sync/sessionCache-merge-scratchlist.test.ts
@@ -268,3 +268,92 @@ describe('cascade-delete safety (regression)', () => {
expect(store.scratchlist.list(newSession.id)).toEqual([])
})
})
+
+describe('mergeSessions preserves lastModelError alert state', () => {
+ it('carries unresolved lastModelError from old row onto new (incl hub watermarks)', async () => {
+ const { store, cache } = setup()
+ const { oldSession, newSession } = makeSessions(cache)
+ const eventId = 'evt-merge-1800000000001'
+ const atTs = 1_800_000_000_001
+
+ const result = store.sessions.updateSessionMetadata(
+ oldSession.id,
+ {
+ ...(oldSession.metadata ?? { path: '/tmp/project', host: 'localhost' }),
+ lastModelError: {
+ eventId,
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'Error: T: [resource_exhausted]',
+ atTs,
+ priorAssistantClaimsDone: false,
+ acknowledgedAt: 1_800_000_000_002,
+ notifiedAt: 1_800_000_000_003
+ }
+ },
+ oldSession.metadataVersion,
+ 'default'
+ )
+ expect(result.result).toBe('success')
+ cache.getSession(oldSession.id) // ensure cache exists
+ // Pull store write into cache via refresh path used by mergeSessions.
+ const refreshedOld = store.sessions.getSession(oldSession.id)
+ expect(refreshedOld?.metadata && (refreshedOld.metadata as { lastModelError?: { atTs: number } }).lastModelError?.atTs).toBe(atTs)
+
+ // Re-load old session into cache from store (mergeSessions reads stored rows).
+ await cache.mergeSessions(oldSession.id, newSession.id, 'default')
+
+ const merged = cache.getSession(newSession.id)?.metadata?.lastModelError
+ expect(merged?.eventId).toBe(eventId)
+ expect(merged?.atTs).toBe(atTs)
+ expect(merged?.acknowledgedAt).toBe(1_800_000_000_002)
+ expect(merged?.notifiedAt).toBe(1_800_000_000_003)
+ expect(merged?.kind).toBe('quota_exhausted')
+ })
+
+ it('keeps the new row eventId when atTs is lower than the old row (clock skew)', async () => {
+ const { store, cache } = setup()
+ const { oldSession, newSession } = makeSessions(cache)
+
+ store.sessions.updateSessionMetadata(
+ oldSession.id,
+ {
+ ...(oldSession.metadata ?? { path: '/tmp/project', host: 'localhost' }),
+ lastModelError: {
+ eventId: 'evt-old-high-clock',
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'old',
+ atTs: 9_000,
+ priorAssistantClaimsDone: false,
+ notifiedAt: 9_001
+ }
+ },
+ oldSession.metadataVersion,
+ 'default'
+ )
+ store.sessions.updateSessionMetadata(
+ newSession.id,
+ {
+ ...(newSession.metadata ?? { path: '/tmp/project', host: 'localhost' }),
+ lastModelError: {
+ eventId: 'evt-new-low-clock',
+ kind: 'transport_closed',
+ transient: true,
+ rawSnippet: 'new',
+ atTs: 1_000,
+ priorAssistantClaimsDone: false
+ }
+ },
+ newSession.metadataVersion,
+ 'default'
+ )
+
+ await cache.mergeSessions(oldSession.id, newSession.id, 'default')
+
+ const merged = cache.getSession(newSession.id)?.metadata?.lastModelError
+ expect(merged?.eventId).toBe('evt-new-low-clock')
+ expect(merged?.atTs).toBe(1_000)
+ expect(merged?.kind).toBe('transport_closed')
+ })
+})
diff --git a/hub/src/sync/sessionCache-model-error-notified.test.ts b/hub/src/sync/sessionCache-model-error-notified.test.ts
new file mode 100644
index 0000000000..2126382221
--- /dev/null
+++ b/hub/src/sync/sessionCache-model-error-notified.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it, spyOn } from 'bun:test'
+import type { SyncEvent } from '@hapi/protocol/types'
+import { Store } from '../store'
+import type { EventPublisher } from './eventPublisher'
+import { SessionCache } from './sessionCache'
+import { NotificationHub } from '../notifications/notificationHub'
+import type { SyncEngine, Session } from './syncEngine'
+import type {
+ ModelErrorNotification,
+ ModelErrorSendOutcome,
+ NotificationChannel
+} from '../notifications/notificationTypes'
+
+function createCapturingPublisher(events: SyncEvent[]): EventPublisher {
+ return {
+ emit: (event: SyncEvent) => {
+ events.push(event)
+ }
+ } as unknown as EventPublisher
+}
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
+
+describe('SessionCache.acknowledgeModelError', () => {
+ it('retries version-mismatch then persists acknowledgedAt', async () => {
+ const store = new Store(':memory:')
+ const cache = new SessionCache(store, createCapturingPublisher([]))
+ const eventId = 'evt-ack-9002'
+ const atTs = 9_002
+ const session = cache.getOrCreateSession(
+ 'model-error-ack-retry',
+ {
+ path: '/tmp/project',
+ host: 'localhost',
+ flavor: 'cursor',
+ lastModelError: {
+ eventId,
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'Error: T: [resource_exhausted]',
+ atTs,
+ priorAssistantClaimsDone: false
+ }
+ },
+ null,
+ 'default'
+ )
+
+ let calls = 0
+ const original = store.sessions.updateSessionMetadata.bind(store.sessions)
+ spyOn(store.sessions, 'updateSessionMetadata').mockImplementation((...args) => {
+ calls += 1
+ if (calls === 1) {
+ return {
+ result: 'version-mismatch' as const,
+ version: session.metadataVersion,
+ value: session.metadata
+ }
+ }
+ return original(...args)
+ })
+
+ await cache.acknowledgeModelError(session.id, eventId)
+
+ expect(calls).toBeGreaterThanOrEqual(2)
+ expect(cache.getSession(session.id)?.metadata?.lastModelError?.acknowledgedAt).toEqual(
+ expect.any(Number)
+ )
+ })
+})
+
+describe('SessionCache.markModelErrorNotified', () => {
+ it('retries version-mismatch then persists notifiedAt so a fresh hub does not redeliver', async () => {
+ const store = new Store(':memory:')
+ const cache = new SessionCache(store, createCapturingPublisher([]))
+ const eventId = 'evt-notified-9001'
+ const atTs = 9_001
+ const session = cache.getOrCreateSession(
+ 'model-error-notified-retry',
+ {
+ path: '/tmp/project',
+ host: 'localhost',
+ flavor: 'cursor',
+ lastModelError: {
+ eventId,
+ kind: 'quota_exhausted',
+ transient: false,
+ rawSnippet: 'Error: T: [resource_exhausted]',
+ atTs,
+ priorAssistantClaimsDone: false
+ }
+ },
+ null,
+ 'default'
+ )
+
+ let calls = 0
+ const original = store.sessions.updateSessionMetadata.bind(store.sessions)
+ spyOn(store.sessions, 'updateSessionMetadata').mockImplementation((...args) => {
+ calls += 1
+ if (calls === 1) {
+ return {
+ result: 'version-mismatch' as const,
+ version: session.metadataVersion,
+ value: session.metadata
+ }
+ }
+ return original(...args)
+ })
+
+ await cache.markModelErrorNotified(session.id, eventId)
+
+ expect(calls).toBeGreaterThanOrEqual(2)
+ const refreshed = cache.getSession(session.id)
+ expect(refreshed?.metadata?.lastModelError?.notifiedAt).toEqual(expect.any(Number))
+ expect(refreshed?.metadata?.lastModelError?.atTs).toBe(atTs)
+
+ class Channel implements NotificationChannel {
+ readonly modelErrors: ModelErrorNotification[] = []
+ async sendReady() {}
+ async sendPermissionRequest() {}
+ async sendTaskNotification() {}
+ async sendSessionCompletion() {}
+ async sendModelError(
+ _session: Session,
+ notification: ModelErrorNotification
+ ): Promise {
+ this.modelErrors.push(notification)
+ return 'delivered'
+ }
+ }
+
+ const engine = {
+ listeners: new Set<(e: { type: string; sessionId: string }) => void>(),
+ getSession: (id: string) => cache.getSession(id),
+ getSessions: () => {
+ const s = cache.getSession(session.id)
+ return s ? [s] : []
+ },
+ subscribe(listener: (e: { type: string; sessionId: string }) => void) {
+ this.listeners.add(listener)
+ return () => this.listeners.delete(listener)
+ },
+ async markModelErrorNotified() {},
+ emit(sessionId: string) {
+ for (const listener of this.listeners) {
+ listener({ type: 'session-updated', sessionId })
+ }
+ }
+ }
+ const channel = new Channel()
+ const hub = new NotificationHub(engine as unknown as SyncEngine, [channel])
+ engine.emit(session.id)
+ await sleep(10)
+ expect(channel.modelErrors).toHaveLength(0)
+ hub.stop()
+ })
+})
diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts
index cbab45975f..2900fdb733 100644
--- a/hub/src/sync/sessionCache.ts
+++ b/hub/src/sync/sessionCache.ts
@@ -904,6 +904,150 @@ export class SessionCache {
throw new Error('Session was modified concurrently. Please try again.')
}
+ async markModelErrorBridged(sessionId: string, eventId: string): Promise {
+ const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId)
+ if (!session) {
+ throw new Error('Session not found')
+ }
+
+ const currentMetadata = session.metadata ?? { path: '', host: '' }
+ const currentError = currentMetadata.lastModelError
+ if (!currentError || currentError.eventId !== eventId) {
+ return
+ }
+ if (currentError.bridgedForEventId === eventId) {
+ return
+ }
+
+ const newMetadata = {
+ ...currentMetadata,
+ lastModelError: {
+ ...currentError,
+ bridgedForEventId: eventId
+ }
+ }
+
+ const result = this.store.sessions.updateSessionMetadata(
+ sessionId,
+ newMetadata,
+ session.metadataVersion,
+ session.namespace,
+ { touchUpdatedAt: false }
+ )
+
+ if (result.result === 'error') {
+ throw new Error('Failed to update session metadata')
+ }
+
+ if (result.result === 'version-mismatch') {
+ throw new Error('Session was modified concurrently. Please try again.')
+ }
+
+ this.refreshSession(sessionId)
+ }
+
+ async acknowledgeModelError(sessionId: string, eventId: string): Promise {
+ // Bind dismiss to the error the client actually showed. If a newer
+ // lastModelError replaced it between render and click, refuse so we
+ // don't silently ack the unseen error (banner/dot would vanish).
+ // Identity is eventId (not wall-clock atTs). Retry version-mismatch
+ // (CLI metadata race) like markModelErrorNotified.
+ for (let attempt = 0; attempt < METADATA_RETRY_ATTEMPTS; attempt += 1) {
+ const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId)
+ if (!session) {
+ throw new Error('Session not found')
+ }
+
+ const currentMetadata = session.metadata ?? { path: '', host: '' }
+ const currentError = currentMetadata.lastModelError
+ if (!currentError) {
+ return
+ }
+ if (currentError.eventId !== eventId) {
+ throw new Error('Model error changed; refresh before acknowledging.')
+ }
+ if (typeof currentError.acknowledgedAt === 'number') {
+ return
+ }
+
+ const result = this.store.sessions.updateSessionMetadata(
+ sessionId,
+ {
+ ...currentMetadata,
+ lastModelError: {
+ ...currentError,
+ acknowledgedAt: Date.now()
+ }
+ },
+ session.metadataVersion,
+ session.namespace,
+ { touchUpdatedAt: false }
+ )
+
+ if (result.result === 'success') {
+ this.refreshSession(sessionId)
+ return
+ }
+ if (result.result === 'error') {
+ throw new Error('Failed to update session metadata')
+ }
+
+ this.refreshSession(sessionId)
+ }
+
+ throw new Error('Session was modified concurrently. Please try again.')
+ }
+
+ /**
+ * Persist delivery watermark on lastModelError so hub restarts do not
+ * re-page the same unacknowledged eventId (in-memory Map alone is lost).
+ * No-ops when the error changed under us — a different eventId owns the page.
+ * Retries on version-mismatch (same pattern as renameSession / #919).
+ */
+ async markModelErrorNotified(sessionId: string, eventId: string): Promise {
+ for (let attempt = 0; attempt < METADATA_RETRY_ATTEMPTS; attempt += 1) {
+ const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId)
+ if (!session) {
+ return
+ }
+
+ const currentMetadata = session.metadata ?? { path: '', host: '' }
+ const currentError = currentMetadata.lastModelError
+ if (!currentError || currentError.eventId !== eventId) {
+ return
+ }
+ if (typeof currentError.notifiedAt === 'number') {
+ return
+ }
+
+ const result = this.store.sessions.updateSessionMetadata(
+ sessionId,
+ {
+ ...currentMetadata,
+ lastModelError: {
+ ...currentError,
+ notifiedAt: Date.now()
+ }
+ },
+ session.metadataVersion,
+ session.namespace,
+ { touchUpdatedAt: false }
+ )
+
+ if (result.result === 'success') {
+ this.refreshSession(sessionId)
+ return
+ }
+ if (result.result === 'error') {
+ throw new Error('Failed to persist model-error notification')
+ }
+
+ this.refreshSession(sessionId)
+ }
+
+ throw new Error('Model-error notification metadata stayed contended')
+ }
+
/**
* Clear archive-related metadata on an archived session so it can be resumed.
* - Removes `lifecycleState`, `archivedBy`, `archiveReason`, and stamps
@@ -1182,24 +1326,25 @@ export class SessionCache {
this.emitScratchlistChanged(oldSessionId)
}
- const mergedMetadata = this.mergeSessionMetadata(oldStored.metadata, newStored.metadata)
- if (mergedMetadata !== null && mergedMetadata !== newStored.metadata) {
- for (let attempt = 0; attempt < 2; attempt += 1) {
- const latest = this.store.sessions.getSessionByNamespace(newSessionId, namespace)
- if (!latest) break
- const result = this.store.sessions.updateSessionMetadata(
- newSessionId,
- mergedMetadata,
- latest.metadataVersion,
- namespace,
- { touchUpdatedAt: false }
- )
- if (result.result === 'success') {
- break
- }
- if (result.result === 'error') {
- break
- }
+ // Recompute merge against the live target row each attempt — a newer
+ // lastModelError (or other field) can land on newSessionId between the
+ // initial read and this write (version-mismatch retry).
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ const latest = this.store.sessions.getSessionByNamespace(newSessionId, namespace)
+ if (!latest) break
+ const mergedMetadata = this.mergeSessionMetadata(oldStored.metadata, latest.metadata)
+ if (mergedMetadata === null || mergedMetadata === latest.metadata) {
+ break
+ }
+ const result = this.store.sessions.updateSessionMetadata(
+ newSessionId,
+ mergedMetadata,
+ latest.metadataVersion,
+ namespace,
+ { touchUpdatedAt: false }
+ )
+ if (result.result === 'success' || result.result === 'error') {
+ break
}
}
@@ -1379,6 +1524,42 @@ export class SessionCache {
changed = true
}
+ // Preserve durable model-error alert state across resume/dedup row merges.
+ // Identity is eventId (wall-clock atTs is display-only and can go
+ // backwards after NTP/sleep). Carry old when new has none; when both
+ // share an eventId, merge hub watermarks.
+ type ModelErrorState = {
+ eventId?: string
+ atTs?: number
+ acknowledgedAt?: number
+ notifiedAt?: number
+ [key: string]: unknown
+ }
+ const oldError = oldObj.lastModelError as ModelErrorState | undefined
+ const newError = newObj.lastModelError as ModelErrorState | undefined
+ const oldId = typeof oldError?.eventId === 'string' ? oldError.eventId : null
+ const newId = typeof newError?.eventId === 'string' ? newError.eventId : null
+ if (oldError && oldId && !newError) {
+ merged.lastModelError = oldError
+ changed = true
+ } else if (oldError && newError && oldId && newId && oldId === newId) {
+ merged.lastModelError = {
+ ...oldError,
+ ...newError,
+ acknowledgedAt: newError.acknowledgedAt ?? oldError.acknowledgedAt,
+ notifiedAt: newError.notifiedAt ?? oldError.notifiedAt,
+ bridgedForEventId: newError.bridgedForEventId ?? oldError.bridgedForEventId,
+ retriedAndFailed: newError.retriedAndFailed === true || oldError.retriedAndFailed === true,
+ supersededByUserTurn: newError.supersededByUserTurn === true
+ || oldError.supersededByUserTurn === true,
+ bridgeable: newError.bridgeable === false || oldError.bridgeable === false
+ ? false
+ : (newError.bridgeable ?? oldError.bridgeable),
+ lastUserMessage: newError.lastUserMessage ?? oldError.lastUserMessage
+ }
+ changed = true
+ }
+
return changed ? merged : newMetadata
}
diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts
index 42067f5a10..7d183c55f8 100644
--- a/hub/src/sync/syncEngine.ts
+++ b/hub/src/sync/syncEngine.ts
@@ -60,6 +60,11 @@ import {
} from './rpcGateway'
import { SessionCache } from './sessionCache'
import { ingestNotifySummaryFromMessage } from './workGraphNotifyIngest'
+import {
+ readAutoBridgeTransientModelErrorsEnabled,
+ writeAutoBridgeTransientModelErrorsEnabled
+} from '../config/autoBridgeTransientModelErrors'
+import { getConfiguration } from '../configuration'
type PiResumeAttempt = NonNullable['piResumeAttempt']>
type PtyResumeAttempt = NonNullable['ptyResumeAttempt']>
@@ -201,6 +206,15 @@ export class SyncEngine {
* Defaults to "1" for unit tests; startHub overwrites with getOrCreateOwnerId().
*/
private hubOwnerUserId: string = '1'
+ /** Test-only settings root so session-ready reconcile can run without full hub boot. */
+ private settingsDataDirForTests: string | null = null
+ /** Serialize settings write/fanout with first-activation / session-ready reconcile. */
+ private autoBridgeConfigTail: Promise = Promise.resolve()
+ /**
+ * Active Cursor sessions whose last auto-bridge Settings RPC failed.
+ * Heartbeats retry persisted desired state until reconcile succeeds.
+ */
+ private readonly pendingAutoBridgeReconcile = new Set()
constructor(
private readonly store: Store,
@@ -522,9 +536,16 @@ export class SyncEngine {
serviceTier?: string | null
collaborationMode?: CodexCollaborationMode
}): void {
+ const before = this.sessionCache.getSession(payload.sid)
+ const wasActive = before?.active === true
this.sessionCache.handleSessionAlive(payload)
this.messageService.replayImmediateQueuedMessages(payload.sid)
this.triggerDedupIfNeeded(payload.sid)
+ // First inactive → active: catch toggles while inactive (fanout is
+ // active-only). Also retry sessions whose prior fanout/rollback RPC failed.
+ if (!wasActive || this.pendingAutoBridgeReconcile.has(payload.sid)) {
+ void this.reconcileCursorAutoBridgeSetting(payload.sid)
+ }
}
handleSessionReady(payload: { sid: string; time: number }): void {
@@ -538,9 +559,123 @@ export class SyncEngine {
})
.catch(() => {})
}
+ // session-ready fires after set-session-config is registered (alive can
+ // race earlier). Re-read under the same lock as Settings fanout.
+ void this.reconcileCursorAutoBridgeSetting(payload.sid)
this.triggerDedupIfNeeded(payload.sid)
}
+ /** @internal test hook — hub settings dataDir without createConfiguration(). */
+ setSettingsDataDirForTests(dataDir: string | null): void {
+ this.settingsDataDirForTests = dataDir
+ }
+
+ /**
+ * Owner hub setting changed: push to every active default-namespace Cursor CLI.
+ * Serialized with first-activation / session-ready reconcile.
+ */
+ async fanoutAutoBridgeTransientModelErrors(enabled: boolean): Promise {
+ await this.withAutoBridgeConfigLock(async () => {
+ await this.pushAutoBridgeSettingToActiveCursorSessions(enabled)
+ })
+ }
+
+ /**
+ * Persist the owner auto-bridge pref and fanout under one lock so concurrent
+ * Settings PUTs cannot interleave disk write / rollback / RPC push.
+ */
+ async applyAutoBridgeTransientModelErrorsSetting(
+ dataDir: string,
+ enabled: boolean
+ ): Promise {
+ await this.withAutoBridgeConfigLock(async () => {
+ const previous = await readAutoBridgeTransientModelErrorsEnabled(dataDir)
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, enabled)
+ try {
+ await this.pushAutoBridgeSettingToActiveCursorSessions(enabled)
+ } catch (error) {
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, previous)
+ await this.pushAutoBridgeSettingToActiveCursorSessions(previous).catch(() => {})
+ throw error
+ }
+ })
+ }
+
+ private async pushAutoBridgeSettingToActiveCursorSessions(enabled: boolean): Promise {
+ const targets = this.sessionCache.getSessions().filter(
+ (session) => session.active
+ && session.namespace === 'default'
+ && session.metadata?.flavor === 'cursor'
+ )
+ const results = await Promise.allSettled(
+ targets.map((session) => this.rpcGateway.requestSessionConfig(session.id, {
+ autoBridgeTransientModelErrors: enabled
+ }))
+ )
+ results.forEach((result, index) => {
+ const id = targets[index]!.id
+ if (result.status === 'rejected') {
+ this.pendingAutoBridgeReconcile.add(id)
+ } else {
+ this.pendingAutoBridgeReconcile.delete(id)
+ }
+ })
+ if (results.some((result) => result.status === 'rejected')) {
+ throw new Error('Failed to update every active Cursor session')
+ }
+ }
+
+ /**
+ * Push the current owner hub auto-bridge pref to one Cursor CLI.
+ * Best-effort: never throws into the socket path.
+ */
+ async reconcileCursorAutoBridgeSetting(sessionId: string): Promise {
+ try {
+ await this.withAutoBridgeConfigLock(async () => {
+ const session = this.sessionCache.getSession(sessionId)
+ ?? this.sessionCache.refreshSession(sessionId)
+ if (!session) {
+ return
+ }
+ if (session.namespace !== 'default' || session.metadata?.flavor !== 'cursor') {
+ return
+ }
+ const dataDir = this.resolveSettingsDataDir()
+ if (!dataDir) {
+ return
+ }
+ const enabled = await readAutoBridgeTransientModelErrorsEnabled(dataDir)
+ await this.rpcGateway.requestSessionConfig(sessionId, {
+ autoBridgeTransientModelErrors: enabled
+ })
+ this.pendingAutoBridgeReconcile.delete(sessionId)
+ })
+ } catch (error) {
+ this.pendingAutoBridgeReconcile.add(sessionId)
+ console.warn(
+ `[sync] failed to reconcile auto-bridge setting for session ${sessionId}`,
+ error
+ )
+ }
+ }
+
+ private withAutoBridgeConfigLock(fn: () => Promise): Promise {
+ const run = this.autoBridgeConfigTail.then(fn, fn)
+ this.autoBridgeConfigTail = run.then(() => undefined, () => undefined)
+ return run
+ }
+
+ private resolveSettingsDataDir(): string | null {
+ if (this.settingsDataDirForTests) {
+ return this.settingsDataDirForTests
+ }
+ try {
+ return getConfiguration().dataDir
+ } catch {
+ return null
+ }
+ }
+
clearQueuedThinkingGrace(sessionId: string): void {
this.sessionCache.clearQueuedThinkingGrace(sessionId)
}
@@ -966,6 +1101,25 @@ export class SyncEngine {
private reloadAll(): void {
this.sessionCache.reloadAll()
this.machineCache.reloadAll()
+ this.queuePersistedActiveCursorAutoBridgeReconcile()
+ }
+
+ /**
+ * Hub restart: in-memory pendingAutoBridgeReconcile is empty. If SQLite
+ * restored a Cursor row as already active, the first heartbeat is not an
+ * inactive→active transition — seed those ids so handleSessionAlive still
+ * pushes persisted settings.json.
+ */
+ private queuePersistedActiveCursorAutoBridgeReconcile(): void {
+ for (const session of this.sessionCache.getSessions()) {
+ if (
+ session.active
+ && session.namespace === 'default'
+ && session.metadata?.flavor === 'cursor'
+ ) {
+ this.pendingAutoBridgeReconcile.add(session.id)
+ }
+ }
}
getOrCreateSession(
@@ -1869,6 +2023,61 @@ export class SyncEngine {
await this.sessionCache.updateSessionSummary(sessionId, text)
}
+ async acknowledgeModelError(sessionId: string, eventId: string): Promise {
+ await this.sessionCache.acknowledgeModelError(sessionId, eventId)
+ }
+
+ async markModelErrorNotified(sessionId: string, eventId: string): Promise {
+ await this.sessionCache.markModelErrorNotified(sessionId, eventId)
+ }
+
+ async bridgeModelError(sessionId: string, eventId: string): Promise<{ ok: boolean; reason?: string }> {
+ const session = this.sessionCache.refreshSession(sessionId)
+ ?? this.sessionCache.getSession(sessionId)
+ if (!session) {
+ throw new Error('Session not found')
+ }
+
+ const err = session.metadata?.lastModelError
+ if (!err) {
+ throw new Error('No model error to bridge')
+ }
+ if (err.eventId !== eventId) {
+ throw new Error('Model error changed; refresh before bridging.')
+ }
+ if (!err.transient) {
+ throw new Error('Model error is not transient')
+ }
+ if (err.bridgedForEventId === err.eventId) {
+ throw new Error('Model error was already bridged')
+ }
+ if (err.retriedAndFailed) {
+ throw new Error('Bridge already failed for this error')
+ }
+ if (err.supersededByUserTurn) {
+ throw new Error('Model error was superseded by a newer turn')
+ }
+ if (err.bridgeable === false) {
+ throw new Error('Model error is not bridgeable')
+ }
+
+ // Do not mark bridgedForEventId here — CLI persists recovery only after
+ // the bridge prompt actually succeeds.
+ return await this.rpcGateway.bridgeModelError(sessionId, {
+ eventId: err.eventId,
+ atTs: err.atTs,
+ kind: err.kind,
+ rawSnippet: err.rawSnippet,
+ lastUserMessage: err.lastUserMessage,
+ priorAssistantClaimsDone: err.priorAssistantClaimsDone,
+ transient: err.transient,
+ bridgedForEventId: err.bridgedForEventId,
+ retriedAndFailed: err.retriedAndFailed,
+ supersededByUserTurn: err.supersededByUserTurn,
+ bridgeable: err.bridgeable
+ })
+ }
+
async deleteSession(sessionId: string): Promise {
await this.sessionCache.deleteSession(sessionId)
}
@@ -1883,6 +2092,7 @@ export class SyncEngine {
serviceTier?: string | null
collaborationMode?: CodexCollaborationMode
copilotAgentMode?: CopilotAgentMode
+ autoBridgeTransientModelErrors?: boolean
}
): Promise {
const session = this.sessionCache.getSession(sessionId)
diff --git a/hub/src/sync/syncEngineReopenPreservesPtyId.test.ts b/hub/src/sync/syncEngineReopenPreservesPtyId.test.ts
index 1f328d3b3a..bde878a0e8 100644
--- a/hub/src/sync/syncEngineReopenPreservesPtyId.test.ts
+++ b/hub/src/sync/syncEngineReopenPreservesPtyId.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, beforeEach } from 'bun:test'
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import type { Session } from '@hapi/protocol/types'
import { Store } from '../store'
import { RpcRegistry } from '../socket/rpcRegistry'
@@ -28,6 +28,16 @@ describe('SyncEngine reopen/resume PTY session id preservation', () => {
const NAMESPACE = 'default'
+ /** Minimal MachineCache stand-in; must include expireInactive (SyncEngine's 5s timer). */
+ function fakeMachineCache() {
+ return {
+ getOnlineMachinesByNamespace: () => [
+ { id: 'machine-x', metadata: { host: 'localhost' } }
+ ],
+ expireInactive: () => {}
+ }
+ }
+
function baseMetadata(overrides: Record = {}): Record {
return {
path: '/tmp/proj',
@@ -50,13 +60,7 @@ describe('SyncEngine reopen/resume PTY session id preservation', () => {
function installFakeRunner(): void {
const cache = (engine as unknown as { sessionCache: import('./sessionCache').SessionCache }).sessionCache
- ;(engine as unknown as { machineCache: unknown }).machineCache = {
- getOnlineMachinesByNamespace: () => [
- { id: 'machine-x', metadata: { host: 'localhost' } }
- ],
- expireInactive: () => {}
- }
- ;(engine as unknown as { waitForSessionActive: unknown }).waitForSessionActive = async () => true
+ ;(engine as unknown as { machineCache: unknown }).machineCache = fakeMachineCache() ;(engine as unknown as { waitForSessionActive: unknown }).waitForSessionActive = async () => true
;(engine as unknown as { waitForSessionReady: unknown }).waitForSessionReady = async () => 'ready'
;(engine as unknown as { rpcGateway: { spawnSession: unknown } }).rpcGateway.spawnSession =
async (...args: unknown[]) => {
@@ -99,6 +103,10 @@ describe('SyncEngine reopen/resume PTY session id preservation', () => {
installFakeRunner()
})
+ afterEach(() => {
+ engine.stop()
+ })
+
it('clears stale readiness before spawning the same-id PTY replacement', async () => {
const sessionId = insertSession(
'pty-session-stale-ready',
@@ -236,11 +244,7 @@ describe('SyncEngine reopen/resume PTY session id preservation', () => {
engine.handleSessionAlive({ sid: sessionId, time: Date.now() })
const restarted = new SyncEngine(store, {} as never, new RpcRegistry(), { broadcast() {} } as never)
- ;(restarted as any).machineCache = {
- getOnlineMachinesByNamespace: () => [{ id: 'machine-x', metadata: { host: 'localhost' } }],
- expireInactive: () => {}
- }
- ;(restarted as any).rpcGateway.stopRunnerSession = async () => 'still_alive'
+ ;(restarted as any).machineCache = fakeMachineCache() ;(restarted as any).rpcGateway.stopRunnerSession = async () => 'still_alive'
const result = await restarted.reopenSession(sessionId, NAMESPACE)
@@ -262,11 +266,7 @@ describe('SyncEngine reopen/resume PTY session id preservation', () => {
engine.handleSessionAlive({ sid: sessionId, time: Date.now() })
const restarted = new SyncEngine(store, {} as never, new RpcRegistry(), { broadcast() {} } as never)
- ;(restarted as any).machineCache = {
- getOnlineMachinesByNamespace: () => [{ id: 'machine-x', metadata: { host: 'localhost' } }],
- expireInactive: () => {}
- }
- ;(restarted as any).rpcGateway.stopRunnerSession = async () => 'already_gone'
+ ;(restarted as any).machineCache = fakeMachineCache() ;(restarted as any).rpcGateway.stopRunnerSession = async () => 'already_gone'
;(restarted as any).rpcGateway.spawnSession = async () => {
restarted.handleSessionAlive({ sid: sessionId, time: Date.now() })
restarted.handleSessionReady({ sid: sessionId, time: Date.now() })
diff --git a/hub/src/telegram/bot.ts b/hub/src/telegram/bot.ts
index cddbe15223..4b249b06c0 100644
--- a/hub/src/telegram/bot.ts
+++ b/hub/src/telegram/bot.ts
@@ -9,8 +9,15 @@ import { Bot, Context, InlineKeyboard } from 'grammy'
import { SyncEngine, Session, type Machine } from '../sync/syncEngine'
import { handleCallback, CallbackContext } from './callbacks'
import { formatReadyNotification, formatSessionNotification, createNotificationKeyboard } from './sessionView'
-import { getAgentName } from '../notifications/sessionInfo'
-import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes'
+import { getAgentName, getSessionName } from '../notifications/sessionInfo'
+import type {
+ ModelErrorNotification,
+ ModelErrorSendOutcome,
+ NotificationChannel,
+ TaskNotification
+} from '../notifications/notificationTypes'
+import type { NotificationSendContext } from '../notifications/notificationSendContext'
+import { formatModelErrorBody, formatModelErrorTitle } from '../notifications/modelErrorCopy'
import type { Store } from '../store'
export interface BotContext extends Context {
@@ -264,6 +271,43 @@ export class HappyBot implements NotificationChannel {
}
}
+ async sendModelError(
+ session: Session,
+ notification: ModelErrorNotification,
+ _ctx?: NotificationSendContext
+ ): Promise {
+ // No active-session guard: bounded retries must still reach Telegram
+ // if the session ended between the first attempt and the timer.
+
+ const agentName = getAgentName(session)
+ const sessionName = getSessionName(session)
+ const title = formatModelErrorTitle(notification.kind)
+ const body = formatModelErrorBody(notification, { agentName, sessionName })
+ // Plain text (no parse_mode): sessionName can contain
+ // Markdown metacharacters; Telegram drops the whole message on parse errors.
+ const text = `\u{1F6A8} Model error - ${title}\n\n${body}`
+ const url = buildMiniAppDeepLink(this.publicUrl, `session_${session.id}`)
+ const keyboard = new InlineKeyboard().webApp('Open Session', url)
+
+ const chatIds = this.getBoundChatIds(session.namespace)
+ if (chatIds.length === 0) {
+ return 'unavailable'
+ }
+
+ let delivered = 0
+ for (const chatId of chatIds) {
+ try {
+ await this.bot.api.sendMessage(chatId, text, {
+ reply_markup: keyboard
+ })
+ delivered++
+ } catch (error) {
+ console.error(`[HAPIBot] Failed to send model-error notification to chat ${chatId}:`, error)
+ }
+ }
+ return delivered > 0 ? 'delivered' : 'failed'
+ }
+
async sendTaskNotification(session: Session, notification: TaskNotification): Promise {
if (!session.active) {
return
diff --git a/hub/src/web/routes/cli.test.ts b/hub/src/web/routes/cli.test.ts
index f903364320..f6d0f6984a 100644
--- a/hub/src/web/routes/cli.test.ts
+++ b/hub/src/web/routes/cli.test.ts
@@ -1,7 +1,8 @@
-import { beforeAll, describe, expect, it, mock } from 'bun:test'
+import { afterEach, beforeAll, describe, expect, it, mock } from 'bun:test'
import { Hono } from 'hono'
import type { SyncEngine } from '../../sync/syncEngine'
-import { createConfiguration } from '../../configuration'
+import { createConfiguration, getConfiguration } from '../../configuration'
+import { writeAutoBridgeTransientModelErrorsEnabled } from '../../config/autoBridgeTransientModelErrors'
import { createCliRoutes } from './cli'
import { SessionIdentityConflictError } from '../../store/sessions'
@@ -289,3 +290,61 @@ describe('cli lazy session creation', () => {
expect(response.status).toBe(409)
})
})
+
+describe('cli autoBridgeTransientModelErrors namespace gate', () => {
+ afterEach(async () => {
+ await writeAutoBridgeTransientModelErrorsEnabled(getConfiguration().dataDir, false)
+ })
+
+ it('returns hub auto-bridge only for the default namespace', async () => {
+ await writeAutoBridgeTransientModelErrorsEnabled(getConfiguration().dataDir, true)
+ const sessionId = '22222222-2222-4222-8222-222222222222'
+ const app = createApp({
+ getOrCreateSession: () => ({ id: sessionId }),
+ resolveSessionAccess: () => ({
+ ok: true,
+ session: { id: sessionId },
+ sessionId
+ })
+ } as never)
+
+ const defaultCreate = await app.request('/cli/sessions', {
+ method: 'POST',
+ headers: {
+ ...authHeaders(),
+ 'content-type': 'application/json'
+ },
+ body: JSON.stringify({
+ id: sessionId,
+ tag: 'auto-bridge-default',
+ metadata: {}
+ })
+ })
+ expect(defaultCreate.status).toBe(200)
+ const defaultBody = await defaultCreate.json() as { autoBridgeTransientModelErrors?: boolean }
+ expect(defaultBody.autoBridgeTransientModelErrors).toBe(true)
+
+ const tenantCreate = await app.request('/cli/sessions', {
+ method: 'POST',
+ headers: {
+ authorization: 'Bearer test-token:tenant-a',
+ 'content-type': 'application/json'
+ },
+ body: JSON.stringify({
+ id: sessionId,
+ tag: 'auto-bridge-tenant',
+ metadata: {}
+ })
+ })
+ expect(tenantCreate.status).toBe(200)
+ const tenantCreateBody = await tenantCreate.json() as { autoBridgeTransientModelErrors?: boolean }
+ expect(tenantCreateBody.autoBridgeTransientModelErrors).toBe(false)
+
+ const tenantGet = await app.request(`/cli/sessions/${sessionId}`, {
+ headers: { authorization: 'Bearer test-token:tenant-a' }
+ })
+ expect(tenantGet.status).toBe(200)
+ const tenantGetBody = await tenantGet.json() as { autoBridgeTransientModelErrors?: boolean }
+ expect(tenantGetBody.autoBridgeTransientModelErrors).toBe(false)
+ })
+})
diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts
index 5e2345860c..de2b53f3c6 100644
--- a/hub/src/web/routes/cli.ts
+++ b/hub/src/web/routes/cli.ts
@@ -8,6 +8,7 @@ import {
PROTOCOL_VERSION
} from '@hapi/protocol'
import { getConfiguration } from '../../configuration'
+import { readAutoBridgeTransientModelErrorsEnabled } from '../../config/autoBridgeTransientModelErrors'
import { readSessionSummaryContractEnabled } from '../../config/sessionSummaryContract'
import { constantTimeEquals } from '../../utils/crypto'
import { parseAccessToken } from '../../utils/accessToken'
@@ -129,10 +130,13 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono SyncEngine | null): Hono {
diff --git a/hub/src/web/routes/hubSettings.test.ts b/hub/src/web/routes/hubSettings.test.ts
index 10ddfd6ae6..8c60decceb 100644
--- a/hub/src/web/routes/hubSettings.test.ts
+++ b/hub/src/web/routes/hubSettings.test.ts
@@ -5,6 +5,7 @@ import { join } from 'node:path'
import { Hono } from 'hono'
import type { WebAppEnv } from '../middleware/auth'
import { createHubSettingsRoutes } from './hubSettings'
+import { writeAutoBridgeTransientModelErrorsEnabled } from '../../config/autoBridgeTransientModelErrors'
import { writeSessionSummaryContractEnabled } from '../../config/sessionSummaryContract'
import { writeSessionSummaryInChatEnabled } from '../../config/sessionSummaryInChat'
@@ -27,18 +28,19 @@ describe('GET/PUT /api/hub-settings', () => {
return { app, dataDir }
}
- it('returns default off for emit and chat display', async () => {
+ it('returns default off for emit, chat display, and auto-bridge', async () => {
const { app } = await createApp()
const response = await app.request('/api/hub-settings')
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(await response.json()).toEqual({
sessionSummaryContract: false,
- sessionSummaryInChat: false
+ sessionSummaryInChat: false,
+ autoBridgeTransientModelErrors: false
})
})
- it('persists emit toggle for owner without changing display', async () => {
+ it('persists emit toggle for owner without changing display or auto-bridge', async () => {
const { app } = await createApp()
const put = await app.request('/api/hub-settings', {
method: 'PUT',
@@ -48,13 +50,15 @@ describe('GET/PUT /api/hub-settings', () => {
expect(put.status).toBe(200)
expect(await put.json()).toEqual({
sessionSummaryContract: true,
- sessionSummaryInChat: false
+ sessionSummaryInChat: false,
+ autoBridgeTransientModelErrors: false
})
const get = await app.request('/api/hub-settings')
expect(await get.json()).toEqual({
sessionSummaryContract: true,
- sessionSummaryInChat: false
+ sessionSummaryInChat: false,
+ autoBridgeTransientModelErrors: false
})
})
@@ -70,7 +74,23 @@ describe('GET/PUT /api/hub-settings', () => {
expect(put.status).toBe(200)
expect(await put.json()).toEqual({
sessionSummaryContract: true,
- sessionSummaryInChat: true
+ sessionSummaryInChat: true,
+ autoBridgeTransientModelErrors: false
+ })
+ })
+
+ it('persists autoBridgeTransientModelErrors toggle for owner', async () => {
+ const { app } = await createApp()
+ const put = await app.request('/api/hub-settings', {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ autoBridgeTransientModelErrors: true })
+ })
+ expect(put.status).toBe(200)
+ expect(await put.json()).toEqual({
+ sessionSummaryContract: false,
+ sessionSummaryInChat: false,
+ autoBridgeTransientModelErrors: true
})
})
@@ -94,6 +114,20 @@ describe('GET/PUT /api/hub-settings', () => {
expect(response.status).toBe(400)
})
+ it('rejects combined two-field updates', async () => {
+ const { app } = await createApp()
+ const response = await app.request('/api/hub-settings', {
+ method: 'PUT',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ sessionSummaryContract: true,
+ autoBridgeTransientModelErrors: true
+ })
+ })
+ expect(response.status).toBe(400)
+ expect(await response.json()).toEqual({ error: 'Update one hub setting per request' })
+ })
+
it('rejects non-default namespaces for PUT but allows GET', async () => {
const { app, dataDir } = await createApp('default')
await writeSessionSummaryInChatEnabled(dataDir, true)
@@ -109,7 +143,8 @@ describe('GET/PUT /api/hub-settings', () => {
expect(get.status).toBe(200)
expect(await get.json()).toEqual({
sessionSummaryContract: false,
- sessionSummaryInChat: true
+ sessionSummaryInChat: true,
+ autoBridgeTransientModelErrors: false
})
const put = await tenantApp.request('/api/hub-settings', {
@@ -124,10 +159,12 @@ describe('GET/PUT /api/hub-settings', () => {
const { app, dataDir } = await createApp()
await writeSessionSummaryContractEnabled(dataDir, true)
await writeSessionSummaryInChatEnabled(dataDir, true)
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, true)
const response = await app.request('/api/hub-settings')
expect(await response.json()).toEqual({
sessionSummaryContract: true,
- sessionSummaryInChat: true
+ sessionSummaryInChat: true,
+ autoBridgeTransientModelErrors: true
})
})
})
diff --git a/hub/src/web/routes/hubSettings.ts b/hub/src/web/routes/hubSettings.ts
index 3d2a087789..cb23215b3a 100644
--- a/hub/src/web/routes/hubSettings.ts
+++ b/hub/src/web/routes/hubSettings.ts
@@ -1,31 +1,46 @@
import { Hono } from 'hono'
import { UpdateHubSettingsRequestSchema, type HubSettingsResponse } from '@hapi/protocol'
import {
- getSettingsFile,
- readSettingsOrThrow,
- updateSettings,
- type Settings
-} from '../../config/settings'
+ readAutoBridgeTransientModelErrorsEnabled,
+ writeAutoBridgeTransientModelErrorsEnabled
+} from '../../config/autoBridgeTransientModelErrors'
+import {
+ readSessionSummaryContractEnabled,
+ writeSessionSummaryContractEnabled
+} from '../../config/sessionSummaryContract'
+import {
+ readSessionSummaryInChatEnabled,
+ writeSessionSummaryInChatEnabled
+} from '../../config/sessionSummaryInChat'
+import type { SyncEngine } from '../../sync/syncEngine'
import type { WebAppEnv } from '../middleware/auth'
const OWNER_ONLY_ERROR = 'Hub settings are only available to the hub owner'
-function toHubSettings(settings: Settings): HubSettingsResponse {
+async function readHubSettings(dataDir: string): Promise {
+ const [sessionSummaryContract, sessionSummaryInChat, autoBridgeTransientModelErrors] = await Promise.all([
+ readSessionSummaryContractEnabled(dataDir),
+ readSessionSummaryInChatEnabled(dataDir),
+ readAutoBridgeTransientModelErrorsEnabled(dataDir)
+ ])
return {
- sessionSummaryContract: settings.sessionSummaryContract === true,
- sessionSummaryInChat: settings.sessionSummaryInChat === true
+ sessionSummaryContract,
+ sessionSummaryInChat,
+ autoBridgeTransientModelErrors
}
}
-export function createHubSettingsRoutes(dataDir: string): Hono {
+export function createHubSettingsRoutes(
+ dataDir: string,
+ getSyncEngine?: () => SyncEngine | null
+): Hono {
const app = new Hono()
// Authenticated readers (any namespace) can observe hub-wide display/emit
// flags. Mutations stay owner-only below.
app.get('/hub-settings', async (c) => {
c.header('Cache-Control', 'no-store')
- const settings = await readSettingsOrThrow(getSettingsFile(dataDir))
- return c.json(toHubSettings(settings))
+ return c.json(await readHubSettings(dataDir))
})
app.put('/hub-settings', async (c) => {
@@ -37,21 +52,46 @@ export function createHubSettingsRoutes(dataDir: string): Hono {
if (!parsed.success) {
return c.json({ error: 'Invalid body' }, 400)
}
- const response = await updateSettings(getSettingsFile(dataDir), (current) => {
- const settings: Settings = { ...current }
- if (parsed.data.sessionSummaryContract !== undefined) {
- settings.sessionSummaryContract = parsed.data.sessionSummaryContract
- }
- if (parsed.data.sessionSummaryInChat !== undefined) {
- settings.sessionSummaryInChat = parsed.data.sessionSummaryInChat
- }
- return {
- settings,
- result: toHubSettings(settings)
+ const providedFieldCount = [
+ parsed.data.sessionSummaryContract,
+ parsed.data.sessionSummaryInChat,
+ parsed.data.autoBridgeTransientModelErrors
+ ].filter((value) => value !== undefined).length
+ if (providedFieldCount > 1) {
+ return c.json({ error: 'Update one hub setting per request' }, 400)
+ }
+ if (parsed.data.sessionSummaryContract !== undefined) {
+ await writeSessionSummaryContractEnabled(
+ dataDir,
+ parsed.data.sessionSummaryContract
+ )
+ }
+ if (parsed.data.sessionSummaryInChat !== undefined) {
+ await writeSessionSummaryInChatEnabled(
+ dataDir,
+ parsed.data.sessionSummaryInChat
+ )
+ }
+ if (parsed.data.autoBridgeTransientModelErrors !== undefined) {
+ const enabled = parsed.data.autoBridgeTransientModelErrors
+ const engine = getSyncEngine?.() ?? null
+ if (engine) {
+ try {
+ // Disk write + fanout + rollback share SyncEngine's lock so
+ // concurrent PUTs cannot leave CLI prefs ahead of settings.json.
+ await engine.applyAutoBridgeTransientModelErrorsSetting(dataDir, enabled)
+ } catch (error) {
+ const message = error instanceof Error
+ ? error.message
+ : 'Failed to update every active Cursor session'
+ return c.json({ error: message }, 409)
+ }
+ } else {
+ await writeAutoBridgeTransientModelErrorsEnabled(dataDir, enabled)
}
- })
+ }
c.header('Cache-Control', 'no-store')
- return c.json(response)
+ return c.json(await readHubSettings(dataDir))
})
return app
diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts
index d690e0521c..9cf0df3726 100644
--- a/hub/src/web/routes/sessions.test.ts
+++ b/hub/src/web/routes/sessions.test.ts
@@ -63,6 +63,7 @@ function createApp(session: Session, opts?: {
getSessionExport?: (sessionId: string, session: Session, options?: { force?: boolean }) => unknown
sessionExists?: boolean
archiveSession?: (sessionId: string) => Promise
+ acknowledgeModelError?: (sessionId: string, eventId: string) => Promise
getCursorChatStoreStatus?: SyncEngine['getCursorChatStoreStatus']
listCodexModelsForSession?: SyncEngine['listCodexModelsForSession']
forkConversation?: SyncEngine['forkConversation']
@@ -71,6 +72,7 @@ function createApp(session: Session, opts?: {
updateSessionSummary?: SyncEngine['updateSessionSummary']
setSessionPinned?: (sessionId: string, pinned: boolean) => void
setSessionPinMode?: (sessionId: string, mode: 'none' | 'project' | 'global') => void
+ namespace?: string
}) {
const applySessionConfigCalls: Array<[string, Record]> = []
const applySessionConfig = async (sessionId: string, config: Record) => {
@@ -124,6 +126,7 @@ function createApp(session: Session, opts?: {
}))
const sessionExists = opts?.sessionExists !== false
const archiveSessionMock = opts?.archiveSession ?? (async () => {})
+ const acknowledgeModelErrorMock = opts?.acknowledgeModelError ?? (async () => {})
const engine = {
resolveSessionAccess: () => sessionExists
? { ok: true, sessionId: session.id, session }
@@ -147,6 +150,7 @@ function createApp(session: Session, opts?: {
archiveSession: archiveSessionMock,
setSessionPinned: opts?.setSessionPinned ?? (() => {}),
setSessionPinMode: opts?.setSessionPinMode ?? (() => {}),
+ acknowledgeModelError: acknowledgeModelErrorMock,
getSessionExport: opts?.getSessionExport ?? (() => ({
type: 'success',
payload: {
@@ -169,7 +173,7 @@ function createApp(session: Session, opts?: {
const app = new Hono()
app.use('*', async (c, next) => {
- c.set('namespace', 'default')
+ c.set('namespace', opts?.namespace ?? 'default')
await next()
})
app.route('/api', createSessionsRoutes(() => engine as SyncEngine))
@@ -1564,4 +1568,133 @@ describe('sessions routes', () => {
expect(body.sessions.map((s) => s.id)).toEqual(['new-inactive'])
})
+ describe('POST /sessions/:id/model-error/acknowledge', () => {
+ it('forwards eventId to the engine when body is valid', async () => {
+ const calls: Array<[string, string]> = []
+ const { app } = createApp(createSession(), {
+ acknowledgeModelError: async (sessionId, eventId) => {
+ calls.push([sessionId, eventId])
+ }
+ })
+
+ const response = await app.request('/api/sessions/session-1/model-error/acknowledge', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ eventId: 'evt-ack-1' })
+ })
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({ ok: true })
+ expect(calls).toEqual([['session-1', 'evt-ack-1']])
+ })
+
+ it('returns 400 when eventId is missing', async () => {
+ let called = false
+ const { app } = createApp(createSession(), {
+ acknowledgeModelError: async () => { called = true }
+ })
+
+ const response = await app.request('/api/sessions/session-1/model-error/acknowledge', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({})
+ })
+
+ expect(response.status).toBe(400)
+ expect(called).toBe(false)
+ })
+
+ it('returns 409 when the displayed error no longer matches', async () => {
+ const { app } = createApp(createSession(), {
+ acknowledgeModelError: async () => {
+ throw new Error('Model error changed; refresh before acknowledging.')
+ }
+ })
+
+ const response = await app.request('/api/sessions/session-1/model-error/acknowledge', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ eventId: 'evt-stale' })
+ })
+
+ expect(response.status).toBe(409)
+ expect(await response.json()).toEqual({
+ error: 'Model error changed; refresh before acknowledging.'
+ })
+ })
+ })
+
+ describe('POST /sessions/:id/model-error/bridge', () => {
+ it('returns 409 session_inactive when the session is not active', async () => {
+ const { app } = createApp(createSession({
+ active: false,
+ metadata: { path: '/tmp/project', host: 'localhost', flavor: 'cursor' }
+ }))
+
+ const response = await app.request('/api/sessions/session-1/model-error/bridge', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ eventId: 'evt-1' })
+ })
+
+ expect(response.status).toBe(409)
+ expect(await response.json()).toEqual({
+ error: 'Session is inactive',
+ code: 'session_inactive'
+ })
+ })
+
+ it('returns 400 when eventId is missing', async () => {
+ const { app } = createApp(createSession({
+ metadata: { path: '/tmp/project', host: 'localhost', flavor: 'cursor' }
+ }))
+
+ const response = await app.request('/api/sessions/session-1/model-error/bridge', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({})
+ })
+
+ expect(response.status).toBe(400)
+ })
+ })
+
+ describe('POST /sessions/:id/model-error/auto-bridge-setting', () => {
+ it('rejects non-default namespaces with 403 and does not apply config', async () => {
+ const { app, applySessionConfigCalls } = createApp(createSession({
+ metadata: { path: '/tmp/project', host: 'localhost', flavor: 'cursor' }
+ }), { namespace: 'tenant-a' })
+
+ const response = await app.request('/api/sessions/session-1/model-error/auto-bridge-setting', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ enabled: true })
+ })
+
+ expect(response.status).toBe(403)
+ expect(await response.json()).toEqual({
+ error: 'Model error auto-bridge is only available to the hub owner'
+ })
+ expect(applySessionConfigCalls).toEqual([])
+ })
+
+ it('applies the setting for the default namespace', async () => {
+ const { app, applySessionConfigCalls } = createApp(createSession({
+ metadata: { path: '/tmp/project', host: 'localhost', flavor: 'cursor' }
+ }))
+
+ const response = await app.request('/api/sessions/session-1/model-error/auto-bridge-setting', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ enabled: true })
+ })
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual({ ok: true })
+ expect(applySessionConfigCalls).toEqual([
+ ['session-1', { autoBridgeTransientModelErrors: true }]
+ ])
+ })
+ })
+
})
diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts
index 44238c9e4f..7ce6cb652a 100644
--- a/hub/src/web/routes/sessions.ts
+++ b/hub/src/web/routes/sessions.ts
@@ -1,4 +1,6 @@
import {
+ AcknowledgeModelErrorRequestSchema,
+ BridgeModelErrorRequestSchema,
CursorMigrateToAcpRequestSchema,
DeleteUploadRequestSchema,
ForkConversationRequestSchema,
@@ -460,6 +462,125 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
return c.json({ ok: true })
})
+ app.post('/sessions/:id/model-error/acknowledge', async (c) => {
+ const engine = requireSyncEngine(c, getSyncEngine)
+ if (engine instanceof Response) {
+ return engine
+ }
+
+ const sessionResult = requireSessionFromParam(c, engine)
+ if (sessionResult instanceof Response) {
+ return sessionResult
+ }
+
+ const body = await c.req.json().catch(() => null)
+ const parsed = AcknowledgeModelErrorRequestSchema.safeParse(body)
+ if (!parsed.success) {
+ return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400)
+ }
+
+ try {
+ await engine.acknowledgeModelError(sessionResult.sessionId, parsed.data.eventId)
+ return c.json({ ok: true })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to acknowledge model error'
+ if (
+ message.includes('concurrently')
+ || message.includes('version')
+ || message.includes('changed')
+ ) {
+ return c.json({ error: message }, 409)
+ }
+ return c.json({ error: message }, 500)
+ }
+ })
+
+ app.post('/sessions/:id/model-error/bridge', async (c) => {
+ const engine = requireSyncEngine(c, getSyncEngine)
+ if (engine instanceof Response) {
+ return engine
+ }
+
+ // Bridge needs a live CLI RPC target; inactive → 409 session_inactive.
+ const sessionResult = requireSessionFromParam(c, engine, { requireActive: true })
+ if (sessionResult instanceof Response) {
+ return sessionResult
+ }
+
+ const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
+ if (flavor !== 'cursor') {
+ return c.json({ error: 'Model error bridge is only supported for Cursor sessions' }, 400)
+ }
+
+ const body = await c.req.json().catch(() => null)
+ const parsed = BridgeModelErrorRequestSchema.safeParse(body)
+ if (!parsed.success) {
+ return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400)
+ }
+
+ try {
+ const result = await engine.bridgeModelError(
+ sessionResult.sessionId,
+ parsed.data.eventId
+ )
+ if (!result.ok) {
+ return c.json({ ok: false, reason: result.reason ?? 'not_bridgeable' }, 409)
+ }
+ return c.json({ ok: true })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to bridge model error'
+ if (
+ message.includes('not active')
+ || message.includes('not transient')
+ || message.includes('already bridged')
+ || message.includes('already failed')
+ || message.includes('changed')
+ || message.includes('superseded')
+ || message.includes('not bridgeable')
+ ) {
+ return c.json({ error: message }, 409)
+ }
+ return c.json({ error: message }, 500)
+ }
+ })
+
+ app.post('/sessions/:id/model-error/auto-bridge-setting', async (c) => {
+ // Owner-only — matches CLI create/get which force false for tenants.
+ if (c.get('namespace') !== 'default') {
+ return c.json({ error: 'Model error auto-bridge is only available to the hub owner' }, 403)
+ }
+
+ const engine = requireSyncEngine(c, getSyncEngine)
+ if (engine instanceof Response) {
+ return engine
+ }
+
+ const sessionResult = requireSessionFromParam(c, engine, { requireActive: true })
+ if (sessionResult instanceof Response) {
+ return sessionResult
+ }
+
+ const flavor = sessionResult.session.metadata?.flavor ?? 'claude'
+ if (flavor !== 'cursor') {
+ return c.json({ error: 'Model error auto-bridge is only supported for Cursor sessions' }, 400)
+ }
+
+ const body = await c.req.json().catch(() => null)
+ if (!body || typeof body !== 'object' || typeof body.enabled !== 'boolean') {
+ return c.json({ error: 'Invalid body' }, 400)
+ }
+
+ try {
+ await engine.applySessionConfig(sessionResult.sessionId, {
+ autoBridgeTransientModelErrors: body.enabled
+ })
+ return c.json({ ok: true })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to apply auto-bridge setting'
+ return c.json({ error: message }, 409)
+ }
+ })
+
app.post('/sessions/:id/migrate-to-acp', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts
index 3444b88ce6..ba1b8fa8fe 100644
--- a/hub/src/web/server.ts
+++ b/hub/src/web/server.ts
@@ -291,7 +291,7 @@ function createWebApp(options: {
app.route('/api', createPermissionsRoutes(options.getSyncEngine))
app.route('/api', createMachinesRoutes(options.getSyncEngine))
app.route('/api', createStorageRoutes(configuration.dbPath))
- app.route('/api', createHubSettingsRoutes(configuration.dataDir))
+ app.route('/api', createHubSettingsRoutes(configuration.dataDir, options.getSyncEngine))
app.route('/api', createUsageRoutes(options.store))
app.route('/api', createGitRoutes(options.getSyncEngine))
// 中文注释:这里提供两类 Codex 辅助能力:扫描本地 transcript 以导入到 Hapi,以及按需重启 Codex Desktop 客户端。
diff --git a/ios/HapiNotificationService/NotificationService.swift b/ios/HapiNotificationService/NotificationService.swift
index 981df0813e..f23b0aac0f 100644
--- a/ios/HapiNotificationService/NotificationService.swift
+++ b/ios/HapiNotificationService/NotificationService.swift
@@ -97,7 +97,10 @@ final class NotificationService: UNNotificationServiceExtension {
}
// Coalescing (Android `type-` tag): a newer push of the
// same type for the same session replaces the previous one.
- content.threadIdentifier = "\(type.isEmpty ? "unknown" : type)-\(fields["sessionId"] ?? "")"
+ content.threadIdentifier = {
+ if let tag = fields["tag"], !isBlank(tag) { return tag }
+ return "\(type.isEmpty ? "unknown" : type)-\(fields["sessionId"] ?? "")"
+ }()
// Decrypted plaintext for the app-side tap/action handlers — key name
// is `PushCoordinator.decryptedUserInfoKey`.
var userInfo = content.userInfo
diff --git a/ios/Packages/HapiKit/Sources/HapiClient/Push/PushPayload.swift b/ios/Packages/HapiKit/Sources/HapiClient/Push/PushPayload.swift
index 710ce8a2a1..2a0213a375 100644
--- a/ios/Packages/HapiKit/Sources/HapiClient/Push/PushPayload.swift
+++ b/ios/Packages/HapiKit/Sources/HapiClient/Push/PushPayload.swift
@@ -7,6 +7,7 @@ public enum PushType: String, Sendable {
case ready
case permissionRequest = "permission-request"
case taskNotification = "task-notification"
+ case modelError = "model-error"
}
/// `severity` — visual urgency accent (`hub/src/fcm/fcmService.ts`).
@@ -71,6 +72,8 @@ public struct PushPayload: Equatable, Sendable {
public var severity: PushSeverity?
public var contractVersion: String?
public var notifySummary: PushNotifySummary?
+ /// Hub coalescing tag (`FcmSendPayload.tag`), when present.
+ public var tag: String?
/// The contract version this client implements.
public static let contractVersion = "1"
@@ -91,7 +94,7 @@ public struct PushPayload: Equatable, Sendable {
switch type {
case .permissionRequest: return requestId != nil
case .ready, .taskNotification: return true
- case nil: return false
+ case .modelError, nil: return false
}
}
@@ -103,6 +106,13 @@ public struct PushPayload: Equatable, Sendable {
supportsActions ? rawType : nil
}
+ /// Coalescing identity: prefer the hub-supplied `tag` (event-specific
+ /// for `model-error`) before falling back to `type-`.
+ public var notificationTag: String {
+ if let tag, !tag.isPushBlank { return tag }
+ return "\(rawType.isEmpty ? "unknown" : rawType)-\(sessionId)"
+ }
+
/// Title to render; falls back to the session name, then a constant.
public var displayTitle: String {
if let title, !title.isPushBlank { return title }
@@ -134,7 +144,8 @@ public struct PushPayload: Equatable, Sendable {
requestId: String? = nil,
severity: PushSeverity? = nil,
contractVersion: String? = nil,
- notifySummary: PushNotifySummary? = nil
+ notifySummary: PushNotifySummary? = nil,
+ tag: String? = nil
) {
self.type = type
self.rawType = rawType
@@ -147,6 +158,7 @@ public struct PushPayload: Equatable, Sendable {
self.severity = severity
self.contractVersion = contractVersion
self.notifySummary = notifySummary
+ self.tag = tag
}
// MARK: - Parsing
@@ -170,7 +182,8 @@ public struct PushPayload: Equatable, Sendable {
requestId: nonBlank(data["requestId"]),
severity: data["severity"].flatMap(PushSeverity.init(rawValue:)),
contractVersion: nonBlank(data["contractVersion"]),
- notifySummary: data["notifySummary"].flatMap(parseNotifySummary)
+ notifySummary: data["notifySummary"].flatMap(parseNotifySummary),
+ tag: nonBlank(data["tag"])
)
}
diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/Push/PushPayloadTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/Push/PushPayloadTests.swift
index e8a614de46..e5650e65b5 100644
--- a/ios/Packages/HapiKit/Tests/HapiClientTests/Push/PushPayloadTests.swift
+++ b/ios/Packages/HapiKit/Tests/HapiClientTests/Push/PushPayloadTests.swift
@@ -74,6 +74,34 @@ struct PushPayloadTests {
#expect(payload.displayTitle == "Permission needed")
}
+ @Test func modelErrorIsKnownTypeAndPrefersHubTag() throws {
+ let eventTag = "model-error-sess-1-evt-1710000000000"
+ let payload = try #require(PushPayload.parse(dictionary: [
+ "type": "model-error",
+ "sessionId": "sess-1",
+ "title": "Rate limited",
+ "body": "status 429",
+ "severity": "error",
+ "contractVersion": "1",
+ "tag": eventTag,
+ ]))
+ #expect(payload.type == .modelError)
+ #expect(payload.rawType == "model-error")
+ #expect(payload.notificationTag == eventTag)
+ #expect(!payload.supportsActions)
+ #expect(payload.categoryIdentifier == nil)
+ #expect(payload.severity == .error)
+ }
+
+ @Test func modelErrorWithoutHubTagFallsBackToTypeSessionId() throws {
+ let payload = try #require(PushPayload.parse(dictionary: [
+ "type": "model-error",
+ "sessionId": "sess-1",
+ "contractVersion": "1",
+ ]))
+ #expect(payload.notificationTag == "model-error-sess-1")
+ }
+
@Test func absentContractVersionCountsAsKnown() throws {
var fields = fullFields
fields["contractVersion"] = nil
diff --git a/ios/README.md b/ios/README.md
index 962a36de4d..4114889a39 100644
--- a/ios/README.md
+++ b/ios/README.md
@@ -663,7 +663,9 @@ End-to-end encrypted APNs push, mirroring the Android FCM stack
carries a ~60-line copy of the HapiKit `PushEnvelope` decrypt, kept honest
by the shared test vector.
- **Actions.** `permission-request` → Allow / Deny; `ready` and
- `task-notification` → inline Reply. Handlers run in the notification
+ `task-notification` → inline Reply. `model-error` is tap-to-open only
+ (HIGH / heads-up channel on Android; event-specific coalescing tag).
+ Handlers run in the notification
delegate's async completion and resolve the owning hub Android-style
(active hub first, then the roster; 404 "Session not found" / 403 = try
the next hub) — approve/deny post `{}`, reply posts `{text, localId}`.
diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts
index 735d8a5ad9..0c0102ab45 100644
--- a/shared/src/apiTypes.ts
+++ b/shared/src/apiTypes.ts
@@ -52,7 +52,9 @@ export type CliMessagesResponse = z.infer
export const CreateSessionResponseSchema = z.object({
session: SessionSchema,
/** Hub opt-in for AGENT_NOTIFY_SUMMARY prompt injection (default off when omitted). */
- sessionSummaryContract: z.boolean().optional()
+ sessionSummaryContract: z.boolean().optional(),
+ /** Hub opt-in: Cursor auto-bridge after transient model errors (default off). */
+ autoBridgeTransientModelErrors: z.boolean().optional()
})
export type CreateSessionResponse = z.infer
@@ -60,7 +62,8 @@ export type CreateSessionResponse = z.infer
export const HubSettingsResponseSchema = z.object({
sessionSummaryContract: z.boolean(),
/** Show compact AGENT_NOTIFY_SUMMARY in chat (default off / hide). */
- sessionSummaryInChat: z.boolean()
+ sessionSummaryInChat: z.boolean(),
+ autoBridgeTransientModelErrors: z.boolean()
})
export type HubSettingsResponse = z.infer
@@ -68,10 +71,13 @@ export type HubSettingsResponse = z.infer
export const UpdateHubSettingsRequestSchema = z
.object({
sessionSummaryContract: z.boolean().optional(),
- sessionSummaryInChat: z.boolean().optional()
+ sessionSummaryInChat: z.boolean().optional(),
+ autoBridgeTransientModelErrors: z.boolean().optional()
})
.refine(
- (data) => data.sessionSummaryContract !== undefined || data.sessionSummaryInChat !== undefined,
+ (data) => data.sessionSummaryContract !== undefined
+ || data.sessionSummaryInChat !== undefined
+ || data.autoBridgeTransientModelErrors !== undefined,
{ message: 'At least one hub setting field is required' }
)
@@ -431,6 +437,20 @@ export const ScratchlistEntryUpdateRequestSchema = z.object({
export type ScratchlistEntryUpdateRequest = z.infer
+/** Dismiss the model-error banner for a specific displayed error (by eventId). */
+export const AcknowledgeModelErrorRequestSchema = z.object({
+ eventId: z.string().min(1)
+})
+
+export type AcknowledgeModelErrorRequest = z.infer