From 499dfa1c1968f9ce81ef3f87efb29c38773ca7bb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:30:46 -0700 Subject: [PATCH] fix(mobile): keep the provider session id alive while an agent sits idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile Chat UI subscribes to an agent transcript by providerSession.id, so losing that id blanks the chat: use-mobile-native-chat-session clears the message list and then returns without subscribing when sessionId is null. Two places dropped the id on a status ping that carried no session metadata, both while the agent was idle at its prompt — exactly when mobile reads it: - The renderer store refused to carry the id across `done`. A completed turn does not end the provider session (the TUI stays alive and resumable), and OSC 9999 repaints plus reconnect snapshot replays re-deliver a metadata-less `done` onto an already-done row, so retention has to cover done -> done. - The main-process OSC ingest overwrote the cached row without the id. The OSC wire payload has no providerSession field, so an OSC observation is never evidence the session ended. Dropping it there erased the id from persisted rows (lost across restart) and from headless `orca serve`, which serves those rows to mobile directly rather than through the renderer store. Both keep the turn boundary: a new turn after `done` still starts clean, so a reused pane cannot inherit a finished session. Closes #10630 --- src/main/agent-hooks/server.test.ts | 76 ++++++++++++++++++ src/main/agent-hooks/server.ts | 19 +++++ .../agent-status-provider-session.test.ts | 80 +++++++++++++++++++ src/renderer/src/store/slices/agent-status.ts | 9 ++- 4 files changed, 182 insertions(+), 2 deletions(-) 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