diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index e05e0c17312..5deca6d8ea6 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -7640,6 +7640,82 @@ describe('AgentHookServer ingestRemote', () => { }) describe('AgentHookServer ingestTerminalStatus', () => { + // Why: the OSC 9999 payload cannot carry a provider session, so letting it overwrite the row + // erased the session id from persisted rows and from headless `orca serve` — which serves these + // rows straight to mobile — leaving Chat UI with no transcript to subscribe to (#10630). + it('keeps the cached provider session when an OSC status completes the turn', () => { + const server = new AgentHookServer() + const providerSession = { key: 'session_id' as const, id: 'claude-session-1' } + + server.ingestRemote( + { + paneKey: PANE, + providerSession, + payload: { state: 'working', prompt: 'summarize the diff', agentType: 'claude' } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'done', prompt: 'summarize the diff', agentType: 'claude' } + }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', providerSession }) + }) + + // Why: an OSC ping that names no agent — or the literal 'unknown', which + // resolveAgentStatusIdentity treats identically — makes no claim about the pane's identity, so it + // must not read as a mismatch. Missing this stripped the session from persisted and headless rows + // while the live renderer kept it, blanking mobile chat only after a restart (#10630). + it.each([undefined, 'unknown' as const])( + 'keeps the cached provider session when an OSC status claims agentType %s', + (agentType) => { + const server = new AgentHookServer() + const providerSession = { key: 'session_id' as const, id: 'claude-session-1' } + + server.ingestRemote( + { + paneKey: PANE, + providerSession, + payload: { state: 'working', prompt: 'summarize the diff', agentType: 'claude' } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { + state: 'done', + prompt: 'summarize the diff', + ...(agentType ? { agentType } : {}) + } + }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ providerSession }) + } + ) + + it('drops the cached provider session when an OSC status starts a new turn', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + providerSession: { key: 'session_id' as const, id: 'claude-session-1' }, + payload: { state: 'done', prompt: 'first', agentType: 'claude' } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'working', prompt: 'second', agentType: 'claude' } + }) + + expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined() + }) + it('forwards runtime terminal status through the normal listener and snapshot path', () => { vi.useFakeTimers() vi.setSystemTime(1_000) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 8887a285235..de451cca77d 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1463,12 +1463,31 @@ export class AgentHookServer { ) { return } + // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is + // never evidence that the session ended — yet overwriting the row dropped the cached identity. + // That erased it from persisted rows (lost across restart) and from headless `orca serve`, which + // serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630). + // A new turn after `done` still starts clean so a reused pane cannot inherit a finished session. + // Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an + // omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so + // it must not be read as a mismatch and strip the session the renderer would have kept. + const claimedAgentType = + event.payload.agentType && event.payload.agentType !== 'unknown' + ? event.payload.agentType + : undefined + const preservedProviderSession = + previous?.providerSession && + (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && + (previous.payload.state !== 'done' || event.payload.state === 'done') + ? previous.providerSession + : undefined // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. this.applyNormalizedStatus({ paneKey, tabId, worktreeId, connectionId, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), payload: event.payload }) } 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 30262d079f0..eb541647128 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 @@ -41,6 +41,86 @@ describe('recordAgentProviderSession', () => { ) }) + // Why: mobile Chat UI keys its transcript subscription on providerSession.id, so a + // metadata-less end-of-turn `done` used to blank the chat every turn (#10630). + it('keeps the provider session when the turn completes without session metadata', () => { + const store = createTestStore() + const providerSession = { key: 'session_id' as const, id: 'claude-session-1' } + + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'summarize the diff', agentType: 'claude' }, + 'Claude', + { updatedAt: 10, stateStartedAt: 10 }, + undefined, + { providerSession } + ) + store.getState().setAgentStatus('tab-1:leaf-1', { + state: 'done', + prompt: 'summarize the diff', + agentType: 'claude' + }) + + expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']?.providerSession).toEqual( + providerSession + ) + }) + + // Why: `done` is the resting state, and both OSC 9999 repaints and reconnect snapshot + // replays re-deliver a metadata-less `done` onto an already-done row. Retaining only the + // first one still blanked the chat the moment a second landed (#10630). + it('keeps the provider session across repeated metadata-less done pings', () => { + const store = createTestStore() + const providerSession = { key: 'session_id' as const, id: 'claude-session-1' } + + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'summarize the diff', agentType: 'claude' }, + 'Claude', + { updatedAt: 10, stateStartedAt: 10 }, + undefined, + { providerSession } + ) + for (const prompt of ['summarize the diff', 'summarize the diff again']) { + store + .getState() + .setAgentStatus('tab-1:leaf-1', { state: 'done', prompt, agentType: 'claude' }) + } + + expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']?.providerSession).toEqual( + providerSession + ) + }) + + // Why: retention stops at the turn boundary. A metadata-less `working` after `done` starts + // fresh work, so the finished session must not ride along into it. + it('does not carry a completed session into the next turn', () => { + const store = createTestStore() + + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'first', agentType: 'claude' }, + 'Claude', + { updatedAt: 10, stateStartedAt: 10 }, + undefined, + { providerSession: { key: 'session_id' as const, id: 'claude-session-1' } } + ) + store + .getState() + .setAgentStatus('tab-1:leaf-1', { state: 'done', prompt: 'first', agentType: 'claude' }) + store + .getState() + .setAgentStatus('tab-1:leaf-1', { state: 'working', prompt: 'second', agentType: 'claude' }) + + expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']?.providerSession).toBeUndefined() + }) + it('uses the session file as part of Pi resume ownership only', () => { const base = { paneKey: 'tab-1:leaf-1', diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 90603abb19b..92d40ff95f9 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1776,10 +1776,15 @@ export const createAgentStatusSlice: StateCreator