mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user