Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/main/agent-hooks/server-ai-vault-liveness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import type { AgentStatusClearIpcPayload } from '../../shared/agent-status-types'
import { AgentHookServer } from './server'

const PANE = makePaneKey('external-live', '11111111-1111-4111-8111-111111111111')
Expand Down Expand Up @@ -40,6 +41,29 @@ describe('AgentHookServer AI Vault liveness identity', () => {
expect(server.getStatusChangeSnapshot()).toEqual([])
})

it('forgets a resume refused for a native chat, and tells renderers, only for that conversation', () => {
const server = new AgentHookServer()
const cleared: AgentStatusClearIpcPayload[] = []
server.setPaneStatusClearListener((clear) => cleared.push(clear))
seedLocalIdentity(server)
const other = {
agent: 'gemini',
providerSession: { key: 'session_id' as const, id: 'other' }
}
const refused = { agent: 'gemini', providerSession: PROVIDER_SESSION }

server.forgetRefusedPaneResume(PANE, other)
expect(server.getStatusSnapshot()).toHaveLength(1)
server.forgetRefusedPaneResume(PANE, refused)

// No resumable remnant either, unlike a dismissal.
expect(server.getStatusSnapshot()).toEqual([])
expect(cleared).toEqual([
{ paneKey: PANE, refusedResume: other },
{ paneKey: PANE, refusedResume: refused }
])
})

it('hydrates dismissed provider identity without reviving visible status', async () => {
const userDataPath = await mkdtemp(join(tmpdir(), 'orca-ai-vault-liveness-'))
const first = new AgentHookServer()
Expand Down
23 changes: 23 additions & 0 deletions src/main/agent-hooks/server/server-tab-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,31 @@ import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listene
import { paneCacheKeyMatchesTab } from './server-status-identity'
import { AgentHookServerCleanup } from './server-cleanup'
import type { EnrichedAgentHookEventPayload } from './server-types'
import type { RefusedPaneResume } from '../../../shared/agent-status-types'
import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume'

export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
/** The host refused `paneKey`'s saved resume because a native chat owns that conversation:
* forget the pane's claim on it here, with no resumable remnant, and tell renderers to forget
* theirs. A row for another conversation is left alone. */
forgetRefusedPaneResume(paneKey: string, refused: RefusedPaneResume): void {
// Why the owner's key: a moved pane's row, and the renderer's state for it, live under it.
const ownerKey = this.resolvePaneKeyAlias(paneKey)
const row = this.state.lastStatusByPaneKey.get(ownerKey)
const clear = { paneKey: ownerKey, refusedResume: refused }
if (
row &&
row.payload.agentType === refused.agent &&
agentProviderSessionsEqual(refused.agent, row.providerSession, refused.providerSession)
) {
this.dropStatusEntry(ownerKey, { preserveResumeIdentity: false })
this.emitPaneStatusCleared(clear)
return
}
// Why renderers only: the pane's row is another conversation's, which other subscribers keep.
this.onPaneStatusCleared?.(clear)
}

/** Drop every status/cache claim attributable to a closed tab prefix. */
dropStatusEntriesByTabPrefix(tabId: string): void {
this.markTabClosedForAgentStatus(tabId)
Expand Down
27 changes: 27 additions & 0 deletions src/main/ai-vault/structured-session-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ import {
} from '../native-chat/structured-agent-cli-conversations'
import {
STRUCTURED_AGENT_RUNTIME_REGISTRATIONS,
structuredAgentRuntimeRegistration,
type StructuredAgentRuntimeRegistration
} from '../runtime/structured-agent-runtime-registrations'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
import { commandName, commandProgramIndex, shellCommands } from './shell-command-words'

/** Whether the client a projection answers opens a chat of this agent from its history row. A row
Expand Down Expand Up @@ -175,6 +178,30 @@ export async function assertLegacyAiVaultResumeCommandAllowed(
}
}

/** The same rule for a resume a terminal launches from its saved state rather than a typed command:
* `agent` resuming `providerSession` would write a conversation one of `records` owns. */
export function nativeChatOwnsTerminalResume(
records: readonly AgentSessionRecord[],
agent: string,
providerSession: AgentProviderSessionMetadata
): boolean {
const owner = historyRowOwner(agent)
const history = owner ? structuredAgentRuntimeRegistration(owner)?.sessionHistory : null
if (!owner || !history) {
return false
}
const matches = history.resumeTargetMatches ?? sameIdInAnyCase
// A session-file agent resumes by the file the hook reported, the rest by id.
const targets = [providerSession.id, providerSession.transcriptPath].flatMap((target) =>
target?.trim() ? [target.trim()] : []
)
return listStructuredProviderSessionOwnership(records).some(
(ownership) =>
ownership.provider === owner &&
targets.some((target) => matches(target, ownership.providerSessionId))
)
}

function findResumeOwnership(
args: AiVaultPrepareSessionResumeArgs
): StructuredProviderSessionOwnership | null {
Expand Down
2 changes: 2 additions & 0 deletions src/main/ipc/pty-ipc-mock-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const openCodeClearPtyMock: Mock = vi.fn()
export const buildAgentHookEnvMock: Mock = vi.fn()
export const clearAgentHookPaneStateMock: Mock = vi.fn()
export const registerPaneKeyAliasMock: Mock = vi.fn()
export const forgetRefusedPaneResumeMock: Mock = vi.fn()
export const piBuildPtyEnvMock: Mock = vi.fn()
export const piClearPtyMock: Mock = vi.fn()
export const trackMock: Mock = vi.fn()
Expand Down Expand Up @@ -134,6 +135,7 @@ export const agentHookServerModuleMock = () => ({
buildPtyEnv: buildAgentHookEnvMock,
clearPaneState: clearAgentHookPaneStateMock,
registerPaneKeyAlias: registerPaneKeyAliasMock,
forgetRefusedPaneResume: forgetRefusedPaneResumeMock,
clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock
}
})
Expand Down
2 changes: 2 additions & 0 deletions src/main/ipc/pty-ipc-suite-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
buildAgentHookEnvMock,
clearAgentHookPaneStateMock,
registerPaneKeyAliasMock,
forgetRefusedPaneResumeMock,
piBuildPtyEnvMock,
piClearPtyMock,
isPwshAvailableMock,
Expand Down Expand Up @@ -159,6 +160,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment {
buildAgentHookEnvMock.mockReset()
clearAgentHookPaneStateMock.mockReset()
registerPaneKeyAliasMock.mockReset()
forgetRefusedPaneResumeMock.mockReset()
piBuildPtyEnvMock.mockReset()
piClearPtyMock.mockReset()
isPwshAvailableMock.mockReset()
Expand Down
170 changes: 170 additions & 0 deletions src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { describe, expect, vi } from 'vitest'
import { forgetRefusedPaneResumeMock, spawnMock } from './pty-ipc-mock-registry'
import { posixOnlyIt } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command'
import { registerPtyHandlers } from './pty/register-handlers'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import type { AgentSessionRecord } from '../../shared/agent-session-record'

vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
vi.mock('node:child_process', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
)
vi.mock('../opencode/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
)
vi.mock('../mimo/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
)
vi.mock('../agent-hooks/server', () =>
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
)
vi.mock('../pi/titlebar-extension-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
)
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
vi.mock('../wsl', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
)
vi.mock('../telemetry/client', () =>
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
)
vi.mock('../telemetry/classify-error', () =>
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
)
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
)
vi.mock('../memory/pty-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
)
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
)
vi.mock('../codex/codex-pane-account-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
)
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
)

describe('registerPtyHandlers', () => {
const { handlers, mainWindow, createMockProc } = setupPtyIpcSuite()

describe('a saved resume of a conversation a native chat owns', () => {
// The incident: a chat's Claude session saved by a floating pane that leaked into the chat.
const CHAT_SESSION_ID = 'b6bd7d57-2ec3-4e62-a47d-715e2455301d'
const PANE_SESSION_ID = '2f0c1d3e-5a6b-4c7d-8e9f-0a1b2c3d4e5f'

function chatOwning(providerSessionId: string): AgentSessionRecord {
const record = agentSessionRecordFixture()
const [link] = record.providerHandleChain
return {
...record,
providerHandleChain: [{ ...link!, handle: claudeProviderHandle(providerSessionId, null) }]
}
}

function registerWithChats(listRecords: () => AgentSessionRecord[]) {
const runtime = new OrcaRuntimeService()
vi.spyOn(runtime, 'openAgentSessionRecordStore').mockImplementation(async () => {
const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype)
store.listRecords = listRecords
return store
})
registerPtyHandlers(mainWindow, runtime)
}

const LEAF_ID = '11111111-1111-4111-8111-111111111111'

async function spawnClaudeResume(
providerSessionId: string,
{ spawnIntent = 'automatic-restore', leafId = LEAF_ID } = {}
) {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
vi.useFakeTimers()
try {
const result = await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
tabId: 'tab-floating',
leafId,
command: `claude --resume '${providerSessionId}'`,
launchAgent: 'claude',
resumeProviderSession: { key: 'session_id', id: providerSessionId },
spawnIntent
})
for (let flush = 0; flush < 3; flush += 1) {
await Promise.resolve()
vi.runAllTimers()
}
const env: Record<string, string> = spawnMock.mock.calls.at(-1)![2].env
const typed = mockProc.proc.write.mock.calls.map((call) => String(call[0]))
const reported = typeof result === 'object' && result !== null ? result : {}
return {
refused: 'agentResumeRefused' in reported,
resumeUnavailable: 'agentResumeUnavailable' in reported,
input: [env[POSIX_SHELL_STARTUP_COMMAND_ENV] ?? '', ...typed].join('\n'),
env
}
} finally {
vi.useRealTimers()
}
}

posixOnlyIt('starts the plain shell, says so, and the host forgets it', async () => {
registerWithChats(() => [chatOwning(CHAT_SESSION_ID)])

const spawned = await spawnClaudeResume(CHAT_SESSION_ID)

expect(spawned.resumeUnavailable).toBe(true)
expect(spawned.refused).toBe(true)
expect(spawned.input).not.toContain(CHAT_SESSION_ID)
// A plain shell, not an unattended agent's: Git may prompt for credentials again.
expect(spawned.env.GIT_TERMINAL_PROMPT).toBeUndefined()
expect(forgetRefusedPaneResumeMock).toHaveBeenCalledWith(`tab-floating:${LEAF_ID}`, {
agent: 'claude',
providerSession: { key: 'session_id', id: CHAT_SESSION_ID }
})
})

posixOnlyIt('resumes a conversation no chat owns', async () => {
registerWithChats(() => [chatOwning(CHAT_SESSION_ID)])

const spawned = await spawnClaudeResume(PANE_SESSION_ID)

expect(spawned.resumeUnavailable).toBe(false)
expect(spawned.input).toContain(`--resume '${PANE_SESSION_ID}'`)
expect(spawned.env.GIT_TERMINAL_PROMPT).toBe('0')
expect(forgetRefusedPaneResumeMock).not.toHaveBeenCalled()
})

posixOnlyIt('with unreadable chat records, refuses only an automatic restore', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
registerWithChats(() => {
throw new Error('journal unavailable')
})

const restored = await spawnClaudeResume(CHAT_SESSION_ID)
const asked = await spawnClaudeResume(CHAT_SESSION_ID, {
spawnIntent: 'user-resume',
leafId: '22222222-2222-4222-8222-222222222222'
})

expect(restored.refused).toBe(true)
expect(restored.input).not.toContain(CHAT_SESSION_ID)
expect(asked.refused).toBe(false)
expect(asked.input).toContain(`--resume '${CHAT_SESSION_ID}'`)
// Nothing is forgotten: the saved session is tried again on the next restore.
expect(forgetRefusedPaneResumeMock).not.toHaveBeenCalled()
})
})
})
23 changes: 23 additions & 0 deletions src/main/ipc/pty/host-env/native-chat-owned-resume.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest'
import { nativeChatSpawnResumeOwnership } from './native-chat-owned-resume'

const RESUME = {
launchAgent: 'claude' as const,
resumeProviderSession: {
key: 'session_id' as const,
id: 'b6bd7d57-2ec3-4e62-a47d-715e2455301d'
}
}

describe('nativeChatSpawnResumeOwnership', () => {
it('never consults this host for a remote or WSL conversation', () => {
const runtime = { openAgentSessionRecordStore: vi.fn() }

expect(
nativeChatSpawnResumeOwnership(runtime, { ...RESUME, connectionId: 'ssh-1' }, null)
).toBe(null)
expect(nativeChatSpawnResumeOwnership(runtime, RESUME, 'Ubuntu')).toBe(null)
expect(nativeChatSpawnResumeOwnership(runtime, { launchAgent: 'claude' }, null)).toBe(null)
expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled()
})
})
Loading
Loading