Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
148 commits
Select commit Hold shift + click to select a range
c867bd2
refactor(native-chat): remove the unused terminal handoff
brennanb2025 Sep 25, 2026
eab4ef1
fix(native-chat): never let the pre-stop snapshot hold a chat's stop
brennanb2025 Sep 25, 2026
abafbbc
refactor(native-chat): drop helpers only the terminal handoff called
brennanb2025 Sep 25, 2026
3cbf6b5
docs(native-chat): stop citing the removed handoff in lifecycle comments
brennanb2025 Sep 25, 2026
e048579
test(native-chat): type the stalled snapshot drain without a cast
brennanb2025 Sep 25, 2026
b50c574
test(native-chat): pin that a start dead before proving owes no settl…
brennanb2025 Sep 25, 2026
12efdef
fix(native-chat): keep the owner-status read behind an in-flight attach
brennanb2025 Sep 25, 2026
42d10ee
refactor(terminal): remove the agent-session PTY write gate
brennanb2025 Sep 25, 2026
0483915
refactor(native-chat): drop the transcript helpers only the handoff c…
brennanb2025 Sep 25, 2026
d0f4dae
fix(native-chat): stop calling a starting chat "mid-handoff"
brennanb2025 Sep 25, 2026
faecece
test(native-chat): type the stand-in roster decoder without a cast
brennanb2025 Sep 25, 2026
250250e
refactor(codex): name the pinned rollout lookup for what it does
brennanb2025 Sep 25, 2026
b588185
refactor(native-chat): type the owner-status reply as the host sends it
brennanb2025 Sep 25, 2026
35742fd
refactor(native-chat): normalize terminal-handoff lease values once a…
brennanb2025 Sep 25, 2026
54ce5fc
refactor(native-chat): stop threading the owner kind through a reserv…
brennanb2025 Sep 25, 2026
713b126
test(native-chat): pin the legacy-lease rewrite with a transaction th…
brennanb2025 Sep 25, 2026
44d7977
fix(native-chat): name a chat write by its target, not the owner gene…
brennanb2025 Sep 25, 2026
6dad39a
fix(native-chat): every journal append reaches the chats that are open
brennanb2025 Sep 25, 2026
97bf3bb
test(native-chat): an epoch replacement reaches the open chat
brennanb2025 Sep 25, 2026
427dc8d
test(native-chat): each row reaches an open chat once, and a live han…
brennanb2025 Sep 25, 2026
2bd44f5
test(native-chat): give the legacy-lease store test a tab id so the b…
brennanb2025 Sep 25, 2026
f79eb9b
test(worktree-activation): restore the OMP surfaced-agent resume test
brennanb2025 Sep 25, 2026
50d2e33
perf(native-chat): a publish behind a delivered commit reads nothing
brennanb2025 Sep 25, 2026
bd72b13
test(native-chat): state why the teardown test's fake journal is safe…
brennanb2025 Sep 25, 2026
fa9b76a
docs(native-chat): say mutation admission checks only the writer lease
brennanb2025 Sep 25, 2026
5435973
docs(native-chat): drop the send rebase from comments that still desc…
brennanb2025 Sep 25, 2026
8fd4558
Merge #22811 into the PR 1 base
brennanb2025 Sep 25, 2026
12dde37
Merge #22812 into the PR 1 base
brennanb2025 Sep 25, 2026
c753e8a
fix(native-chat): a message is accepted, then delivered
brennanb2025 Sep 25, 2026
82f9bfd
fix(native-chat): settle queued messages only for the child that ended
brennanb2025 Sep 25, 2026
f3e52d2
fix(native-chat): an adoption that fails to import keeps the conversa…
brennanb2025 Sep 25, 2026
29a340b
perf(native-chat): the recovering open reads the journal once
brennanb2025 Sep 25, 2026
c7a652f
fix(native-chat): an attach that fails after indexing its child leave…
brennanb2025 Sep 25, 2026
34d0bb0
fix(native-chat): a withdrawn message shows no error, and a rejection…
brennanb2025 Sep 25, 2026
46b6472
fix(orchestration): a structured worker whose agent outlasts the prea…
brennanb2025 Sep 25, 2026
283819d
fix(native-chat): a message rejected while its chat was closed reads …
brennanb2025 Sep 25, 2026
40dabe8
test(orchestration): name why the readiness settlement fakes are cast
brennanb2025 Sep 25, 2026
31444bf
fix(native-chat): keep each pane's own fence on frames so a failed re…
brennanb2025 Sep 25, 2026
494d8c7
docs(native-chat): drop the fence from the admission the send effects…
brennanb2025 Sep 25, 2026
0e74529
docs(native-chat): give the fence move on release the reason that sti…
brennanb2025 Sep 25, 2026
6c16f35
docs(native-chat): stop citing a write fence check in launch and mail…
brennanb2025 Sep 25, 2026
d2f646b
refactor(native-chat): the provider child is its own record
brennanb2025 Sep 25, 2026
25fa38a
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-wri…
brennanb2025 Sep 25, 2026
aa56b50
fix(native-chat): the delivery loop alone settles a message its start…
brennanb2025 Sep 25, 2026
d23cca4
Merge #22812 and main into PR 1
brennanb2025 Sep 25, 2026
4993696
refactor(native-chat): a stopped child ends on the one reading of its…
brennanb2025 Sep 25, 2026
a2ad8f2
feat(native-chat): the host says it accepts a send before any agent h…
brennanb2025 Sep 25, 2026
48def96
refactor(native-chat): an attach never opens a journal of its own
brennanb2025 Sep 25, 2026
da65d57
fix(native-chat): a moved fence resends nothing on a host that accept…
brennanb2025 Sep 25, 2026
0184a3d
refactor(native-chat): a child's end says whether the user or the hos…
brennanb2025 Sep 25, 2026
12577d8
fix(native-chat): a chat whose only work is a queued message is not o…
brennanb2025 Sep 25, 2026
a59eed9
test(native-chat): type the queued-message fixtures in the resume-off…
brennanb2025 Sep 25, 2026
ccaf412
fix(native-chat): a start that dies while a message waits on it is th…
brennanb2025 Sep 25, 2026
0efca93
docs(native-chat): say what an attach's open conversation and unconfi…
brennanb2025 Sep 25, 2026
74d6506
test(native-chat): pin what a failed start settles, and what a resume…
brennanb2025 Sep 25, 2026
9c671f1
test(native-chat): the failed-start pins fail on what the message bec…
brennanb2025 Sep 25, 2026
80ccb33
test(orchestration): the preamble's host stub is typed, not cast
brennanb2025 Sep 25, 2026
568bb46
feat(native-chat): a typed failure fact beside every failure sentence
brennanb2025 Sep 26, 2026
e25f4d8
fix(native-chat): durable failure rows and rejection reasons are writ…
brennanb2025 Sep 26, 2026
e6dadd1
Merge main into PR 1
brennanb2025 Sep 26, 2026
b2bb2a1
feat(native-chat): refusals name their cause, and a failed start is w…
brennanb2025 Sep 26, 2026
5263550
refactor(native-chat): one reader of why a submission was rejected
brennanb2025 Sep 26, 2026
c38220f
test(native-chat): pin provider diagnostics where they are composed
brennanb2025 Sep 26, 2026
445a4f5
Merge remote-tracking branch 'origin/brennanb2025/chat-accept-then-de…
brennanb2025 Sep 26, 2026
3592e14
fix(native-chat): an attachment Orca refuses says which limit it broke
brennanb2025 Sep 26, 2026
0ce944a
fix(native-chat): the chat tab table refuses with a typed cause
brennanb2025 Sep 26, 2026
0188573
fix(native-chat): a compaction Codex refuses up front keeps Codex's w…
brennanb2025 Sep 26, 2026
570eff5
fix(native-chat): an unreadable chat record no longer promises an upd…
brennanb2025 Sep 26, 2026
dd05f06
fix(native-chat): a restart or a close leaves released clients a sent…
brennanb2025 Sep 26, 2026
34a1e43
fix(native-chat): log why an attachment could not be read
brennanb2025 Sep 26, 2026
6cd9a9c
refactor(native-chat): drop an unused thrown-refusal cause reader
brennanb2025 Sep 26, 2026
44672f5
fix(native-chat): a compaction the provider never confirmed is record…
brennanb2025 Sep 26, 2026
ac5e9c8
fix(native-chat): an empty or non-user message is not recorded as a b…
brennanb2025 Sep 26, 2026
20d35c9
fix(native-chat): an undelivered preamble's error ends in one period
brennanb2025 Sep 26, 2026
e9e40b9
refactor(native-chat): a compaction ends as compacted, failed, or unc…
brennanb2025 Sep 26, 2026
6374f35
Merge main into PR 1
brennanb2025 Sep 27, 2026
724b961
Merge main into PR 1
brennanb2025 Sep 27, 2026
7243c96
refactor(native-chat): a failure's sentence is written only from its …
brennanb2025 Sep 27, 2026
d19f909
fix(native-chat): a start that failed without an observed exit no lon…
brennanb2025 Sep 27, 2026
c39151d
fix(native-chat): a view never restarts a chat whose last start failed
brennanb2025 Sep 27, 2026
6962658
Merge remote-tracking branch 'origin/brennanb2025/chat-accept-then-de…
brennanb2025 Sep 27, 2026
7382a71
fix(native-chat): a chat a terminal agent still holds says to quit th…
brennanb2025 Sep 27, 2026
4c41ba5
refactor(native-chat): keep the changed files inside their lint limits
brennanb2025 Sep 27, 2026
9613178
test(native-chat): start the child the loop waits on with an attach, …
brennanb2025 Sep 27, 2026
0224459
fix(native-chat): settle a gone generation's turn wherever a conversa…
brennanb2025 Sep 27, 2026
2276cef
test(native-chat): prove the next child's start settles the turn an e…
brennanb2025 Sep 27, 2026
9275869
test(native-chat): count a failed start's rows by row, not by text
brennanb2025 Sep 27, 2026
158ca0a
test(native-chat): give the failed-start and stale-turn waits a loade…
brennanb2025 Sep 27, 2026
93c5747
refactor(native-chat): refusals carry details keyed by their code
brennanb2025 Sep 27, 2026
8f66a25
test(native-chat): pin the open's and the send's start and row counts…
brennanb2025 Sep 27, 2026
73bc984
test(native-chat): a restart-resume failure keeps its refusal details
brennanb2025 Sep 27, 2026
ebf232f
Merge remote-tracking branch 'origin/brennanb2025/chat-accept-then-de…
brennanb2025 Sep 27, 2026
e7cf962
test(native-chat): import the failure words once in the provider chil…
brennanb2025 Sep 27, 2026
ad04c3b
fix(native-chat): settle a gone generation's turn at every open but a…
brennanb2025 Sep 27, 2026
05b8b93
test(native-chat): hold the create's start open until the views bind
brennanb2025 Sep 27, 2026
4634735
fix(native-chat): a start the provider refused or Orca broke no longe…
brennanb2025 Sep 27, 2026
b532cd9
fix(native-chat): a refused /clear start keeps the situation it named
brennanb2025 Sep 27, 2026
e60047d
fix(native-chat): the journal schema and comments describe a refusal'…
brennanb2025 Sep 27, 2026
2927227
fix(native-chat): a Claude child that exits while being acquired stil…
brennanb2025 Sep 27, 2026
1504703
Merge remote-tracking branch 'origin/brennanb2025/chat-accept-then-de…
brennanb2025 Sep 27, 2026
d5716f3
Merge main into PR 1
brennanb2025 Sep 27, 2026
590bf69
Merge remote-tracking branch 'origin/brennanb2025/chat-accept-then-de…
brennanb2025 Sep 27, 2026
79327d4
fix(native-chat): word a failed chat start's refusal as its start fai…
brennanb2025 Sep 27, 2026
96a12ea
fix(native-chat): show Claude's API retries as one sentence row
brennanb2025 Sep 27, 2026
d2ef1b6
fix(native-chat): tell the user to run /clear again when its new conv…
brennanb2025 Sep 27, 2026
e5d73cd
test(native-chat): expect a failed Claude create to be refused in a s…
brennanb2025 Sep 27, 2026
f160988
fix(native-chat): name the agent that stopped starting and say how to…
brennanb2025 Sep 27, 2026
740ed1f
Merge main into PR 1
brennanb2025 Sep 28, 2026
7002b1b
refactor(native-chat): drop the composer's second error formatter
brennanb2025 Sep 28, 2026
cb5ce82
test(native-chat): pin the reason on a message rejected while its cha…
brennanb2025 Sep 28, 2026
0a50e3f
fix(native-chat): answer a create refused before spawn in the words i…
brennanb2025 Sep 28, 2026
82e6a2c
fix(native-chat): say how to retry after an agent stopped before it f…
brennanb2025 Sep 28, 2026
248c23c
fix(native-chat): say how to reach a Claude chat when a WSL Claude ac…
brennanb2025 Sep 28, 2026
64be90f
Merge PR 1 (with #22999) into the failure-facts branch
brennanb2025 Sep 28, 2026
d1506de
fix(native-chat): say the reason a host names for a refused chat write
brennanb2025 Sep 28, 2026
1e168ab
fix(native-chat): retry a refused send under a new id only once its a…
brennanb2025 Sep 28, 2026
12e30c7
fix(native-chat): say a /clear that never finished did not finish, in…
brennanb2025 Sep 28, 2026
574e2ac
fix(native-chat): a send the provider never received after a restart …
brennanb2025 Sep 28, 2026
c7b4330
fix(native-chat): a send refused after its agent exited keeps its pla…
brennanb2025 Sep 28, 2026
11b23cc
fix(native-chat): stop reading a provider's words from an error that …
brennanb2025 Sep 28, 2026
2e89498
fix(native-chat): say a chat whose history can't be read can't contin…
brennanb2025 Sep 28, 2026
e839453
fix(native-chat): decide what every way a child ends means for queued…
brennanb2025 Sep 28, 2026
57f1d54
test(native-chat): a close that stops the child and then fails reject…
brennanb2025 Sep 28, 2026
6c1d624
test(native-chat): type the refusal a persisted-schema test admits
brennanb2025 Sep 28, 2026
f4320d4
fix(native-chat): say whether a chat's history is damaged or just cou…
brennanb2025 Sep 28, 2026
4eb3633
Merge main into PR A
brennanb2025 Sep 28, 2026
8f834f1
docs(native-chat): say why a history that couldn't open names its ret…
brennanb2025 Sep 28, 2026
bb18141
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-fai…
brennanb2025 Sep 28, 2026
53b2f63
fix(native-chat): a failed start's row keeps the words its rejected m…
brennanb2025 Sep 28, 2026
e3506c7
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-fai…
brennanb2025 Sep 28, 2026
b36f709
fix(native-chat): a Claude start Orca itself failed no longer says Cl…
brennanb2025 Sep 28, 2026
3e218e3
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-fai…
brennanb2025 Sep 28, 2026
83abd11
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-fai…
brennanb2025 Sep 28, 2026
9ab2a2e
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-fai…
brennanb2025 Sep 28, 2026
e8f0a5b
fix(native-chat): name the agent that stopped, and blame Orca for its…
brennanb2025 Sep 28, 2026
c360017
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-fai…
brennanb2025 Sep 28, 2026
4bca1fe
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-fai…
brennanb2025 Sep 28, 2026
4b33dcf
test(native-chat): pin the sidebar verdict to the rejection classifie…
brennanb2025 Sep 28, 2026
7117793
fix(native-chat): blame Orca, not Codex, when Orca closes the Codex c…
brennanb2025 Sep 28, 2026
d187bee
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-fai…
brennanb2025 Sep 28, 2026
99003be
fix(native-chat): a chat whose history is damaged reads "Unable to lo…
brennanb2025 Sep 28, 2026
3f4f601
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-fai…
brennanb2025 Sep 28, 2026
ad336b3
fix(native-chat): route the conversation-outlives-agent writers throu…
brennanb2025 Sep 28, 2026
2b4f8c3
fix(native-chat): a start Orca stopped because it never finished read…
brennanb2025 Sep 28, 2026
d07508a
test(native-chat): pin the merged close and host-stop rows to their t…
brennanb2025 Sep 28, 2026
f6bca9f
fix(native-chat): a read of a chat this host cannot open says why
brennanb2025 Sep 28, 2026
06d9f6e
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-fai…
brennanb2025 Sep 28, 2026
58cf1ee
test(native-chat): a Claude retrying past the idle window keeps its c…
brennanb2025 Sep 28, 2026
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
10 changes: 6 additions & 4 deletions mobile/src/session/mobile-structured-agent-session-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import type {
import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation'
import {
agentSessionRefusalNotice,
agentSessionRpcErrorFailure,
agentSessionWriteFailureNotice,
agentSessionWriteKindForMethod,
agentSessionWriteNoticeEnglish,
agentSessionWriteNoticeParts,
type AgentSessionWriteKind
agentSessionWriteNoticeParts
} from '../../../src/shared/agent-session-refusal-notice'
import {
agentSessionRpcErrorFailure,
agentSessionWriteKindForMethod,
type AgentSessionWriteKind
} from '../../../src/shared/agent-session-write-failure'
import { structuredSessionOperationId } from './structured-session-operation-id'
import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import type { RpcClient } from '../transport/rpc-client'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,36 @@ describe('what a structured refusal says on the phone', () => {
})
})

it('says what the host named as the reason, in the same words as desktop', async () => {
const result = await requestStructuredAgentSessionMutation({
client: fakeClient(async () => ({
ok: true,
result: {
ok: false,
refusal: {
code: 'agent_session_operation_invalid',
message: 'This conversation has been cleared. Use the current conversation.',
details: { reason: 'conversationCleared' }
}
},
_meta: { runtimeId: 'runtime-1' }
})),
method: 'agentSession.send',
fingerprintMethod: 'agentSession.send',
sessionId: 'session-1',
expectedRuntimeFence: 1,
fields: { body: 'hello' },
clientOperationId: `1900000000000-${'e'.repeat(32)}`
})

expect(result).toEqual({
status: 'refused',
code: 'agent_session_operation_invalid',
message:
'This conversation has been cleared. Your message was not sent. Open the current conversation to continue.'
})
})

const stop = {
method: 'agentSession.cancel',
fingerprintMethod: 'agentSession.cancel',
Expand Down
132 changes: 132 additions & 0 deletions src/main/claude/claude-api-retry-idle-sweep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// A Claude that keeps retrying a refused request is working, so the idle sweep must not stop it.
// Every retry frame's publish is the activity the sweep reads, even though a run writes one row.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { STRUCTURED_AGENT_SESSION_IDLE_MS } from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep'
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
HOST_TEST_THREAD as THREAD,
hostTestAttachParams,
resetHostTestOperationIds
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'

const SWEEP_MS = 5
const RETRY_GAP_MS = 10 * 60_000

let root: string
let host: StructuredAgentSessionHost
let sink: StructuredAgentSessionEventSink | null
let closeSession: Mock<NonNullable<StructuredAgentSessionAdapter['closeSession']>>
let clock: number

function apiRetry(attempt: number): Record<string, unknown> {
return {
type: 'system',
subtype: 'api_retry',
attempt,
max_retries: 10,
retry_delay_ms: 600_000,
error_status: 429,
error: 'rate_limit',
session_id: 'provider-1',
uuid: `9b2f6a1e-0c4d-4e7a-8f3b-00000000000${attempt}`
}
}

/** Long enough for many sweep ticks, so "still open" means the sweep declined. */
function waitOutSeveralSweeps(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20))
}

beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-claude-retry-sweep-'))
resetHostTestOperationIds()
sink = null
clock = NOW
closeSession = vi.fn(async () => true)
const store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
const adapter: StructuredAgentSessionAdapter = {
acquire: async ({ fence, spawnToken, events }) => {
sink = events ?? null
return {
process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken },
acquisitionGeneration: 'generation-1',
link: {
linkId: `link-${fence}`,
handle: { provider: 'codex', threadId: THREAD },
origin: 'created',
mintedAtFence: fence,
observedAt: NOW
}
}
},
closeSession,
releaseAcquisition: async () => true,
dispatch: async () => ({ state: 'admitted' }),
cancelTurn: async () => ({ cancelled: false }),
answerPrompt: async () => undefined,
setOption: async () => undefined
}
host = new StructuredAgentSessionHost({
store,
adapter,
journalRoot: root,
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-1',
idleSweep: { intervalMs: SWEEP_MS, idleMs: STRUCTURED_AGENT_SESSION_IDLE_MS },
now: () => clock
})
})

afterEach(async () => {
await host.flushAllStreamedEvents()
await rm(root, { recursive: true, force: true })
})

describe('a Claude retrying a refused request', () => {
it('keeps the conversation open past the idle window while retry frames arrive', async () => {
expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({
ok: true
})
if (!sink) {
throw new Error('the host never handed the provider its event sink')
}
const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: '1' })

// Five frames ten minutes apart: fifty minutes, well past the thirty-minute idle window.
for (let attempt = 1; attempt <= 5; attempt += 1) {
clock += RETRY_GAP_MS
translator.handle({ type: 'message', sessionId: SESSION, message: apiRetry(attempt) })
await waitOutSeveralSweeps()
expect(closeSession).not.toHaveBeenCalled()
expect(host.hasSession(SESSION)).toBe(true)
}

const items = host['sessions'].get(SESSION)?.journal.snapshot().items ?? []
const retryRows = items.filter(
(item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerRetrying'
)
expect(retryRows).toHaveLength(1)
expect(retryRows[0]?.body).toMatchObject({
failure: { detail: { text: expect.stringContaining('"attempt":5') } }
})

// Once the frames stop, the same clock does let the sweep close it.
clock += STRUCTURED_AGENT_SESSION_IDLE_MS
await vi.waitFor(() => {
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(host.hasSession(SESSION)).toBe(false)
})
})
})
133 changes: 133 additions & 0 deletions src/main/claude/claude-api-retry-row.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import { MAX_PROVIDER_DIAGNOSTIC_CHARS } from '../../shared/agent-session-failure'
import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory'
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'

const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'workspace-1',
hostId: 'host-1',
agent: 'claude',
providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' }
}

/** A frame as Claude Code sends it while it retries a refused request. */
function apiRetry(attempt: number, fields: Record<string, unknown> = {}): Record<string, unknown> {
return {
type: 'system',
subtype: 'api_retry',
attempt,
max_retries: 10,
retry_delay_ms: 622,
error_status: 429,
error: 'rate_limit',
session_id: 'provider-1',
uuid: `9b2f6a1e-0c4d-4e7a-8f3b-00000000000${attempt}`,
...fields
}
}

let root = ''

beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-claude-api-retry-'))
})

afterEach(async () => {
await rm(root, { recursive: true, force: true })
})

async function statusRowsFor(frames: Record<string, unknown>[]) {
const journal = await openAgentSessionJournal({
identity: IDENTITY,
journalDir: root,
now: () => 1_700_000_000_000,
mintEpoch: () => 'epoch-1'
})
const deferred = createDeferredStructuredAgentSessionEventSink()
deferred.bind({ journal, fence: 1, publish: vi.fn() })
const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' })
for (const frame of frames) {
translator.handle({ type: 'message', sessionId: 'orca-session', message: frame })
await deferred.drained()
}
return journal
.snapshot()
.items.flatMap((item): Extract<AgentJournalItemBody, { kind: 'status' }>[] =>
item.body.kind === 'status' ? [item.body] : []
)
}

describe('a Claude api_retry frame', () => {
it('writes one sentence row per retry run, revised by each attempt, not the frame', async () => {
const rows = await statusRowsFor([apiRetry(1), apiRetry(2), apiRetry(3)])

expect(rows).toHaveLength(1)
const [row] = rows
expect(row).toMatchObject({
text: 'Claude is rate-limited and retrying.',
tone: 'warning',
failure: {
kind: 'providerRetrying',
retry: { error: 'rate_limit', status: 429 },
detail: { audience: 'log' }
}
})
expect(row).not.toHaveProperty('providerFrame')
// The latest attempt's frame, as the log detail.
expect(row.failure?.detail?.text).toContain('"attempt":3')
})

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.'
])
})

it('does not call a retry a rate limit unless the frame says so', async () => {
const rows = await statusRowsFor([
apiRetry(1, { error: 'overloaded', error_status: 529 }),
apiRetry(1, { error: 'server_error', error_status: 500 }),
apiRetry(1, { error: undefined, error_status: undefined }),
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.'
])
expect(rows[0]?.failure).toMatchObject({ retry: { error: 'overloaded', status: 529 } })
})

it('caps the frame it keeps as the detail', async () => {
const [row] = await statusRowsFor([apiRetry(1, { padding: 'x'.repeat(5_000) })])
expect(row?.failure?.detail?.text.length).toBe(MAX_PROVIDER_DIAGNOSTIC_CHARS)
})

it('leaves a frame kind no one catalogued to the provider fallback, as before', async () => {
const unknown = {
type: 'system',
subtype: 'future_notice',
error: 'rate_limit',
session_id: 'provider-1',
uuid: 'future-1'
}
const rows = await statusRowsFor([unknown])
expect(rows).toEqual([
unhandledProviderFrameJournalItem('claude', 'message:system:future_notice', unknown)?.body
])
expect(rows[0]).toMatchObject({ text: 'rate_limit', tone: 'error' })
})
})
39 changes: 39 additions & 0 deletions src/main/claude/claude-api-retry-row.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// The one row a Claude `system/api_retry` frame writes: a sentence and a `providerRetrying` fact,
// revised in place for every attempt of the same retry run.

import {
agentSessionFailureFact,
providerDiagnostic,
readProviderRetry
} from '../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../shared/agent-session-failure-words'
import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types'
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 words = agentSessionFailureWords(
agentSessionFailureFact('providerRetrying', {
detail: providerDiagnostic(JSON.stringify(message), 'log'),
...(retry ? { retry } : {})
}),
{ surface: 'row', agentName: TUI_AGENT_DISPLAY_NAMES.claude }
)
return { kind: 'status', tone: 'warning', ...words }
}

/** Numbers retry runs: a frame whose attempt does not follow the last one starts a new run. */
export function createClaudeApiRetryRuns(): (message: Record<string, unknown>) => number {
let run = 0
let lastAttempt: number | null = null
return (message) => {
const attempt = typeof message.attempt === 'number' ? message.attempt : null
if (attempt === null || lastAttempt === null || attempt <= lastAttempt) {
run += 1
}
lastAttempt = attempt
return run
}
}
6 changes: 6 additions & 0 deletions src/main/claude/claude-stream-json-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { providerDiagnosticOf } from '../../shared/agent-session-failure'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process'
import { hasLiveClaudePtys } from '../claude-accounts/live-pty-gate'
Expand Down Expand Up @@ -592,6 +593,11 @@ describe('Claude stream-json connection', () => {
await until(() => exit, 'the exit error')
// The status and stderr are the only diagnostic a refused start leaves behind.
expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/)
// Kept apart from Orca's wording where it is composed, and marked as log text.
expect(providerDiagnosticOf(exit)).toEqual({
text: expect.stringMatching(/^\(code 1\)\n.*claude: not signed in/s),
audience: 'log'
})
expect(connection.closed).toBe(true)
// Stderr-triggered capture can win or lose the race with this real child's exit.
const closed = await connection.close()
Expand Down
Loading
Loading