Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions mobile/src/session/MobileNativeChatMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
{
Expand Down
6 changes: 6 additions & 0 deletions src/main/acp/acp-dialects/acp-dialect.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -69,6 +70,7 @@ export type AcpTurnEnding = {
durationMs?: number
failureDetail?: string
notSignedIn?: boolean
usageLimit?: AgentSessionUsageLimit
hookBlock?: AcpHookBlock
}

Expand Down Expand Up @@ -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<AcpTurnEnding, 'stopReason' | 'usageLimit'> | 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
Expand Down
40 changes: 39 additions & 1 deletion src/main/acp/acp-dialects/opencode-dialect.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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,
Comment thread
brennanb2025 marked this conversation as resolved.
/\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) {
Expand Down Expand Up @@ -41,5 +78,6 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate {
}

export const OPENCODE_ACP_DIALECT: AcpDialect = {
normalizeToolUpdate
normalizeToolUpdate,
promptFailure
}
75 changes: 75 additions & 0 deletions src/main/acp/acp-timeline-opencode-dialect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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',
Expand Down
11 changes: 7 additions & 4 deletions src/main/acp/acp-timeline-translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down
31 changes: 24 additions & 7 deletions src/main/acp/acp-turn-failures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
})
Expand All @@ -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')
Expand All @@ -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 [
{
Expand Down
Loading