diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 8b98e16519d8..9c731c34d90f 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -337,6 +337,45 @@ describe('MobileNativeChatMessage', () => { observedAt: 2, body })! + + it.each([false, true])('draws an OpenCode usage warning with supplied reset: %s', (hasReset) => { + const resetsAt = new Date(2026, 9, 15, 15, 42).getTime() + const now = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 9, 15, 12).getTime()) + try { + const host = agentSessionFailureWords( + agentSessionFailureFact('usageLimitReached', { + detail: { + text: 'Internal error: Rate limit exceeded. Please try again later.', + audience: 'person' + }, + ...(hasReset ? { usageLimit: { resetsAt } } : {}) + }), + { surface: 'row', agentName: 'OpenCode' } + ) + expect(host.text).toBe( + 'OpenCode hit its usage limit. Send a message once it resets to continue.' + ) + const tree = render(statusRow({ kind: 'status', tone: 'warning', ...host })) + const text = tree.root.find( + (node) => String(node.type) === 'Text' && node.props.selectable === true + ) + expect(text.props.children).toEqual( + hasReset + ? expect.stringMatching( + /^OpenCode hit its usage limit\. Send a message once it resets to continue\. It resets at (3:42\sPM|15:42)\.$/ + ) + : host.text + ) + expect(Object.assign({}, ...text.props.style)).toMatchObject({ color: colors.statusAmber }) + expect(tree.root.find((node) => String(node.type) === 'AlertTriangle').props.color).toBe( + colors.statusAmber + ) + expect(markdownIn(tree.root)).toEqual([]) + } finally { + now.mockRestore() + } + }) + it.each([ [ 'an API retry as a warning', @@ -370,6 +409,17 @@ describe('MobileNativeChatMessage', () => { }), colors.statusRed ], + ...[ + 'Please add or update your payment method to continue', + 'Your current subscription plan does not yet include access to glm-5' + ].map( + (instruction) => + [ + `an OpenCode corrective instruction: ${instruction}`, + statusRow({ kind: 'status', tone: 'error', text: `Internal error: ${instruction}` }), + colors.statusRed + ] as const + ), [ 'an interruption as a faint aside', { diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index feaf28ea9a4a..818d049e4cd7 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -1,4 +1,5 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' +import type { AgentSessionUsageLimit } from '../../../shared/agent-session-usage-limit' import type { AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { @@ -69,6 +70,7 @@ export type AcpTurnEnding = { durationMs?: number failureDetail?: string notSignedIn?: boolean + usageLimit?: AgentSessionUsageLimit hookBlock?: AcpHookBlock } @@ -114,6 +116,10 @@ export type AcpDialect = { settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined /** The provider's words in a `session/prompt` error answer, when its message is generic. */ promptErrorDetail?(error: AcpAgentError): string | undefined + promptFailure?( + error: AcpAgentError, + at: number + ): Pick | undefined authenticationRequired?(error: AcpAgentError): boolean /** A hook's refusal, read from the `_meta` of the prompt's answer or of the turn's end. */ hookBlock?(meta: unknown): AcpHookBlock | undefined diff --git a/src/main/acp/acp-dialects/opencode-dialect.ts b/src/main/acp/acp-dialects/opencode-dialect.ts index 98566568f160..b487eb8b804c 100644 --- a/src/main/acp/acp-dialects/opencode-dialect.ts +++ b/src/main/acp/acp-dialects/opencode-dialect.ts @@ -1,4 +1,6 @@ import { z } from 'zod' +import { usageLimitResetsAt } from '../../../shared/agent-session-usage-limit' +import type { AcpAgentError } from '../acp-errors' import type { ToolCallUpdate } from '../generated/acp-protocol.generated' import type { AcpDialect } from './acp-dialect' @@ -14,6 +16,41 @@ const commandRawOutputSchema = z.looseObject({ .optional() }) +const promptFailureDataSchema = z.object({ + errorName: z.enum(['provider.quota', 'provider.rate-limit']), + status: z.number().optional().catch(undefined), + resetsAt: z.number().optional().catch(undefined), + retryAfterMs: z.number().nonnegative().optional().catch(undefined) +}) + +// ACP omits HTTP status; documented billing and entitlement instructions survive in the message. +const NON_RESET_QUOTA_FAILURES = [ + /\b(?:insufficient (?:balance|account funds)|credit limit exceeded|payment required|account invoice is overdue|billing_error)\b/i, + /\bplease add or update your payment method to continue\b/i, + /\b(?:GLM Coding Plan|enterprise) package has expired\b/i, + /\byour current subscription plan does not yet include access to\b/i +] + +function promptFailure(error: AcpAgentError, at: number) { + const parsed = promptFailureDataSchema.safeParse(error.data) + if ( + !parsed.success || + parsed.data.status === 402 || + NON_RESET_QUOTA_FAILURES.some((pattern) => pattern.test(error.message)) + ) { + return undefined + } + const { resetsAt, retryAfterMs } = parsed.data + const reset = usageLimitResetsAt( + resetsAt ?? (retryAfterMs === undefined ? undefined : at + retryAfterMs), + at + ) + return { + stopReason: 'rate_limit', + ...(reset === undefined ? {} : { usageLimit: { resetsAt: reset } }) + } +} + function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { const parsed = commandRawOutputSchema.safeParse(update.rawOutput) if (!parsed.success) { @@ -41,5 +78,6 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { } export const OPENCODE_ACP_DIALECT: AcpDialect = { - normalizeToolUpdate + normalizeToolUpdate, + promptFailure } diff --git a/src/main/acp/acp-timeline-opencode-dialect.test.ts b/src/main/acp/acp-timeline-opencode-dialect.test.ts index 45f234c8c425..ba5de7595f82 100644 --- a/src/main/acp/acp-timeline-opencode-dialect.test.ts +++ b/src/main/acp/acp-timeline-opencode-dialect.test.ts @@ -6,6 +6,7 @@ import { import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { OPENCODE_ACP_DIALECT } from './acp-dialects/opencode-dialect' import { AcpTimelineTranslator } from './acp-timeline-translator' +import { AcpAgentError } from './acp-errors' afterEach(closeProviderTimelineRigs) @@ -38,6 +39,80 @@ const bashStart = { } describe('OpenCode over ACP', () => { + it.each([ + { errorName: 'provider.quota', message: undefined }, + { errorName: 'provider.rate-limit', retryAfterMs: 60_000, message: undefined }, + ...[ + 'Usage limit reached for 5 hours. Your limit will reset at 2026-10-01 00:00:00', + 'Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-10-01 00:00:00' + ].map((message) => ({ errorName: 'provider.quota', message })) + ])('shows usage limits for $errorName', async ({ message: detail, ...data }) => { + const { rig, translator, apply } = await openCodeRig() + const message = `Internal error: ${detail ?? 'Rate limit exceeded. Please try again later.'}` + apply( + translator.promptFailed( + 'send-1', + new AcpAgentError(-32603, message, { + service: 'session', + ...data + }), + 1001 + ) + ) + const statuses = (await rig.rows()).filter((row) => row.body.kind === 'status') + expect(statuses.map((row) => row.body)).toEqual([ + expect.objectContaining({ + kind: 'status', + tone: 'warning', + failure: { + kind: 'usageLimitReached', + detail: { text: message, audience: 'person' }, + ...('retryAfterMs' in data ? { usageLimit: { resetsAt: 61_001 } } : {}) + } + }) + ]) + expect((await rig.turns())[0]?.outcome).toBe('failure') + }) + + it.each([ + { status: 402, message: 'Request rejected' }, + { message: 'Payment Required' }, + { message: 'Insufficient balance or no resource package. Please recharge.' }, + { message: 'Credit limit exceeded.' }, + { message: 'Upstream request failed: Insufficient account funds' }, + { message: 'Upstream request failed: Account invoice is overdue' }, + ...[ + 'Please add or update your payment method to continue', + 'Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.', + '[1309][Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.][20260929132151e73af01340d54b58]', + 'Your current subscription plan does not yet include access to glm-5', + 'Your enterprise package has expired. Please contact your enterprise administrator.' + ].map((message) => ({ message })), + { + errorName: 'provider.free-limit', + message: + 'Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models.' + } + ])('keeps non-reset errors generic: $message', async ({ message: detail, ...data }) => { + const { rig, translator, apply } = await openCodeRig() + const message = `Internal error: ${detail}` + apply( + translator.promptFailed( + 'send-1', + new AcpAgentError(-32603, message, { + service: 'session', + errorName: 'provider.quota', + ...data + }), + 1001 + ) + ) + expect( + (await rig.rows()).filter((row) => row.body.kind === 'status').map((row) => row.body) + ).toEqual([{ kind: 'status', tone: 'error', text: message }]) + expect((await rig.turns())[0]?.outcome).toBe('failure') + }) + it.each([ { label: 'metadata output and exit fallback', diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index b9bca6338909..8689ea5c93b5 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -96,15 +96,18 @@ export class AcpTimelineTranslator { promptFailed(clientMessageId: string, error: Error, at: number): ProviderTimelineEvent[] { const detail = error instanceof AcpAgentError ? acpPromptErrorDetail(this.dialect, error) : undefined + const failure = + error instanceof AcpAgentError ? this.dialect.promptFailure?.(error, at) : undefined + const stopReason = failure?.stopReason ?? 'error' const ended = this.prompts.last const notSignedIn = this.authenticationRequired(error) if (this.prompts.current?.clientMessageId !== clientMessageId) { // The provider already ended this turn; its answer may carry the only copy of the reason. return ended?.clientMessageId === clientMessageId && this.failures.has(ended.turn) - ? this.failures.row(ended.turn, detail, 'error', notSignedIn) + ? this.failures.row(ended.turn, detail, stopReason, notSignedIn, failure?.usageLimit) : [] } - const ending = { stopReason: 'error', at, failureDetail: detail, notSignedIn } + const ending = { ...failure, stopReason, at, failureDetail: detail, notSignedIn } return this.finishPrompt(clientMessageId, ending) } @@ -299,9 +302,9 @@ export class AcpTimelineTranslator { /** A failed end writes the reason row before the end, so it joins the turn while open. */ private endTurn(turn: string, ending: AcpTurnEnding): ProviderTimelineEvent[] { - const { stopReason, failureDetail, notSignedIn } = ending + const { stopReason, failureDetail, notSignedIn, usageLimit } = ending const events = this.prompts.endCompaction(turn, ending) ?? [ - ...this.failures.ended(turn, stopReason, failureDetail, notSignedIn), + ...this.failures.ended(turn, stopReason, failureDetail, notSignedIn, usageLimit), ...acpTurnEndEvents(this.options.sessionId, turn, ending) ] this.end(turn) diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index a1123deb35f1..db3f61e83cdc 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -5,6 +5,7 @@ import { } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { BoundedMap } from '../../shared/bounded-map' +import type { AgentSessionUsageLimit } from '../../shared/agent-session-usage-limit' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError, AcpAuthRequiredError, AcpSignInPromptError } from './acp-errors' @@ -61,7 +62,12 @@ export class AcpTurnFailures { /** The reason each failed turn's row holds; '' for none yet. */ private readonly rows = new BoundedMap< string, - { text: string; notSignedIn: boolean; usageLimited: boolean } + { + text: string + notSignedIn: boolean + usageLimited: boolean + usageLimit?: AgentSessionUsageLimit + } >({ maxEntries: 128 }) @@ -81,16 +87,20 @@ export class AcpTurnFailures { turn: string, stopReason: string, text: string | undefined, - notSignedIn = false + notSignedIn = false, + usageLimit?: AgentSessionUsageLimit ): ProviderTimelineEvent[] { - return acpStopReasonFailed(stopReason) ? this.row(turn, text, stopReason, notSignedIn) : [] + return acpStopReasonFailed(stopReason) + ? this.row(turn, text, stopReason, notSignedIn, usageLimit) + : [] } row( turn: string, text: string | undefined, stopReason = 'error', - notSignedIn = false + notSignedIn = false, + usageLimit?: AgentSessionUsageLimit ): ProviderTimelineEvent[] { const written = this.rows.peek(turn) const detail = text === undefined ? undefined : providerDiagnostic(text, 'person') @@ -100,23 +110,30 @@ export class AcpTurnFailures { // A later copy of the reason (the prompt's error answer) names no stop reason of its own. const usageLimited = !authenticationRequired && (stopReason === 'rate_limit' || written?.usageLimited === true) + const limit = usageLimit ?? written?.usageLimit if ( written && written.text === (diagnostic?.text ?? '') && written.notSignedIn === authenticationRequired && - written.usageLimited === usageLimited + written.usageLimited === usageLimited && + written.usageLimit?.resetsAt === limit?.resetsAt && + written.usageLimit?.window === limit?.window ) { return [] } this.rows.set(turn, { text: diagnostic?.text ?? '', notSignedIn: authenticationRequired, - usageLimited + usageLimited, + usageLimit: limit }) const failure = authenticationRequired ? agentSessionFailureFact('notSignedIn', { detail: diagnostic }) : usageLimited - ? agentSessionFailureFact('usageLimitReached', { detail: diagnostic }) + ? agentSessionFailureFact('usageLimitReached', { + detail: diagnostic, + usageLimit: limit + }) : null return [ {