From 10f565017ea83bf70935ad4e407c6fde9ed005bb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:07:20 -0700 Subject: [PATCH] fix(native-chat): recover Claude outbox and resume state --- .../claude/claude-structured-dispatch.test.ts | 56 ++++++++++- src/main/claude/claude-structured-dispatch.ts | 21 ++++- .../claude/claude-structured-session-state.ts | 1 + ...structured-agent-session-history-result.ts | 41 ++++++++ .../structured-agent-session-host.ts | 14 +-- ...ude-structured-session-integration.test.ts | 9 ++ .../NativeChatStructuredSession.test.tsx | 1 + .../NativeChatStructuredSession.tsx | 37 +++++--- ...tructuredAgentSessionStatusBridge.test.tsx | 25 ++++- .../StructuredAgentSessionStatusBridge.tsx | 16 +++- ...e-structured-agent-session-outbox.test.tsx | 94 +++++++++++++++++++ .../use-structured-agent-session-outbox.ts | 27 +++++- .../use-structured-agent-session.test.tsx | 3 +- .../use-structured-agent-session.ts | 1 + src/renderer/src/i18n/locales/en.json | 5 +- .../agent-status-provider-session.test.ts | 21 +++++ src/renderer/src/store/slices/agent-status.ts | 10 +- src/shared/agent-session-wire.ts | 4 +- src/shared/agent-status-types.ts | 2 + 19 files changed, 354 insertions(+), 34 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index ffdd0a5bbf8..d4d70078b9a 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -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( diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index da95e373f82..eb8b8d12645 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -54,14 +54,19 @@ export function resolveClaudeReplayWaiter( session: ClaudeSession, message: Record ): 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 return content } -function waitForReplay(session: ClaudeSession, timeoutMs: number): Promise { +function waitForReplay( + session: ClaudeSession, + timeoutMs: number, + acceptsResult: boolean +): Promise { 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', diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 7e6d5449ed6..9a8462f6ac2 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -55,6 +55,7 @@ export type ClaudeStructuredSessionAdapterDeps = { export type ClaudeDispatchWaiter = { resolve: (uuid: string | null) => void timer: ReturnType + acceptsResult: boolean } export type ClaudeSession = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts new file mode 100644 index 00000000000..9830340941d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts @@ -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 } : {}) } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 77188e47ef5..687a592a1e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -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 { diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 859ff5ba34c..79cb5205cc7 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -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' }] } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 1649e3ed994..c4ba1620b93 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -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(), diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 7ff1323979c..3da71a43149 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -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.' )} - +
+ + +
) : null} {controller.error || composerError ? ( diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 934adef6ce7..28ec0d5322b 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ store: null as null | { setState: (state: Record) => 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() + + 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() + + await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalled()) + expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]).toEqual({ + terminalResumeEligible: false + }) + }) }) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index db40fbc5b74..01109d61f1c 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -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 | null = null + let providerSession: AgentProviderSessionMetadata | undefined const apply = (action: Parameters[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 { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index 6c0b78d5ce7..f589a13747c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -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)) + }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 49325667f94..62d186add49 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -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 + } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index 04fa10f32ff..c3ca2ecda54 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -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() }) })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 1015b96dbf5..6d94dc6a6e5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -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 }), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e2e6ccf9350..232e4255063 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/store/slices/agent-status-provider-session.test.ts b/src/renderer/src/store/slices/agent-status-provider-session.test.ts index b4a9e14842b..ef1dd9c1e66 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session.test.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session.test.ts @@ -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' } diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 271ab70829a..83b3c6a6e04 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -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