fix(native-chat): recover Claude outbox and resume state

This commit is contained in:
Brennan Benson
2026-08-14 14:07:20 -07:00
parent 53b2b51d60
commit 10f565017e
19 changed files with 354 additions and 34 deletions
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import { dispatchClaudeTurn } from './claude-structured-dispatch'
import { dispatchClaudeTurn, resolveClaudeReplayWaiter } from './claude-structured-dispatch'
import type { ClaudeSession } from './claude-structured-session-state'
function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession {
@@ -26,6 +26,60 @@ function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMes
}
describe('Claude structured dispatch image limits', () => {
it('accepts a slash command from its result receipt when Claude omits the user replay', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) },
100
)
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
resolveClaudeReplayWaiter(session, {
type: 'result',
subtype: 'success',
session_id: 'provider-session',
uuid: 'command-result-uuid'
})
await expect(dispatched).resolves.toEqual({
state: 'accepted',
providerIdentity: {
provider: 'claude',
sessionId: 'provider-session',
uuid: 'command-result-uuid'
}
})
})
it('does not mistake a normal turn result for its missing user replay', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'hello' }]) },
100
)
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
resolveClaudeReplayWaiter(session, {
type: 'result',
session_id: 'provider-session',
uuid: 'unrelated-result-uuid'
})
expect(session.dispatchWaiters).toHaveLength(1)
resolveClaudeReplayWaiter(session, {
type: 'user',
parent_tool_use_id: null,
session_id: 'provider-session',
uuid: 'user-replay-uuid'
})
await expect(dispatched).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { uuid: 'user-replay-uuid' }
})
})
it('rejects more than twenty URL images before sending', async () => {
const session = sessionFor()
const body = userMessage(
+17 -4
View File
@@ -54,14 +54,19 @@ export function resolveClaudeReplayWaiter(
session: ClaudeSession,
message: Record<string, unknown>
): void {
const isUserReplay = message.type === 'user' && message.parent_tool_use_id === null
const isCompletedCommand = message.type === 'result'
if (
message.type !== 'user' ||
message.parent_tool_use_id !== null ||
(!isUserReplay && !isCompletedCommand) ||
readClaudeFrameString(message, 'session_id') !== session.providerSessionId
) {
return
}
const uuid = readClaudeFrameString(message, 'uuid')
const current = session.dispatchWaiters[0]
if (isCompletedCommand && !current?.acceptsResult) {
return
}
const waiter = uuid ? session.dispatchWaiters.shift() : undefined
if (waiter && uuid) {
clearTimeout(waiter.timer)
@@ -121,9 +126,14 @@ async function messageContent(body: AgentJournalMessageItem): Promise<unknown[]>
return content
}
function waitForReplay(session: ClaudeSession, timeoutMs: number): Promise<string | null> {
function waitForReplay(
session: ClaudeSession,
timeoutMs: number,
acceptsResult: boolean
): Promise<string | null> {
return new Promise((resolve) => {
const waiter = {
acceptsResult,
resolve,
timer: setTimeout(() => {
const index = session.dispatchWaiters.indexOf(waiter)
@@ -149,7 +159,10 @@ export async function dispatchClaudeTurn(
} catch (error) {
return { state: 'rejected', reason: (error as Error).message }
}
const replayed = waitForReplay(session, timeoutMs)
const acceptsResult = input.body.blocks.some(
(block) => block.type === 'text' && block.text.trimStart().startsWith('/')
)
const replayed = waitForReplay(session, timeoutMs, acceptsResult)
try {
await session.connection.send({
type: 'user',
@@ -55,6 +55,7 @@ export type ClaudeStructuredSessionAdapterDeps = {
export type ClaudeDispatchWaiter = {
resolve: (uuid: string | null) => void
timer: ReturnType<typeof setTimeout>
acceptsResult: boolean
}
export type ClaudeSession = {
@@ -0,0 +1,41 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume'
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
import type {
AgentSessionHistoryRequest,
AgentSessionHistoryResult
} from '../../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { readAgentSessionHistory } from './agent-session-history-page'
function providerSessionMetadata(
record: AgentSessionRecord | null
): AgentProviderSessionMetadata | undefined {
const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null
return head
? {
key: 'session_id',
id: head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId
}
: undefined
}
export function readStructuredAgentSessionHistoryResult(input: {
journal: AgentSessionJournal
record: AgentSessionRecord | null
request: AgentSessionHistoryRequest
}): AgentSessionHistoryResult {
const result = readAgentSessionHistory(input.journal, input.request)
const fence = input.record?.lease.runtimeFence
const providerSession = providerSessionMetadata(input.record)
if (fence === undefined) {
return providerSession ? { ...result, providerSession } : result
}
return result.ok
? {
...result,
page: { ...result.page, fence },
...(providerSession ? { providerSession } : {})
}
: { ...result, fence, ...(providerSession ? { providerSession } : {}) }
}
@@ -18,7 +18,6 @@ import type {
AgentSessionSendResult,
AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import { readAgentSessionHistory } from './agent-session-history-page'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import { performAttach } from './structured-agent-session-attach-flow'
import {
@@ -54,6 +53,7 @@ import type {
StructuredAgentSessionHostDeps,
StructuredAgentSessionHostSession
} from './structured-agent-session-host-types'
import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result'
export type {
StructuredAgentSessionCaller,
StructuredAgentSessionHostDeps
@@ -292,12 +292,12 @@ export class StructuredAgentSessionHost {
}
history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult {
const result = readAgentSessionHistory(this.requireSession(request.sessionId).journal, request)
const fence = this.deps.store.getRecord(request.sessionId)?.lease.runtimeFence
if (fence === undefined) {
return result
}
return result.ok ? { ...result, page: { ...result.page, fence } } : { ...result, fence }
const record = this.deps.store.getRecord(request.sessionId)
return readStructuredAgentSessionHistoryResult({
journal: this.requireSession(request.sessionId).journal,
record,
request
})
}
subscribe(input: AgentSessionSubscribeInput): () => void {
@@ -382,6 +382,15 @@ describe('a structured Claude session over agentSession.*', () => {
CLAUDE_CONFIG_DIR: join(root, 'claude-home'),
[CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String)
})
const history = await call('agentSession.history', {
sessionId: SESSION,
direction: 'tail',
limit: 1
})
expect(history).toMatchObject({
ok: true,
result: { providerSession: { key: 'session_id', id: PROVIDER_SESSION } }
})
const stream = await subscribe()
const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] }
@@ -32,6 +32,7 @@ vi.mock('./use-structured-agent-session', () => ({
blockedClientMessageId: null,
send: vi.fn(),
retry: vi.fn(),
discard: vi.fn(),
isWorking: false,
turnId: null,
cancel: vi.fn(),
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { RotateCcw, Trash2 } from 'lucide-react'
import type { AgentType } from '../../../../shared/agent-status-types'
import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
@@ -221,18 +221,29 @@ export function NativeChatStructuredSession(props: {
'Message was not sent.'
)}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => controller.retry(retryableOutboxEntry.clientMessageId)}
>
<RotateCcw className="size-3" />
{translate(
'auto.components.native.chat.NativeChatStructuredSession.a5e7f14068',
'Retry'
)}
</Button>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => controller.discard(retryableOutboxEntry.clientMessageId)}
>
<Trash2 className="size-3" />
{translate('components.native-chat.structuredSession.discardOutboxEntry', 'Discard')}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => controller.retry(retryableOutboxEntry.clientMessageId)}
>
<RotateCcw className="size-3" />
{translate(
'auto.components.native.chat.NativeChatStructuredSession.a5e7f14068',
'Retry'
)}
</Button>
</div>
</div>
) : null}
{controller.error || composerError ? (
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
store: null as null | {
setState: (state: Record<string, unknown>) => void
},
setAgentStatus: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn()
}))
@@ -18,7 +19,7 @@ vi.mock('@/store', async () => {
const useAppStore = create(() => ({
unifiedTabsByWorktree: {},
testRuntimeOwner: null,
setAgentStatus: vi.fn(),
setAgentStatus: mocks.setAgentStatus,
removeAgentStatus: vi.fn()
}))
mocks.store = useAppStore
@@ -54,6 +55,7 @@ const structuredTab = {
const historyResult = {
ok: true,
providerSession: { key: 'session_id', id: 'provider-session-uuid' },
page: {
sessionId: 'session-1',
epoch: 'epoch-1',
@@ -111,4 +113,25 @@ describe('StructuredAgentSessionStatusBridge', () => {
await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(2), { timeout: 1_500 })
})
it('publishes the provider id without making native chat terminal-resumable', async () => {
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalled())
expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]).toEqual({
providerSession: { key: 'session_id', id: 'provider-session-uuid' },
terminalResumeEligible: false
})
expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]?.providerSession?.id).not.toBe('session-1')
})
it('omits resume identity instead of substituting the desktop id on an older host', async () => {
mocks.call.mockResolvedValueOnce({ ...historyResult, providerSession: undefined })
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalled())
expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]).toEqual({
terminalResumeEligible: false
})
})
})
@@ -4,6 +4,7 @@ import type {
AgentSessionHandoffStatus,
AgentSessionHistoryResult
} from '../../../../shared/agent-session-wire'
import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume'
import {
projectStructuredAgentSessionStatus,
structuredAgentSessionPaneKey
@@ -39,7 +40,11 @@ function latestPrompt(state: StructuredAgentSessionState): string {
return ''
}
function projectStatus(tab: StructuredTab, state: StructuredAgentSessionState): void {
function projectStatus(
tab: StructuredTab,
state: StructuredAgentSessionState,
providerSession: AgentProviderSessionMetadata | undefined
): void {
const projection = projectStructuredAgentSessionStatus(state.items)
const store = useAppStore.getState()
store.setAgentStatus(
@@ -53,7 +58,10 @@ function projectStatus(tab: StructuredTab, state: StructuredAgentSessionState):
tab.label,
undefined,
{ tabId: tab.id, worktreeId: tab.worktreeId },
{ providerSession: { key: 'session_id', id: tab.entityId } }
{
...(providerSession ? { providerSession } : {}),
terminalResumeEligible: false
}
)
}
@@ -64,9 +72,10 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
let opening = false
let unsubscribe = (): void => {}
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let providerSession: AgentProviderSessionMetadata | undefined
const apply = (action: Parameters<typeof reduceStructuredAgentSession>[1]): void => {
state = reduceStructuredAgentSession(state, action)
projectStatus(tab, state)
projectStatus(tab, state, providerSession)
}
const scheduleReconnect = (): void => {
if (stopped || connected || reconnectTimer) {
@@ -155,6 +164,7 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
if (stopped) {
return
}
providerSession = result.providerSession
if (result.ok) {
apply({ type: 'tail-page', page: result.page })
} else {
@@ -50,6 +50,28 @@ function refusedResult(code: AgentSessionWireRefusalCode) {
return { ok: false, refusal: { code, message: code } }
}
function unknownResult(clientMessageId: string) {
return {
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-1', sequence: 1 },
value: {
clientMessageId,
submission: {
clientMessageId,
fence: 1,
payloadFingerprint: 'fingerprint',
dispatchState: 'unknown',
providerItemId: null,
reason: 'provider receipt missing',
submittedAt: 1,
resolvedAt: 1
}
}
}
}
describe('useStructuredAgentSessionOutbox', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -148,4 +170,76 @@ describe('useStructuredAgentSessionOutbox', () => {
.clientOperationId
).toBe(firstId)
})
it('discards an unconfirmed head after restart and dispatches the queued message', async () => {
vi.mocked(globalThis.crypto.randomUUID)
.mockReturnValueOnce('11111111-1111-4111-8111-111111111111')
.mockReturnValueOnce('22222222-2222-4222-8222-222222222222')
mocks.call
.mockResolvedValueOnce(unknownResult('11111111-1111-4111-8111-111111111111'))
.mockResolvedValueOnce(acceptedResult(1))
const first = renderHook(() =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
submissions: []
})
)
act(() => expect(first.result.current.send('/permissions prompt')).toBe(true))
await waitFor(() => expect(first.result.current.outbox[0]?.state).toBe('unconfirmed'))
act(() => expect(first.result.current.send('send this next')).toBe(true))
const staleId = first.result.current.outbox[0]!.clientMessageId
first.unmount()
const restarted = renderHook(() =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
submissions: []
})
)
expect(restarted.result.current.outbox).toHaveLength(2)
act(() => restarted.result.current.discard(staleId))
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
expect(mocks.call.mock.calls[1]?.[2]).toMatchObject({
body: { blocks: [{ type: 'text', text: 'send this next' }] }
})
await waitFor(() => expect(restarted.result.current.outbox).toHaveLength(0))
})
it('retries an unconfirmed slash command after restart without leaving a durable head', async () => {
mocks.call
.mockResolvedValueOnce(unknownResult('11111111-1111-4111-8111-111111111111'))
.mockResolvedValueOnce(acceptedResult(1))
const first = renderHook(() =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
submissions: []
})
)
act(() => expect(first.result.current.send('/permissions prompt')).toBe(true))
await waitFor(() => expect(first.result.current.outbox[0]?.state).toBe('unconfirmed'))
const staleId = first.result.current.outbox[0]!.clientMessageId
first.unmount()
const restarted = renderHook(() =>
useStructuredAgentSessionOutbox({
sessionId: 'session-1',
target: LOCAL_TARGET,
fence: 1,
submissions: []
})
)
act(() => restarted.result.current.retry(staleId))
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
await waitFor(() => expect(restarted.result.current.outbox).toHaveLength(0))
})
})
@@ -270,5 +270,30 @@ export function useStructuredAgentSessionOutbox(args: {
outboxRef.current = next
setOutbox(next)
}
return { outbox, error, blockedClientMessageId: blockedIdRef.current, send, retry }
const discard = (clientMessageId: string): void => {
const discarded = outboxRef.current.find((entry) => entry.clientMessageId === clientMessageId)
const next = outboxRef.current.filter((entry) => entry.clientMessageId !== clientMessageId)
if (!writeOutbox(sessionId, next)) {
setError('Message could not be removed from the outbox')
return
}
const clearsError =
discarded?.state === 'unconfirmed' || blockedIdRef.current === clientMessageId
if (blockedIdRef.current === clientMessageId) {
blockedIdRef.current = null
}
if (clearsError) {
setError(null)
}
outboxRef.current = next
setOutbox(next)
}
return {
outbox,
error,
blockedClientMessageId: blockedIdRef.current,
send,
retry,
discard
}
}
@@ -33,7 +33,8 @@ vi.mock('./use-structured-agent-session-outbox', () => ({
blockedClientMessageId: null,
error: null,
send: vi.fn(),
retry: vi.fn()
retry: vi.fn(),
discard: vi.fn()
})
}))
@@ -232,6 +232,7 @@ export function useStructuredAgentSession(args: {
blockedClientMessageId: outboxController.blockedClientMessageId,
send: outboxController.send,
retry: outboxController.retry,
discard: outboxController.discard,
isWorking: turnId !== null,
turnId,
cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }),
+4 -1
View File
@@ -15202,7 +15202,10 @@
"allow": "Allow",
"deny": "Deny"
},
"launchPromptNotDelivered": "Not delivered — check the terminal"
"launchPromptNotDelivered": "Not delivered — check the terminal",
"structuredSession": {
"discardOutboxEntry": "Discard"
}
},
"tab": {
"bar": {
@@ -16,6 +16,27 @@ function makePiCompatibleProviderSession(agent: 'pi' | 'omp' | 'prime-agent') {
}
describe('recordAgentProviderSession', () => {
it('does not capture a structured native owner for terminal resume on restart', () => {
const store = createTestStore()
const paneKey = 'structured-tab:leaf-1'
const providerSession = { key: 'session_id' as const, id: 'provider-session-uuid' }
store
.getState()
.setAgentStatus(
paneKey,
{ state: 'working', prompt: 'keep going', agentType: 'claude' },
'Claude Chat',
undefined,
{ tabId: 'structured-tab', worktreeId: 'wt-1' },
{ providerSession, terminalResumeEligible: false }
)
store.getState().captureAllSleepingAgentSessions('quit')
expect(store.getState().agentStatusByPaneKey[paneKey]?.providerSession).toEqual(providerSession)
expect(store.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
})
it('preserves the root session while a child permission hook moves Codex to waiting', () => {
const store = createTestStore()
const providerSession = { key: 'session_id' as const, id: 'root-session' }
@@ -169,6 +169,7 @@ export type AgentStatusSlice = {
providerSession?: AgentProviderSessionMetadata
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
terminalResumeEligible?: false
}
) => void
@@ -534,7 +535,11 @@ function sleepingRecordFromEntry(args: {
origin?: SleepingAgentSessionRecord['origin']
}): SleepingAgentSessionRecord | null {
const agent = args.entry.agentType
if (!isResumableTuiAgent(agent) || !args.entry.providerSession) {
if (
args.entry.terminalResumeEligible === false ||
!isResumableTuiAgent(agent) ||
!args.entry.providerSession
) {
return null
}
if (!getAgentResumeArgv(agent, args.entry.providerSession)) {
@@ -1941,6 +1946,9 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
? existing?.subagents
: payload.subagents,
...(providerSession ? { providerSession } : {}),
...(metadata?.terminalResumeEligible === false
? { terminalResumeEligible: false as const }
: {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
// Why: `interrupted` is done-only; parseAgentStatusPayload already clamps it for non-done states, so write it through directly.
+3 -1
View File
@@ -13,6 +13,7 @@ import type {
AgentJournalSubmission
} from './agent-session-journal-types'
import type { AgentSessionHandoffStage, AgentSessionOwnerRuntimeKind } from './agent-session-record'
import type { AgentProviderSessionMetadata } from './agent-session-resume'
export type AgentSessionHandoffDirection = 'to-tui' | 'to-native'
export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn'
@@ -93,7 +94,7 @@ export type AgentSessionHistoryPage = {
}
export type AgentSessionHistoryResult =
| { ok: true; page: AgentSessionHistoryPage }
| { ok: true; page: AgentSessionHistoryPage; providerSession?: AgentProviderSessionMetadata }
/** Every reset forces a clean reload; the snapshot is inlined so the client
* never has to make a second call to recover. */
| {
@@ -101,6 +102,7 @@ export type AgentSessionHistoryResult =
reset: AgentJournalResetReason
snapshot: AgentJournalSnapshot
fence?: number
providerSession?: AgentProviderSessionMetadata
}
/** Cursor-qualified incremental publication. Items and submissions carry their
+2
View File
@@ -138,6 +138,8 @@ export type AgentStatusEntry = {
/** Provider-owned conversation/session id captured from hook payloads.
* Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */
providerSession?: AgentProviderSessionMetadata
/** False when the status belongs to a non-terminal owner that restores itself. */
terminalResumeEligible?: false
/** Live-only Command Code turn boundary key; not persisted to last-status.json. */
promptInteractionKey?: string
/** True for a nonterminal state hydrated from last-status.json with no live hook since: