diff --git a/mobile/src/mobile-web/mobile-web-native-chat-binding.ts b/mobile/src/mobile-web/mobile-web-native-chat-binding.ts index 10782e1cf08..4e289ed51bf 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-binding.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-binding.ts @@ -23,7 +23,14 @@ export async function resolveFreshMobileWebNativeChatBinding(args: { (value) => isRecord(value) && value.type === 'terminal' && value.id === binding.hostTabId ) : undefined - if (!isCurrentBinding(tab, binding) || (args.requireTerminal && !binding.hostTerminalId)) { + // Why not revoke when the host reports no agent status: an unreachable SSH host strips it from a + // terminal that still exists, and loss of contact is never evidence the session is gone. Only a + // vanished tab, a different terminal, or a tab rebound to another session ends the grant. + const gone = + tab === undefined || + !isSameTerminal(tab, binding) || + (hasProviderSession(tab) && !isCurrentBinding(tab, binding)) + if (gone || (args.requireTerminal && !binding.hostTerminalId)) { args.nativeChatAuthority.revoke(args.sessionId) throw new MobileWebBrokerError('not_found') } @@ -63,6 +70,22 @@ export function assertCurrentMobileWebNativeChatPageBinding( args.nativeChatAuthority.assertBinding(binding.hostWorkspaceId, sessionId, binding) } +function hasProviderSession(value: unknown): boolean { + return ( + isRecord(value) && isRecord(value.agentStatus) && isRecord(value.agentStatus.providerSession) + ) +} + +function isSameTerminal( + value: unknown, + binding: Readonly +): boolean { + return ( + isRecord(value) && + (typeof value.terminal === 'string' ? value.terminal : null) === binding.hostTerminalId + ) +} + function isCurrentBinding( value: unknown, binding: Readonly diff --git a/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts b/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts index 6e71b71e556..b8e33538798 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts @@ -144,6 +144,36 @@ describe('mobile web native chat operations', () => { }) }) + // An unreachable SSH host strips agentStatus from a terminal that still exists. Revoking the + // grant there reported the session as gone; the read must surface the host failure instead. + it('keeps the grant when the host stops reporting agent status', async () => { + const context = operationContext() + const sendRequest = vi + .fn() + .mockResolvedValueOnce(success(sessionSnapshot({ unreachable: true }))) + .mockResolvedValueOnce(success({ error: 'Transcript unavailable' })) + + await expect( + executeMobileWebNativeChatOperation({ + operation: 'read', + payload: { + workspaceId: context.pageWorkspaceId, + sessionId: context.pageSessionId, + limit: 40 + }, + client: { sendRequest } as unknown as RpcClient, + workspaceAuthority: context.workspaceAuthority, + nativeChatAuthority: context.nativeChatAuthority, + nativeAuthority: {}, + ...OPERATION_RUNTIME + }) + ).rejects.toMatchObject({ code: 'host_error' }) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(context.nativeChatAuthority.resolve('workspace-1', context.pageSessionId)).toMatchObject( + { providerSessionId: 'provider-session-secret' } + ) + }) + it('persists pending delivery through stable hidden chat authority', async () => { const context = operationContext() const sendRequest = vi @@ -216,7 +246,7 @@ function operationContext() { } } -function sessionSnapshot(overrides: { providerSessionId?: string } = {}) { +function sessionSnapshot(overrides: { providerSessionId?: string; unreachable?: boolean } = {}) { return { worktree: 'workspace-1', tabs: [ @@ -225,14 +255,18 @@ function sessionSnapshot(overrides: { providerSessionId?: string } = {}) { id: 'tab-1', terminal: 'terminal-secret', launchAgent: 'claude', - agentStatus: { - state: 'waiting', - agentType: 'claude', - providerSession: { - id: overrides.providerSessionId ?? 'provider-session-secret', - transcriptPath: '/private/transcript.jsonl' - } - } + ...(overrides.unreachable + ? {} + : { + agentStatus: { + state: 'waiting', + agentType: 'claude', + providerSession: { + id: overrides.providerSessionId ?? 'provider-session-secret', + transcriptPath: '/private/transcript.jsonl' + } + } + }) } ] } diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index a207ae69aab..708a32aef2f 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -43,25 +43,42 @@ export type RuntimeNativeChatTranscriptBinding = { } export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { + private readonly lastKnownNativeChatSessions = new Map< + string, + Pick + >() + resolveNativeChatTranscriptBinding(handle: string): RuntimeNativeChatTranscriptBinding | null { const terminalContext = this.resolveTerminalContext(handle) - const snapshot = terminalContext - ? this.mobileSessionTabsByWorktree.get(terminalContext.worktreeId) - : null - if (!terminalContext || !snapshot) { + if (!terminalContext) { + this.lastKnownNativeChatSessions.delete(handle) return null } - const terminal = this.toMobileSessionTabsResult(snapshot).tabs.find( - (tab) => tab.type === 'terminal' && tab.status === 'ready' && tab.terminal === handle - ) - if (!terminal || terminal.type !== 'terminal') { - return null + const snapshot = this.mobileSessionTabsByWorktree.get(terminalContext.worktreeId) + const terminal = snapshot + ? this.toMobileSessionTabsResult(snapshot).tabs.find( + (tab) => tab.type === 'terminal' && tab.status === 'ready' && tab.terminal === handle + ) + : undefined + const live = + terminal?.type === 'terminal' + ? { + agent: terminal.agentStatus?.agentType ?? terminal.launchAgent ?? null, + providerSession: terminal.agentStatus?.providerSession ?? null + } + : null + if (live?.providerSession) { + this.lastKnownNativeChatSessions.set(handle, live) + return { ...terminalContext, ...live } } - return { - ...terminalContext, - agent: terminal.agentStatus?.agentType ?? terminal.launchAgent ?? null, - providerSession: terminal.agentStatus?.providerSession ?? null + // Why: a relay teardown clears this connection's agent status, but loss of contact is never + // evidence the session is gone. Keeping the last-known identity lets the read fail against + // the unreachable host instead of reporting the session as missing. + const remembered = this.lastKnownNativeChatSessions.get(handle) + if (remembered) { + return { ...terminalContext, ...remembered } } + return live ? { ...terminalContext, ...live } : null } constructor( diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-10.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-10.spec.ts index b37fa4963d4..f9e994a3e22 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-10.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-10.spec.ts @@ -528,4 +528,52 @@ describe('OrcaRuntimeService', () => { expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() }) + // An unreachable SSH host clears the connection's agent status, so the live tab loses its + // provider session. Treating that as a missing session reported not_found for a terminal that + // still exists; the last-known identity must survive loss of contact. + it('keeps the native-chat binding when loss of contact clears agent status', () => { + const runtime = new OrcaRuntimeService(null as never) + const readyTab = { + type: 'terminal', + status: 'ready', + terminal: 'handle-1', + launchAgent: 'claude', + agentStatus: { + agentType: 'claude', + providerSession: { id: 'session-1', transcriptPath: '/remote/transcript.jsonl' } + } + } + const strippedTab = { type: 'terminal', status: 'ready', terminal: 'handle-1' } + let tabs: unknown[] = [readyTab] + let context: unknown = { worktreeId: 'worktree-1', connectionId: 'ssh-1' } + const internals = runtime as unknown as { + resolveTerminalContext: (handle: string) => unknown + mobileSessionTabsByWorktree: Map + toMobileSessionTabsResult: (snapshot: unknown) => { tabs: unknown[] } + resolveNativeChatTranscriptBinding: (handle: string) => { providerSession?: unknown } | null + } + internals.resolveTerminalContext = () => context + internals.mobileSessionTabsByWorktree = new Map([['worktree-1', {}]]) + internals.toMobileSessionTabsResult = () => ({ tabs }) + + expect(internals.resolveNativeChatTranscriptBinding('handle-1')).toMatchObject({ + agent: 'claude', + providerSession: { id: 'session-1' } + }) + + tabs = [strippedTab] + expect(internals.resolveNativeChatTranscriptBinding('handle-1')).toMatchObject({ + agent: 'claude', + providerSession: { id: 'session-1' } + }) + + // A terminal that is genuinely gone still resolves to nothing. + context = null + expect(internals.resolveNativeChatTranscriptBinding('handle-1')).toBeNull() + // ...and the remembered identity goes with it, so the grant cannot outlive the terminal. + context = { worktreeId: 'worktree-1', connectionId: 'ssh-1' } + expect(internals.resolveNativeChatTranscriptBinding('handle-1')).toMatchObject({ + providerSession: null + }) + }) })