fix(mobile): keep the provider session id alive while an agent sits idle

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
This commit is contained in:
Brennan Benson
2026-07-28 19:02:39 -07:00
parent 0660ad9d6e
commit 499dfa1c19
4 changed files with 182 additions and 2 deletions
+76
View File
@@ -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)
+19
View File
@@ -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
})
}
@@ -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',
@@ -1776,10 +1776,15 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
const orchestration =
payloadMergedOrchestration ?? runtimeMergedOrchestration ?? completedFallbackOrchestration
// Why: waiting/blocked are still the same resumable turn; child permission hooks omit the root session id.
// Completing a turn does not end the provider session either — the TUI stays alive and resumable at its
// prompt — so `done` must carry the id through, including done→done (OSC 9999 repaints and reconnect
// snapshot replays both re-deliver a metadata-less `done` onto an already-done row). Without that, every
// surface keyed on the id — mobile Chat UI transcripts, the resumable recovery anchor below — loses the
// session while the agent sits idle, which is precisely when it is read (#10630). Only a new turn
// (done→working) still drops it, so a reused pane cannot inherit a finished session.
const canReuseExistingProviderSession =
existing?.agentType === identity.agentType &&
existing.state !== 'done' &&
payload.state !== 'done'
(existing.state !== 'done' || payload.state === 'done')
const providerSession =
metadata?.providerSession ??
(canReuseExistingProviderSession ? existing.providerSession : undefined)