fix(native-chat): say which retry a Claude retry is on and what it last failed with (#25818)

* fix(native-chat): say which attempt a Claude retry is on and what it last failed with

* fix(native-chat): count Claude's retries as retries, not attempts

Claude Code sends its first api_retry frame only after the first request
failed, so frame "attempt N of max_retries" is the Nth retry. Say
"Retry N of M." and store the bound as maxRetries.
This commit is contained in:
Brennan Benson
2026-10-06 16:32:32 -07:00
committed by GitHub
parent 35e173a5b7
commit f6448a1e26
16 changed files with 209 additions and 34 deletions
+19 -9
View File
@@ -77,11 +77,11 @@ describe('a Claude api_retry frame', () => {
expect(rows).toHaveLength(1)
const [row] = rows
expect(row).toMatchObject({
text: 'Claude is rate-limited and retrying.',
text: 'Claude is rate-limited and retrying. Retry 3 of 10.\nLast error: HTTP 429 rate limit.',
tone: 'warning',
failure: {
kind: 'providerRetrying',
retry: { error: 'rate_limit', status: 429 },
retry: { error: 'rate_limit', status: 429, attempt: 3, maxRetries: 10 },
detail: { audience: 'log' }
}
})
@@ -90,11 +90,21 @@ describe('a Claude api_retry frame', () => {
expect(row.failure?.detail?.text).toContain('"attempt":3')
})
it('says how far a gateway failure has got, and the code it failed with', async () => {
const [row] = await statusRowsFor([
apiRetry(1, { error: 'server_error', error_status: 502 }),
apiRetry(2, { error: 'server_error', error_status: 502 })
])
expect(row?.text).toBe(
'Claude hit a temporary problem and is retrying. Retry 2 of 10.\nLast error: HTTP 502 server error.'
)
})
it('starts a new row when a later run starts over', async () => {
const rows = await statusRowsFor([apiRetry(1), apiRetry(2), apiRetry(1)])
expect(rows.map((row) => row.text)).toEqual([
'Claude is rate-limited and retrying.',
'Claude is rate-limited and retrying.'
expect(rows.map((row) => row.text.split('\n')[0])).toEqual([
'Claude is rate-limited and retrying. Retry 2 of 10.',
'Claude is rate-limited and retrying. Retry 1 of 10.'
])
})
@@ -106,10 +116,10 @@ describe('a Claude api_retry frame', () => {
apiRetry(1, { error: 'something_new', error_status: 429 })
])
expect(rows.map((row) => row.text)).toEqual([
'Claude hit a temporary problem and is retrying.',
'Claude hit a temporary problem and is retrying.',
'Claude hit a temporary problem and is retrying.',
'Claude is rate-limited and retrying.'
'Claude hit a temporary problem and is retrying. Retry 1 of 10.\nLast error: HTTP 529 overloaded.',
'Claude hit a temporary problem and is retrying. Retry 1 of 10.\nLast error: HTTP 500 server error.',
'Claude hit a temporary problem and is retrying. Retry 1 of 10.',
'Claude is rate-limited and retrying. Retry 1 of 10.\nLast error: HTTP 429 something new.'
])
expect(rows[0]?.failure).toMatchObject({ retry: { error: 'overloaded', status: 529 } })
})
+6 -1
View File
@@ -13,7 +13,12 @@ import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names'
export const CLAUDE_API_RETRY_FRAME_KIND = 'message:system:api_retry'
export function claudeApiRetryRowBody(message: Record<string, unknown>): AgentJournalStatusItem {
const retry = readProviderRetry({ error: message.error, status: message.error_status })
const retry = readProviderRetry({
error: message.error,
status: message.error_status,
attempt: message.attempt,
maxRetries: message.max_retries
})
const words = agentSessionFailureWords(
agentSessionFailureFact('providerRetrying', {
detail: providerDiagnostic(JSON.stringify(message), 'log'),
@@ -263,6 +263,24 @@ const PIECES: Record<AgentSessionFailureCopyId, (values: AgentSessionFailureCopy
COPY.providerRetryingQuoted,
values
),
providerRetryNumber: (values) =>
translate(
'components.native-chat.failureWords.providerRetryNumber',
COPY.providerRetryNumber,
values
),
providerRetryNumberOf: (values) =>
translate(
'components.native-chat.failureWords.providerRetryNumberOf',
COPY.providerRetryNumberOf,
values
),
providerRetryLastError: (values) =>
translate(
'components.native-chat.failureWords.providerRetryLastError',
COPY.providerRetryLastError,
values
),
previousExitUnverifiable: (values) =>
translate(
'components.native-chat.failureWords.previousExitUnverifiable',
+3
View File
@@ -2808,6 +2808,9 @@
"providerRejectedQuoted": "The provider did not accept this message: {{detail}}.",
"providerRetrying": "{{agent}} hit a temporary problem and is retrying.",
"providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.",
"providerRetryNumber": "Retry {{attempt}}.",
"providerRetryNumberOf": "Retry {{attempt}} of {{maxRetries}}.",
"providerRetryLastError": "Last error: {{detail}}.",
"providerStartFailed": "{{agent}} stopped before it finished starting.",
"queueFull": "Too many messages were waiting for the agent, so this one was not sent.",
"runCommandAgain": "Run /{{command}} again.",
+3
View File
@@ -17833,6 +17833,9 @@
"providerRateLimited": "{{agent}} is rate-limited and retrying.",
"providerRetrying": "{{agent}} hit a temporary problem and is retrying.",
"providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.",
"providerRetryNumber": "Retry {{attempt}}.",
"providerRetryNumberOf": "Retry {{attempt}} of {{maxRetries}}.",
"providerRetryLastError": "Last error: {{detail}}.",
"previousExitUnverifiable": "Couldn't stop {{agent}} from before.",
"argumentsUnsupportedOption": "Saved Arguments contain an unsupported option ({{option}}).",
"argumentsMissingValue": "Saved Arguments need a value for {{option}}.",
+3
View File
@@ -17612,6 +17612,9 @@
"providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.",
"providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando.",
"providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.",
"providerRetryNumber": "Reintento {{attempt}}.",
"providerRetryNumberOf": "Reintento {{attempt}} de {{maxRetries}}.",
"providerRetryLastError": "Último error: {{detail}}.",
"previousExitUnverifiable": "No se pudo detener la ejecución anterior de {{agent}}."
},
"writeNotice": {
+3
View File
@@ -17713,6 +17713,9 @@
"providerRateLimited": "{{agent}} est limité en débit et réessaie.",
"providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie.",
"providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.",
"providerRetryNumber": "Nouvelle tentative {{attempt}}.",
"providerRetryNumberOf": "Nouvelle tentative {{attempt}} sur {{maxRetries}}.",
"providerRetryLastError": "Dernière erreur : {{detail}}.",
"previousExitUnverifiable": "Impossible d'arrêter l'exécution précédente de {{agent}}."
},
"writeNotice": {
+3
View File
@@ -17647,6 +17647,9 @@
"providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。",
"providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。",
"providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。",
"providerRetryNumber": "{{attempt}} 回目の再試行です。",
"providerRetryNumberOf": "{{maxRetries}} 回中 {{attempt}} 回目の再試行です。",
"providerRetryLastError": "直近のエラー: {{detail}}。",
"previousExitUnverifiable": "以前の {{agent}} を停止できませんでした。"
},
"writeNotice": {
+3
View File
@@ -17647,6 +17647,9 @@
"providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.",
"providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.",
"providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.",
"providerRetryNumber": "{{attempt}}번째 재시도입니다.",
"providerRetryNumberOf": "{{maxRetries}}회 중 {{attempt}}번째 재시도입니다.",
"providerRetryLastError": "마지막 오류: {{detail}}.",
"previousExitUnverifiable": "이전에 실행된 {{agent}}을(를) 중지하지 못했습니다."
},
"writeNotice": {
+3
View File
@@ -17612,6 +17612,9 @@
"providerRateLimited": "{{agent}} 已被限流,正在重试。",
"providerRetrying": "{{agent}} 遇到临时问题,正在重试。",
"providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。",
"providerRetryNumber": "第 {{attempt}} 次重试。",
"providerRetryNumberOf": "第 {{attempt}} 次重试,共 {{maxRetries}} 次。",
"providerRetryLastError": "上次错误:{{detail}}。",
"previousExitUnverifiable": "无法停止之前的 {{agent}}。"
},
"writeNotice": {
+5
View File
@@ -89,6 +89,9 @@ export const AGENT_SESSION_FAILURE_COPY = {
providerRateLimited: '{{agent}} is rate-limited and retrying.',
providerRetrying: '{{agent}} hit a temporary problem and is retrying.',
providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.',
providerRetryNumber: 'Retry {{attempt}}.',
providerRetryNumberOf: 'Retry {{attempt}} of {{maxRetries}}.',
providerRetryLastError: 'Last error: {{detail}}.',
previousExitUnverifiable: "Couldn't stop {{agent}} from before."
} as const
@@ -102,6 +105,8 @@ export type AgentSessionFailureCopyValues = {
option?: string
limit?: string
size?: string
attempt?: string
maxRetries?: string
}
/** One piece in the reader's language, placeholders filled. */
+32 -1
View File
@@ -48,6 +48,8 @@ function factsFor(kind: AgentSessionFailureKind): AgentSessionFailureFact[] {
facts.push({ kind, retry: { error: 'rate_limit', status: 429 } })
facts.push({ kind, retry: { error: 'overloaded', status: 529 } })
facts.push({ kind, retry: { status: 500 } })
facts.push({ kind, retry: { error: 'server_error', status: 502, attempt: 3, maxRetries: 10 } })
facts.push({ kind, retry: { attempt: 1 } })
}
if (kind === 'attachmentInvalid') {
for (const reason of AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS) {
@@ -239,7 +241,7 @@ describe('the words written beside a failure fact', () => {
detail: { text: '{"type":"system","subtype":"api_retry"}', audience: 'log' },
retry: { error: 'rate_limit', status: 429 }
})
).toBe('Codex is rate-limited and retrying.')
).toBe('Codex is rate-limited and retrying.\nLast error: HTTP 429 rate limit.')
expect(retrying({})).toBe('Codex hit a temporary problem and is retrying.')
})
@@ -259,6 +261,35 @@ describe('the words written beside a failure fact', () => {
)
})
it("says which retry it is, and the provider's codes when it wrote no account", () => {
const retrying = (retry: AgentSessionFailureFact['retry']) =>
agentSessionFailureSentence({ kind: 'providerRetrying', retry }, 'row', {
agentName: 'Claude'
})
expect(retrying({ error: 'server_error', status: 502, attempt: 3, maxRetries: 10 })).toBe(
'Claude hit a temporary problem and is retrying. Retry 3 of 10.\nLast error: HTTP 502 server error.'
)
expect(retrying({ status: 502, attempt: 3 })).toBe(
'Claude hit a temporary problem and is retrying. Retry 3.\nLast error: HTTP 502.'
)
// The provider's own account outranks its codes.
expect(retrying({ status: 429, cause: 'Too many requests', attempt: 2, maxRetries: 5 })).toBe(
'Claude is rate-limited and retrying. Retry 2 of 5.\nToo many requests'
)
// A provider that words its own progress is quoted alone, never counted twice.
expect(
agentSessionFailureSentence(
{
kind: 'providerRetrying',
detail: { text: 'Reconnecting... 2/5', audience: 'person' },
retry: { status: 502, attempt: 2, maxRetries: 5 }
},
'row',
{ agentName: 'Codex' }
)
).toBe('Codex is retrying: Reconnecting... 2/5.')
})
it('says which limit an attachment broke, in megabytes', () => {
const sentence = (attachment: AgentSessionFailureFact['attachment']) =>
agentSessionFailureSentence({ kind: 'attachmentInvalid', attachment }, 'rejection', {
+7 -16
View File
@@ -25,6 +25,7 @@ import {
type AgentSessionFailureSay
} from './agent-session-failure-copy'
import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals'
import { providerRetryWords, withRetryCause } from './agent-session-provider-retry-words'
import { joinSentences } from './sentence-joining'
import {
DISPATCH_REJECTED_CANCELLED,
@@ -122,11 +123,6 @@ function quotingPersonDetail(
return quoted ? say(quotedLead, { ...values, detail: quoted }) : say(lead, values)
}
/** The provider's account of what failed goes on the line under the sentence, as it wrote it. */
function withRetryCause(sentence: string, cause: string | undefined): string {
return cause ? `${sentence}\n${cause}` : sentence
}
/** The next step after a start or restart that failed: the command, or the message, again. */
function startRetry(
say: AgentSessionFailureSay,
@@ -274,23 +270,18 @@ const FAILURE_SENTENCES = {
hostStopped: (context, _fact, _surface, say) => say('hostStopped', agent(say, context)),
// A provider that says how its retry is going, for a person, is quoted: that is the progress.
providerRetrying: (context, { retry, detail }, _surface, say) =>
withRetryCause(
detail?.audience === 'person'
? quotingPersonDetail(
detail?.audience === 'person'
? withRetryCause(
quotingPersonDetail(
say,
'providerRetrying',
'providerRetryingQuoted',
detail,
agent(say, context)
)
: say(
retry?.error === 'rate_limit' || retry?.status === 429
? 'providerRateLimited'
: 'providerRetrying',
agent(say, context)
),
retry?.cause
),
retry?.cause
)
: providerRetryWords(say, agent(say, context), retry),
previousExitUnverifiable: (context, _fact, _surface, say) =>
say('previousExitUnverifiable', agent(say, context))
} satisfies Record<AgentSessionFailureKind, Sentence>
+20 -1
View File
@@ -92,7 +92,7 @@ describe('reading a failure fact', () => {
expect(
readAgentSessionFailureFact({
kind: 'providerRetrying',
retry: { error: 'rate_limit', status: 429, attempt: 3 }
retry: { error: 'rate_limit', status: 429, retryDelayMs: 600 }
})
).toEqual({ kind: 'providerRetrying', retry: { error: 'rate_limit', status: 429 } })
expect(
@@ -130,6 +130,17 @@ describe('reading a failure fact', () => {
})
).toEqual({ kind: 'restartFailed', refusal: { code: 'agent_session_conflict' } })
})
it('keeps which retry it is, and its maximum only where it bounds that retry', () => {
expect(readProviderRetry({ attempt: 3, maxRetries: 10 })).toEqual({
attempt: 3,
maxRetries: 10
})
expect(readProviderRetry({ status: 502, maxRetries: 10 })).toEqual({ status: 502 })
expect(readProviderRetry({ attempt: 12, maxRetries: 10 })).toEqual({ attempt: 12 })
expect(readProviderRetry({ attempt: 0, maxRetries: 10 })).toBeUndefined()
expect(readProviderRetry({ attempt: 2.5, status: 502 })).toEqual({ status: 502 })
})
})
describe('reading all of a failure fact', () => {
@@ -146,6 +157,14 @@ describe('reading all of a failure fact', () => {
}),
agentSessionFailureFact('providerRetrying', {
retry: readProviderRetry({ error: 'rate_limit', status: 429 })
}),
agentSessionFailureFact('providerRetrying', {
retry: readProviderRetry({
error: 'server_error',
status: 502,
attempt: 3,
maxRetries: 10
})
})
]) {
expect(readWholeAgentSessionFailureFact(JSON.parse(JSON.stringify(fact)))).toEqual(fact)
+21 -6
View File
@@ -128,6 +128,10 @@ export type AgentSessionProviderRetry = {
status?: number
/** The provider's own account of what failed, written for a person: the row's second line. */
cause?: string
/** Which retry this is, counted from 1. */
attempt?: number
/** The most retries the provider makes before it gives up. */
maxRetries?: number
}
export type AgentSessionFailureFact = {
@@ -224,17 +228,28 @@ export function readProviderRetry(value: unknown): AgentSessionProviderRetry | u
}
const error =
typeof value.error === 'string' && value.error.trim() ? value.error.trim() : undefined
const status =
typeof value.status === 'number' && Number.isInteger(value.status) && value.status > 0
? value.status
: undefined
const status = positiveInteger(value.status)
const cause =
typeof value.cause === 'string' ? providerDiagnostic(value.cause, 'person')?.text : undefined
return error || status || cause
? { ...(error ? { error } : {}), ...(status ? { status } : {}), ...(cause ? { cause } : {}) }
const attempt = positiveInteger(value.attempt)
// A maximum bounds a retry; alone, or below the retry it bounds, it says nothing.
const max = positiveInteger(value.maxRetries)
const maxRetries = attempt && max && max >= attempt ? max : undefined
return error || status || cause || attempt
? {
...(error ? { error } : {}),
...(status ? { status } : {}),
...(cause ? { cause } : {}),
...(attempt ? { attempt } : {}),
...(maxRetries ? { maxRetries } : {})
}
: undefined
}
function positiveInteger(value: unknown): number | undefined {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined
}
/** A fact as a reader meets it. Undefined for anything this build cannot place, including a kind a
* newer host added, so the reader falls back to what it does for a row with no fact. */
export function readAgentSessionFailureFact(value: unknown): AgentSessionFailureFact | undefined {
@@ -0,0 +1,60 @@
// The words for a provider retry: which retry it is and what last failed, from the provider's
// own fields, for a provider that wrote none of its own for a person.
import type { AgentSessionProviderRetry } from './agent-session-failure'
import type {
AgentSessionFailureCopyValues,
AgentSessionFailureSay
} from './agent-session-failure-copy'
import { joinSentences } from './sentence-joining'
/** The provider's account of what failed goes on the line under the sentence, as it wrote it. */
export function withRetryCause(sentence: string, cause: string | undefined): string {
return cause ? `${sentence}\n${cause}` : sentence
}
export function providerRetryWords(
say: AgentSessionFailureSay,
agent: AgentSessionFailureCopyValues,
retry: AgentSessionProviderRetry | undefined
): string {
return withRetryCause(
joinSentences([
say(
retry?.error === 'rate_limit' || retry?.status === 429
? 'providerRateLimited'
: 'providerRetrying',
agent
),
...retryNumber(say, retry)
]),
retry?.cause ?? retryLastError(say, retry)
)
}
function retryNumber(
say: AgentSessionFailureSay,
retry: AgentSessionProviderRetry | undefined
): string[] {
if (!retry?.attempt) {
return []
}
const attempt = String(retry.attempt)
return [
retry.maxRetries
? say('providerRetryNumberOf', { attempt, maxRetries: String(retry.maxRetries) })
: say('providerRetryNumber', { attempt })
]
}
/** The provider's own codes for what failed, when it wrote no account of it for a person. */
function retryLastError(
say: AgentSessionFailureSay,
retry: AgentSessionProviderRetry | undefined
): string | undefined {
// `server_error` reads as words once its separators are spaces.
const codes = [retry?.status ? `HTTP ${retry.status}` : '', retry?.error?.replace(/_+/g, ' ')]
.filter(Boolean)
.join(' ')
return codes ? say('providerRetryLastError', { detail: codes }) : undefined
}