Offer to resume chats after managed server updates (#28978)

* Guard managed server restarts with host work reservations

* Protect legacy server update requests with host admission

* Reserve chat-safe restarts in SSH and WSL server updates

* Keep activation host fixtures within the module line limit

* fix(orcad): preserve session queue timing during admission

* Explain chat-safe update deferrals and superseded restarts

* Clarify managed server Force upgrades and retry instructions

* Separate chat entry delegates from conversation creation

* Report child work through main's child evidence in the restart drain test

* Drive main's snapshot-room tests through the restart reservation

Main's room tests deployed over a serving incumbent with a census that has no
chat work count and no restart admission, so the update deferred before the
room probe ran. Give them the same idle census and slot admission as the other
activation tests, and pin the probe before the reservation instead of before the
recount it replaced.

* fix(native-chat): an update keeps waiting on a chat start after its conversation is closed

The restart check counted a start only while its conversation stayed open,
so an idle close during a stalled start let a managed update through.
Every tracked attach, create, start and open now counts as restart work
in the same tracker that counts the session queue.

* fix(native-chat): keep the structured session view within its line limit once formatted

Main's copy sat at 400 lines only because one line was left unformatted; the
merge's formatter pushed it to 401. Inlining the one-use question list keeps it
at 400 with identical behavior.

* fix(orcad): a recovery stop request stops the server; only an older client's empty request waits for idle

Stopping a slot and restarting it for an older client's update were both an empty
.orcad-stop-request file, so routing that file through restart admission let a
crash recovery, stranded-setup release or rejected-candidate rollback leave the
server running. New clients now write "stop-now" into the request (renamed in
whole), which stops at once as before; an empty or unrecognized request takes the
chat-safe restart, and a deferred restart logs why. Also gives the restart
admission activation fixtures the now-required recount.

* docs(orcad): say a directory at the stop-request path is refused, not restarted

* docs(orcad): the plain stop request's comment states both of its meanings

* style: format the restart admission files for the format check

* test(wsl): count the incumbent's recount bridge after the reservation's

* fix(orcad): a Windows stop request retries its rename while antivirus holds the new file

Renaming the just-written stop-now request can briefly fail with EPERM, EACCES
or EBUSY on Windows; the stop now retries those with the same bounded delays as
record-publish instead of answering SIGNAL_FAILED and leaving the server up.

* test(orcad): a real Windows host stops on the slot's stop-now request and relaunches

* test(orcad): recovery stops a real server whose restart admission refuses

* test(orcad): the recovery-stop cell tunnels over the cell's SSH connection

* test(orcad): the recovery-stop cell names itself in the CI log

* fix(settings): a deferred WSL update shows its full reason below the row

* test(orcad): fixtures pass releaseIncumbentAdmission; the census test calls its own method

* test(orcad): the capable-server census in the fence test reports idle chat work

* Record server chat restart offers and desktop creation history

* Offer interrupted chat recovery per managed runtime

* Ship the remote chat resume action labels in the runtime catalog

* Update removal contracts for desktop chat history cleanup

* Scope remote chat recovery to each server occurrence

* Preserve recovery choices and uncertain continuation outcomes

* fix: preserve uncertain restart action outcomes

* Fix restart recovery CI imports and fixtures

* test: align resume assertions with unconfirmed outcomes

* test: bound restart recovery work across scaled populations

* fix(native-chat): title the remote resume dialog with the restart

The dialog can appear out of nowhere when another desktop updates a shared
server, so its title now says what happened: "<machine> restarted".
This commit is contained in:
Brennan Benson
2026-10-25 17:15:55 -07:00
committed by GitHub
parent 3ffe2af9fa
commit bfc633af8f
56 changed files with 2859 additions and 591 deletions
@@ -2,11 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
list: vi.fn(),
retire: vi.fn(),
remove: vi.fn(),
removeManaged: vi.fn(),
closeTunnel: vi.fn(async () => {})
}))
vi.mock('../runtime/desktop-chat-creation-history', () => ({
desktopChatCreationHistory: () => ({ removeEnvironment: mocks.retire })
}))
vi.mock('../../shared/runtime-environment-store', () => ({
listEnvironments: mocks.list,
removeEnvironment: mocks.remove
@@ -39,6 +44,10 @@ describe('releaseEphemeralVmRuntimeEnvironments', () => {
expect(mocks.removeManaged).toHaveBeenCalledTimes(1)
expect(mocks.removeManaged).toHaveBeenCalledWith('/user-data', 'env-vm')
expect(mocks.remove).not.toHaveBeenCalled()
expect(mocks.retire).toHaveBeenCalledWith('env-vm')
expect(mocks.removeManaged.mock.invocationCallOrder[0]).toBeLessThan(
mocks.retire.mock.invocationCallOrder[0]
)
})
it('finds the server of a relay-era VM that converted without recording it', async () => {
@@ -55,6 +64,10 @@ describe('releaseEphemeralVmRuntimeEnvironments', () => {
runtimeEnvironmentId: 'env-paired'
})
expect(mocks.remove).toHaveBeenCalledWith('/user-data', 'env-paired')
expect(mocks.retire).toHaveBeenCalledWith('env-paired')
expect(mocks.remove.mock.invocationCallOrder[0]).toBeLessThan(
mocks.retire.mock.invocationCallOrder[0]
)
expect(mocks.closeTunnel).not.toHaveBeenCalled()
})
@@ -63,5 +76,6 @@ describe('releaseEphemeralVmRuntimeEnvironments', () => {
await releaseEphemeralVmRuntimeEnvironments('/user-data', { runtimeEnvironmentId: 'env-gone' })
expect(mocks.remove).not.toHaveBeenCalled()
expect(mocks.removeManaged).not.toHaveBeenCalled()
expect(mocks.retire).not.toHaveBeenCalled()
})
})
@@ -1,3 +1,4 @@
import { desktopChatCreationHistory } from '../runtime/desktop-chat-creation-history'
import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes'
import { removeManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store'
import { listEnvironments, removeEnvironment } from '../../shared/runtime-environment-store'
@@ -18,11 +19,13 @@ export async function releaseEphemeralVmRuntimeEnvironments(
for (const environment of environments) {
if (!environment.orcadDeployment) {
removeEnvironment(userDataPath, environment.id)
void desktopChatCreationHistory(userDataPath).removeEnvironment(environment.id)
continue
}
const { closeOrcadManagedTunnel } =
await import('../managed-server/lifecycle/orcad-managed-tunnel')
await closeOrcadManagedTunnel(environment.id).catch(() => undefined)
removeManagedOrcadEnvironment(userDataPath, environment.id)
void desktopChatCreationHistory(userDataPath).removeEnvironment(environment.id)
}
}
@@ -43,7 +43,12 @@ export function createManagedOrcadActions(
isActiveEnvironment: (environmentId: string) =>
options.getActiveEnvironmentId() === environmentId,
retireLocalState: (environmentId: string) =>
retireRemovedRuntimeEnvironment(environmentId, options.invalidateTransport, options.store)
retireRemovedRuntimeEnvironment(
environmentId,
options.invalidateTransport,
options.store,
userDataPath()
)
}
return {
status: (selector) => getManagedOrcadRuntimeStatus(userDataPath(), selector),
@@ -130,7 +130,8 @@ describe('managed orcad maintenance IPC', () => {
expect(policy.isActiveEnvironment('active-environment')).toBe(true)
expect(policy.isActiveEnvironment('e-1')).toBe(false)
policy.retireLocalState('e-1')
expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport, store)
expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport, store, '/profile')
// The SSH host stops naming the unlinked server without waiting for a reconnect.
expect(clearHostServerStatus).toHaveBeenCalledWith('ssh-1')
})
@@ -0,0 +1,38 @@
import type { resolveEnvironment } from '../../../shared/runtime-environment-store'
import { RemoteRuntimeClientError } from '../../../shared/remote-runtime-client-error'
import { RuntimeRpcCallQueueOverloadError } from '../../../shared/runtime-rpc-call-queue'
import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import { RUNTIME_MANUALLY_DISCONNECTED_MESSAGE } from './runtime-environment-manual-disconnect'
export function manuallyDisconnectedResponse(
environment: ReturnType<typeof resolveEnvironment>
): RuntimeRpcResponse<never> {
return {
id: 'runtime.manualDisconnect',
ok: false,
error: {
code: 'runtime_manually_disconnected',
message: RUNTIME_MANUALLY_DISCONNECTED_MESSAGE
},
_meta: { runtimeId: environment.runtimeId }
}
}
export function runtimeEnvironmentCallFailure(
environment: ReturnType<typeof resolveEnvironment>,
method: string,
error: unknown
): RuntimeRpcFailure | null {
if (
!(error instanceof RemoteRuntimeClientError) &&
!(error instanceof RuntimeRpcCallQueueOverloadError)
) {
return null
}
return {
id: method,
ok: false,
error: { code: error.code, message: error.message },
_meta: { runtimeId: environment.runtimeId }
}
}
@@ -1,3 +1,8 @@
import {
manuallyDisconnectedResponse,
runtimeEnvironmentCallFailure
} from './runtime-environment-call-failure'
import { desktopChatCreationHistory } from '../../runtime/desktop-chat-creation-history'
import { ipcMain } from 'electron'
import {
addEnvironmentFromPairingCode,
@@ -13,9 +18,7 @@ import {
connectThroughOwningService,
removeThroughOwningService
} from './runtime-environment-host-service-ops'
import { RemoteRuntimeClientError } from '../../../shared/remote-runtime-client-error'
import { RuntimeRpcCallQueueOverloadError } from '../../../shared/runtime-rpc-call-queue'
import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import type { Store } from '../../persistence'
import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup'
@@ -28,8 +31,7 @@ import {
} from './runtime-environment-request-connections'
import {
clearRuntimeEnvironmentManualDisconnect,
isRuntimeEnvironmentManuallyDisconnected,
RUNTIME_MANUALLY_DISCONNECTED_MESSAGE
isRuntimeEnvironmentManuallyDisconnected
} from './runtime-environment-manual-disconnect'
import { callRuntimeEnvironment } from './runtime-environment-transport-routing'
import { getRuntimeEnvironmentStatus } from './runtime-environment-status-probe'
@@ -48,20 +50,6 @@ import {
} from '../../wsl/wsl-orcad-host-environments'
import { assertWslHostReachable, isGatedOffWslHost } from '../../wsl/wsl-host-gate'
function manuallyDisconnectedResponse(
environment: ReturnType<typeof resolveEnvironment>
): RuntimeRpcResponse<never> {
return {
id: 'runtime.manualDisconnect',
ok: false,
error: {
code: 'runtime_manually_disconnected',
message: RUNTIME_MANUALLY_DISCONNECTED_MESSAGE
},
_meta: { runtimeId: environment.runtimeId }
}
}
export { isRuntimeEnvironmentManuallyDisconnected }
type ConnectivityHandlerOptions = {
@@ -75,6 +63,9 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
getUserDataPath,
invalidateTransport
}: ConnectivityHandlerOptions): void {
ipcMain.handle('runtimeEnvironments:desktopCreatedChatIds', () =>
desktopChatCreationHistory(getUserDataPath()).sessionIds()
)
ipcMain.handle('runtimeEnvironments:getStatusSnapshots', () =>
getRuntimeEnvironmentStatusSnapshots()
)
@@ -135,7 +126,12 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
return ownedRemoval.then(() => ({ removed: redactRuntimeEnvironment(environment) }))
}
const removed = removeEnvironment(getUserDataPath(), args.selector)
void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport, store)
void retireRemovedRuntimeEnvironment(
removed.id,
invalidateTransport,
store,
getUserDataPath()
)
closeLegacySelectorTransport(args.selector, removed.id)
return { removed: redactRuntimeEnvironment(removed) }
}
@@ -212,25 +208,6 @@ function registerPassiveStatusHandler(getUserDataPath: () => string): void {
)
}
function runtimeEnvironmentCallFailure(
environment: ReturnType<typeof resolveEnvironment>,
method: string,
error: unknown
): RuntimeRpcFailure | null {
if (
!(error instanceof RemoteRuntimeClientError) &&
!(error instanceof RuntimeRpcCallQueueOverloadError)
) {
return null
}
return {
id: method,
ok: false,
error: { code: error.code, message: error.message },
_meta: { runtimeId: environment.runtimeId }
}
}
function registerPassiveCallHandler(getUserDataPath: () => string): void {
ipcMain.handle(
'runtimeEnvironments:call',
@@ -249,6 +226,11 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void {
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
return manuallyDisconnectedResponse(environment)
}
void desktopChatCreationHistory(getUserDataPath()).recordCreate(
environment.id,
args.method,
args.params
)
// Every renderer request to a paired host passes this account environment boundary.
const accounts = await withWslOrcadAccountEnv({
environmentId: environment.id,
@@ -1,5 +1,6 @@
export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
'runtimeEnvironments:list',
'runtimeEnvironments:desktopCreatedChatIds',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:verifyAndAddFromPairingCode',
'runtimeEnvironments:repairFromPairingCode',
@@ -244,7 +244,8 @@ describe('runtimeEnvironments:remove by host kind', () => {
})
expect(mocks.wsl.service!.remove).not.toHaveBeenCalled()
expect(mocks.removeEnvironment).toHaveBeenCalledWith('/u', id)
expect(mocks.retire).toHaveBeenCalledWith(id, expect.any(Function), expect.anything())
expect(mocks.retire).toHaveBeenCalledWith(id, expect.any(Function), expect.anything(), '/u')
// Removed by its own id, so no legacy-selector transport is closed.
expect(mocks.closeRequest).not.toHaveBeenCalled()
})
})
@@ -1,3 +1,4 @@
import { desktopChatCreationHistory } from '../../runtime/desktop-chat-creation-history'
import { clearBrowserRoutePartitionStorageForEnvironment } from '../../browser/browser-route-partition-storage-runtime'
import { retireBrowserRoutePartitionStorageForEnvironment } from '../../browser/browser-route-partition-storage-retirement'
import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence'
@@ -16,8 +17,12 @@ export type RemovedServerStore = RemoteLayoutSnapshotStore & {
export function retireRemovedRuntimeEnvironment(
environmentId: string,
invalidateTransport: (environmentId: string) => Promise<void> | void,
store: RemovedServerStore
store: RemovedServerStore,
userDataPath?: string
): Promise<void> {
if (userDataPath) {
void desktopChatCreationHistory(userDataPath).removeEnvironment(environmentId)
}
clearRuntimeEnvironmentCapabilityEvidence(environmentId)
// Retained session partitions make listings advertise the removed server as a known host.
store.removeWorkspaceSessionHost(toRuntimeExecutionHostId(environmentId))
@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../../shared/protocol-version'
import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../shared/electron-remote-runtime-client-capabilities'
import * as environmentStore from '../../../shared/runtime-environment-store'
import { AtomicSnapshotWriter } from '../../persistence/atomic-snapshot-writer'
import { desktopChatCreationHistory } from '../../runtime/desktop-chat-creation-history'
import { RemoteRuntimeClientError } from '../../../shared/remote-runtime-client-error'
import { RuntimeRpcCallQueueOverloadError } from '../../../shared/runtime-rpc-call-queue'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
@@ -116,10 +118,58 @@ describe('registerRuntimeEnvironmentHandlers', () => {
})
afterEach(() => {
vi.restoreAllMocks()
resetRuntimeEnvironmentStatusOwners()
rmSync(userDataPath, { recursive: true, force: true })
})
it.each([false, true])(
'dispatches with minting membership while durability is unresolved (lost reply: %s)',
async (lostReply) => {
registerRuntimeEnvironmentHandlers(store as never)
const environment = environmentStore.addEnvironmentFromPairingCode(userDataPath, {
name: 'desk',
pairingCode: pairingCode()
})
const history = desktopChatCreationHistory(userDataPath)
vi.spyOn(AtomicSnapshotWriter.prototype, 'write').mockImplementation(
() => new Promise(() => {})
)
sendRemoteRuntimeRequestMock.mockImplementation(async (_connection, method) => {
if (method === 'status.get') {
return {
id: 'status',
ok: true,
result: { runtimeId: 'runtime-remote', capabilities: [] },
_meta: { runtimeId: 'runtime-remote' }
}
}
expect(history.sessionIds()).toEqual(['codex_minted'])
if (lostReply) {
throw new RemoteRuntimeClientError('connection_closed', 'reply lost')
}
return { id: 'create', ok: true, result: {}, _meta: { runtimeId: 'runtime-remote' } }
})
const call = handler<
{ selector: string; method: string; params: unknown },
RuntimeRpcResponse<unknown>
>('runtimeEnvironments:call')
await expect(
call(null, {
selector: environment.id,
method: 'agentSession.create',
params: { envelope: { sessionId: 'codex_minted' } }
})
).resolves.toMatchObject(
lostReply ? { ok: false, error: { message: 'reply lost' } } : { ok: true }
)
expect(history.sessionIds()).toEqual(['codex_minted'])
expect(
sendRemoteRuntimeRequestMock.mock.calls.some((call) => call[1] === 'agentSession.create')
).toBe(true)
}
)
it('proxies generic one-shot RPC calls to the saved remote runtime', async () => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
+6 -1
View File
@@ -125,7 +125,12 @@ function createService(args: WslHostHandlerArgs): WslHostService | null {
}
},
removed: (environmentId) =>
retireRemovedRuntimeEnvironment(environmentId, args.invalidateTransport, args.store)
retireRemovedRuntimeEnvironment(
environmentId,
args.invalidateTransport,
args.store,
args.getUserDataPath()
)
})
const onChange = created.handleConnectionChange.bind(created)
const isKnownDistro = (distro: string): boolean => created.isKnownDistro(distro)
@@ -0,0 +1,83 @@
import { afterEach, expect, it, vi } from 'vitest'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
import {
CALLER,
hostTestState
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness'
import {
HOST_TEST_THREAD as THREAD,
HOST_TEST_NOW as NOW,
hostTestAttachParams,
hostTestOperationId
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { AgentSessionRecoveryCapsule } from '../runtime/agent-session-recovery-capsule'
import { startOrcadWithLifecycle, stopOrcadStructuredAgentSessions } from './orcad-lifecycle'
vi.mock('../runtime/structured-agent-session-runtime', () => ({
stopStructuredAgentSessionRuntime: async (options: { trigger: 'quit' }) => {
await hostTestState().host.flushAllStreamedEvents(options)
}
}))
afterEach(() => vi.restoreAllMocks())
it.each([10, 100])(
'batches %i working chats into durable offers before the profile closes',
async (size) => {
const { host, acquire, root } = hostTestState()
const ids = Array.from({ length: size }, (_, i) => `session-${i}`)
for (const sessionId of ids) {
const params = hostTestAttachParams(null, {
envelope: {
sessionId,
clientOperationId: hostTestOperationId(),
expectedRuntimeFence: null,
payloadFingerprint: ''
}
})
expect(await host.attach(CALLER, params)).toMatchObject({ ok: true })
const events = acquire.mock.calls.at(-1)?.[0].events
if (!events) {
throw new Error('missing provider event sink')
}
events.appendItem(
{ provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 },
{ kind: 'turn', turnId: 'working', state: 'running' },
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await host.flushStreamedEvents(sessionId)
}
const close = vi.fn(async () => true)
host.deps.adapter.closeSession = close
const record = vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'record')
const getRecord = vi.spyOn(host.deps.store, 'getRecord')
const server = await startOrcadWithLifecycle(
async (register) => {
register(async () => {
expect(close.mock.calls).toEqual(ids.map((id) => [id]))
expect(record).toHaveBeenCalledTimes(1)
expect(record.mock.calls[0]?.[0]).toHaveLength(size)
expect(
(await new AgentSessionRecoveryCapsule(root).list(NOW)).map((row) => row.sessionId)
).toEqual(ids)
})
register(stopOrcadStructuredAgentSessions)
return {}
},
async () => {}
)
await server.stop()
// Witness, lease and status passes are per session; no inventory scan for each child.
expect(getRecord.mock.calls.length).toBeLessThanOrEqual(32 * size)
const reads = getRecord.mock.calls.length
await server.stop()
expect(getRecord).toHaveBeenCalledTimes(reads)
expect(close).toHaveBeenCalledTimes(size)
expect(record).toHaveBeenCalledTimes(1)
record.mockRestore()
getRecord.mockRestore()
expect(await host.restartResume.list()).toMatchObject(
ids.map((sessionId) => ({ sessionId, trigger: 'quit' }))
)
}
)
+3 -5
View File
@@ -14,6 +14,7 @@ import {
flushOrcadProfileStoreForShutdown,
installOrcadShutdownSignals,
startOrcadWithHost,
stopOrcadStructuredAgentSessions,
type OrcadShutdownTrigger
} from './orcad-lifecycle'
import { ORCAD_USAGE, parseArgs, resolveOrcadWebClientRoot } from './orcad-command-arguments'
@@ -127,13 +128,10 @@ async function startOrcadRuntime(
registerCleanup(async () => {
// A SQLite-backed orcad has no JSON mirror to absorb a debounced write after SIGTERM.
if (profileStoreForShutdown) {
const paneGrids = paneGridsForShutdown?.flushPaneGrids()
if (paneGrids) {
await paneGrids
}
await flushOrcadProfileStoreForShutdown(profileStoreForShutdown)
await flushOrcadProfileStoreForShutdown(profileStoreForShutdown, paneGridsForShutdown)
}
})
registerCleanup(stopOrcadStructuredAgentSessions)
registerCleanup(() => disposeWatcherProcessAndWait())
const { DesktopPushService } = await import('../runtime/push/desktop-push-service')
const { resolvePushGatewayOrigin } = await import('../runtime/push/push-gateway-origin')
+15 -4
View File
@@ -91,6 +91,12 @@ export function installOrcadShutdownSignals(
return shutdown
}
export async function stopOrcadStructuredAgentSessions(): Promise<void> {
const { stopStructuredAgentSessionRuntime } =
await import('../runtime/structured-agent-session-runtime')
await stopStructuredAgentSessionRuntime({ trigger: 'quit' })
}
export async function startOrcadWithLifecycle<T extends object>(
start: (registerRuntimeCleanup: (cleanup: OrcadRuntimeCleanup) => void) => Promise<T>,
cleanupHost: (runtimeCleanupSucceeded: boolean) => Promise<void>
@@ -162,10 +168,15 @@ export async function startOrcadWithHost<T extends object>(
)
}
export async function flushOrcadProfileStoreForShutdown(store: {
flushFinalOrThrowAsync(): Promise<void>
freezeWritesAsync(): Promise<void>
}): Promise<void> {
export async function flushOrcadProfileStoreForShutdown(
store: { flushFinalOrThrowAsync(): Promise<void>; freezeWritesAsync(): Promise<void> },
paneGrids?: { flushPaneGrids(): Promise<void> | null } | null
): Promise<void> {
// A pane's grid still inside its write burst joins the final save.
const flushing = paneGrids?.flushPaneGrids()
if (flushing) {
await flushing
}
try {
await store.flushFinalOrThrowAsync()
} finally {
@@ -0,0 +1,122 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
import { writeFile, rename } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { DesktopChatCreationHistory } from './desktop-chat-creation-history'
import type * as NodeFs from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
vi.mock('node:fs', async (original) => {
const fs = await original<typeof NodeFs>()
return { ...fs, readFileSync: vi.fn(fs.readFileSync) }
})
vi.mock('node:fs/promises', async (original) => {
const fs = await original<typeof NodeFsPromises>()
return { ...fs, writeFile: vi.fn(fs.writeFile), rename: vi.fn(fs.rename) }
})
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
vi.restoreAllMocks()
})
function root() {
const value = mkdtempSync(join(tmpdir(), 'pr-28978-creation-history-'))
roots.push(value)
return value
}
it('retains minting facts across launches and aliases until their environment is removed', async () => {
const path = root()
const history = new DesktopChatCreationHistory(path)
await history.recordCreate('ssh', 'agentSession.create', { envelope: { sessionId: 'old-chat' } })
await history.recordCreate('wsl', 'agentSession.create', { envelope: { sessionId: 'new-chat' } })
await history.recordCreate('ssh', 'agentSession.ensure', {
envelope: { sessionId: 'someone-elses-chat' }
})
const restarted = new DesktopChatCreationHistory(path)
expect(restarted.sessionIds()).toEqual(['old-chat', 'new-chat'])
await restarted.removeEnvironment('wsl')
expect(new DesktopChatCreationHistory(path).sessionIds()).toEqual(['old-chat'])
})
it('treats unreadable history as unknown and never vetoes a create when its write fails', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const path = join(root(), 'not-a-directory')
writeFileSync(path, 'occupied')
const history = new DesktopChatCreationHistory(path)
expect(history.sessionIds()).toEqual([])
await expect(
history.recordCreate('ssh', 'agentSession.create', { envelope: { sessionId: 'chat' } })
).resolves.toBeUndefined()
expect(history.sessionIds()).toEqual(['chat'])
rmSync(path)
mkdirSync(path)
await history.recordCreate('ssh', 'agentSession.create', { envelope: { sessionId: 'chat' } })
expect(new DesktopChatCreationHistory(path).sessionIds()).toEqual(['chat'])
})
it.each([1_000, 10_000])(
'bounds drained creation work with %i retained memberships',
async (size) => {
const path = root()
const ids = Array.from({ length: size - 1 }, (_, i) => `saved-${i}`)
writeFileSync(
join(path, 'desktop-chat-creation-history.json'),
JSON.stringify({ ssh: ids, alias: ['saved-0'] })
)
vi.mocked(readFileSync).mockClear()
vi.mocked(writeFile).mockClear()
vi.mocked(rename).mockClear()
const history = new DesktopChatCreationHistory(path)
let serializedMemberships = 0
const stringify = JSON.stringify
const serialization = vi
.spyOn(JSON, 'stringify')
.mockImplementation((value, replacer, space) => {
if (
value &&
typeof value === 'object' &&
'ssh' in value &&
Array.isArray(value.ssh) &&
Array.isArray(value.alias)
) {
serializedMemberships += value.ssh.length + value.alias.length
}
return stringify(value, replacer, space)
})
await history.recordCreate('ssh', 'agentSession.create', { envelope: { sessionId: 'saved-0' } })
expect(writeFile).not.toHaveBeenCalled()
expect(rename).not.toHaveBeenCalled()
for (let i = 0; i < 10; i++) {
await history.recordCreate('ssh', 'agentSession.create', {
envelope: { sessionId: `new-${i}` }
})
}
serialization.mockRestore()
expect(readFileSync).toHaveBeenCalledTimes(1)
expect(writeFile).toHaveBeenCalledTimes(10)
expect(rename).toHaveBeenCalledTimes(10)
// Distinct creates still serialize whole snapshots; unchanged bytes skip only disk replacement.
expect(serializedMemberships).toBeGreaterThan(0)
expect(serializedMemberships).toBeLessThanOrEqual(11 * size + 55)
let visits = 0
const iterate = Set.prototype[Symbol.iterator]
const union = vi.spyOn(Set.prototype, Symbol.iterator).mockImplementation(function* (
this: Set<unknown>
) {
for (const id of iterate.call(this)) {
visits++
yield id
}
return undefined
})
const all = history.sessionIds()
union.mockRestore()
expect(visits).toBeLessThanOrEqual(2 * (size + 10))
expect(all).toEqual([...ids, ...Array.from({ length: 10 }, (_, i) => `new-${i}`)])
}
)
@@ -0,0 +1,79 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { AtomicSnapshotWriter } from '../persistence/atomic-snapshot-writer'
/** Desktop minting facts, shared by every window and retained until an environment is removed. */
export class DesktopChatCreationHistory {
private readonly byEnvironment = new Map<string, Set<string>>()
private readonly writer: AtomicSnapshotWriter
constructor(userDataPath: string) {
const file = join(userDataPath, 'desktop-chat-creation-history.json')
this.writer = new AtomicSnapshotWriter(() => file, { fileMode: 0o600, skipUnchanged: true })
try {
const content = readFileSync(file, 'utf8')
const saved: unknown = JSON.parse(content)
this.writer.primeCommittedContent(content)
if (saved && typeof saved === 'object') {
for (const [environmentId, ids] of Object.entries(saved)) {
if (Array.isArray(ids) && ids.every((id) => typeof id === 'string')) {
this.byEnvironment.set(environmentId, new Set<string>(ids))
}
}
}
} catch (error) {
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')) {
console.warn('[desktop-chat-history] Could not read creation history:', error)
}
}
}
async recordCreate(environmentId: string, method: string, params: unknown): Promise<void> {
if (method !== 'agentSession.create' || !params || typeof params !== 'object') {
return
}
if (!('envelope' in params) || !params.envelope || typeof params.envelope !== 'object') {
return
}
const envelope = params.envelope
if (!('sessionId' in envelope) || typeof envelope.sessionId !== 'string') {
return
}
const ids = this.byEnvironment.get(environmentId) ?? new Set<string>()
ids.add(envelope.sessionId)
this.byEnvironment.set(environmentId, ids)
await this.save()
}
sessionIds(): string[] {
return [...new Set([...this.byEnvironment.values()].flatMap((ids) => [...ids]))]
}
async removeEnvironment(environmentId: string): Promise<void> {
if (this.byEnvironment.delete(environmentId)) {
await this.save()
}
}
private async save(): Promise<void> {
try {
await this.writer.write(() =>
JSON.stringify(
Object.fromEntries([...this.byEnvironment].map(([id, ids]) => [id, [...ids]]))
)
)
} catch (error) {
console.warn('[desktop-chat-history] Could not save creation history:', error)
}
}
}
const histories = new Map<string, DesktopChatCreationHistory>()
export function desktopChatCreationHistory(userDataPath: string): DesktopChatCreationHistory {
let history = histories.get(userDataPath)
if (!history) {
history = new DesktopChatCreationHistory(userDataPath)
histories.set(userDataPath, history)
}
return history
}
+1
View File
@@ -82,6 +82,7 @@ export type RuntimeApi = {
onSidebarInputsChanged: (callback: (event: SidebarInputsChangedEvent) => void) => () => void
}
runtimeEnvironments: {
desktopCreatedChatIds?: () => Promise<string[]>
getStatusSnapshots: () => Promise<RuntimeHostStatusSnapshot[]>
onStatusChanged: (callback: (snapshot: RuntimeHostStatusSnapshot) => void) => () => void
list: () => Promise<PublicKnownRuntimeEnvironment[]>
@@ -22,6 +22,8 @@ import { managedOrcadApi } from './managed-orcad-api'
import { getWslHostsApi } from './wsl-hosts-api'
export const runtimeEnvironmentsApi = {
desktopCreatedChatIds: (): Promise<string[]> =>
ipcRenderer.invoke('runtimeEnvironments:desktopCreatedChatIds'),
getStatusSnapshots: (): Promise<RuntimeHostStatusSnapshot[]> =>
ipcRenderer.invoke('runtimeEnvironments:getStatusSnapshots'),
onStatusChanged: (callback: (snapshot: RuntimeHostStatusSnapshot) => void): (() => void) => {
@@ -12,7 +12,7 @@ import { MarkdownTemplatePicker } from '../components/editor/MarkdownTemplatePic
import RecentTabSwitcher from '../components/tab-bar/RecentTabSwitcher'
import { SkillFreshnessUpdateDialog } from '../components/skills/SkillFreshnessUpdateDialog'
import { StarNagCard } from '../components/StarNagCard'
import { NativeChatResumeOnRestartModal } from '../components/NativeChatResumeOnRestartModal'
import { NativeChatResumeOnRestartModal } from '../components/NativeChatResumeRuntimeModal'
import { StarNagAgentValueMomentObserver } from '../components/star-nag/StarNagAgentValueMomentObserver'
import { StarNagToastHost } from '../components/star-nag/StarNagToastHost'
import { TelemetryFirstLaunchSurface } from '../components/TelemetryFirstLaunchSurface'
@@ -0,0 +1,814 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '../store'
import { getDefaultSettings } from '../../../shared/constants'
import {
useNativeChatTargetRestartResuming,
useNativeChatRestartRuntimes
} from './native-chat-restart-runtimes'
import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment'
import { resumeRunView } from './native-chat-resume-run-view'
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
import {
requestNativeChatResumeOnRestartDialog,
getNativeChatResumeOnRestartDialogRequest
} from './native-chat-resume-on-restart-dialog'
import { afterEach, expect, it, vi } from 'vitest'
import {
createResumeModalFixture,
offered,
failure,
type RestartRpc,
type ResumeStatusStream
} from './native-chat-resume-modal.test-support'
import { NativeChatResumeRuntimeModal } from './NativeChatResumeRuntimeModal'
import {
createNativeChatRestartStore,
type NativeChatRestartStore
} from './native-chat-resume-on-restart-store'
import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status'
import * as runtimeRpc from '@/runtime/runtime-rpc-client'
import * as hostOffer from './native-chat-restart-host-offer'
import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed'
const rpc = vi.hoisted(() => vi.fn<RestartRpc>())
const subscriptions = vi.hoisted(() => ({ opened: 0, closed: 0 }))
const stream: ResumeStatusStream = vi.hoisted(() => ({ emit: () => {}, snapshot: new Map() }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: rpc,
subscribeStructuredAgentSessionStatus: async (
_target: unknown,
emit: ResumeStatusStream['emit']
) => {
subscriptions.opened++
stream.emit = emit
emit({ type: 'snapshot', sessions: [] })
return {
unsubscribe: () => {
subscriptions.closed++
}
}
}
}))
vi.mock('sonner', async () =>
(await import('./native-chat-resume-sonner-mock.test-support')).resumeSonnerMock()
)
const { mount, button } = createResumeModalFixture(rpc, stream)
const stores: NativeChatRestartStore[] = []
afterEach(() => {
for (const store of stores.splice(0)) {
store._resetNativeChatRestartOffer()
store.consumeNativeChatResumeOnRestartDialogRequest()
}
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it.each([10, 100])(
'shares discovery and bounds contact work across %i paired owners',
async (size) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
vi.spyOn(runtimeRpc, 'runtimeEnvironmentSupportsCapability').mockResolvedValue(true)
subscriptions.opened = subscriptions.closed = 0
const historyIds = Array.from({ length: 1_000 }, (_, i) => `saved-${i}`)
let transferred = 0
const history = vi.fn(async () => {
transferred += historyIds.length
return historyIds
})
vi.stubGlobal('api', { runtimeEnvironments: { desktopCreatedChatIds: history } })
let idReads = 0
const environments = Array.from({ length: size }, (_, i) => ({
...pairedEnvironment(`paired-${i}`),
get id() {
idReads++
return `paired-${i}`
}
}))
const statuses = new Map(environments.map((env) => [env.id, ready('r1')]))
const observed: NativeChatRestartStore[][] = []
function Lifecycle({ index }: { index: number }) {
observed[index] = useNativeChatRestartRuntimes().map(({ store }) => store)
return null
}
rpc.mockResolvedValue({ sessions: [] })
useAppStore.setState({
runtimeEnvironments: environments,
runtimeStatusByEnvironmentId: statuses
})
idReads = 0
await mount(
<>
<Lifecycle index={0} />
<Lifecycle index={1} />
</>
)
expect(idReads).toBeLessThanOrEqual(20 * size)
expect(rpc).toHaveBeenCalledTimes(size)
expect(history).not.toHaveBeenCalled()
expect(subscriptions.opened).toBe(0)
expect(observed[0]).toEqual(observed[1])
const owners = [...observed[0]]
for (let frame = 0; frame < 10; frame++) {
idReads = 0
await act(async () =>
useAppStore.setState({ runtimeStatusByEnvironmentId: new Map(statuses) })
)
expect(idReads).toBeLessThanOrEqual(20 * size)
}
expect(rpc).toHaveBeenCalledTimes(size)
rpc.mockResolvedValue({ sessions: [{ ...offered[0], sessionId: 'saved-0' }] })
await act(async () =>
useAppStore.setState({
runtimeStatusByEnvironmentId: new Map(
environments.map((env) => [env.id, { ...ready('r1'), hostContactEpoch: 1 }])
)
})
)
expect(rpc).toHaveBeenCalledTimes(2 * size)
expect(history).toHaveBeenCalledTimes(size)
expect(transferred).toBe(size * 1_000)
expect(subscriptions.opened).toBe(size)
expect(observed[0]).toEqual(owners)
expect(observed[1]).toEqual(owners)
expect(owners.every((owner) => owner.startedHere('saved-0'))).toBe(true)
idReads = 0
await act(async () =>
useAppStore.setState({
runtimeStatusByEnvironmentId: new Map(
environments.map((env) => [env.id, { ...ready('r1'), hostContactEpoch: 2 }])
)
})
)
expect(idReads).toBeLessThanOrEqual(21 * size)
expect(rpc).toHaveBeenCalledTimes(3 * size)
expect(history).toHaveBeenCalledTimes(2 * size)
expect(transferred).toBe(2 * size * 1_000)
expect(subscriptions.opened).toBe(size)
await act(async () => Promise.all(owners.map((owner) => owner.refreshNativeChatRestartOffer())))
expect(history).toHaveBeenCalledTimes(2 * size)
await act(async () => useAppStore.setState({ runtimeEnvironments: [] }))
expect(subscriptions.closed).toBe(size)
expect(vi.getTimerCount()).toBe(0)
}
)
it.each([513, 5_130])(
'bounds bulk resume and repeated status work for %i offered chats',
async (size) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
vi.spyOn(runtimeRpc, 'runtimeEnvironmentSupportsCapability').mockResolvedValue(true)
const ids = Array.from({ length: size }, (_, i) => `scale-${i}`)
let candidateReads = 0
const onTarget = hostOffer.restartOfferOnTarget
vi.spyOn(hostOffer, 'restartOfferOnTarget').mockImplementation((answer, target) => {
const result = onTarget(answer, target)
for (const candidate of result.candidates) {
const id = candidate.sessionId
Object.defineProperty(candidate, 'sessionId', {
get: () => {
candidateReads++
return id
}
})
}
return result
})
let publishedRows = 0
const many = ids.map((sessionId) => ({
...offered[0],
get sessionId() {
publishedRows++
return sessionId
}
}))
const history = vi.fn(async () => ids)
vi.stubGlobal('api', { runtimeEnvironments: { desktopCreatedChatIds: history } })
let remote: NativeChatRestartStore | undefined
let offset = 0
let active = 0
let peak = 0
const firstReply = Promise.withResolvers<void>()
rpc.mockImplementation(async (_target, method, params) => {
if (method === 'agentSession.restartResumable') {
return { sessions: many }
}
active++
peak = Math.max(peak, active)
const chunk = params?.sessionIds ?? []
if (offset === 0) {
await firstReply.promise
}
offset += chunk.length
active--
return {
sessions: many.slice(offset),
failed: [],
continued: chunk.map((sessionId) => ({ sessionId, outcome: 'continued' }))
}
})
useAppStore.setState({
settings: { ...getDefaultSettings(''), experimentalNativeChat: false },
runtimeEnvironments: [pairedEnvironment('ssh')],
runtimeStatusByEnvironmentId: new Map([['ssh', ready('r1')]])
})
function Fixture() {
remote = useNativeChatRestartRuntimes()[0]?.store
return <NativeChatResumeStatusSegment iconOnly={false} />
}
await mount(<Fixture />)
expect(publishedRows).toBeGreaterThan(0)
expect(publishedRows).toBeLessThanOrEqual(size)
const feed = getStructuredAgentSessionStatusFeed({ kind: 'environment', environmentId: 'ssh' })
const snapshot = feed.getSnapshot
const instrumented = new WeakSet<object>()
let lookups = 0
vi.spyOn(feed, 'getSnapshot').mockImplementation(() => {
const value = snapshot()
if (!instrumented.has(value)) {
instrumented.add(value)
const get = value.get.bind(value)
vi.spyOn(value, 'get').mockImplementation((id) => {
lookups++
return get(id)
})
}
return value
})
for (let frame = 0; frame < 10; frame++) {
await act(async () =>
stream.emit({
type: 'status',
session: {
sessionId: ids[0],
workspaceId: 'workspace',
agent: 'codex',
status: 'working',
latestPrompt: `activity-${frame}`,
updatedAt: Date.now()
}
})
)
}
await act(async () => vi.advanceTimersByTimeAsync(500))
expect(
rpc.mock.calls.filter(([, method]) => method === 'agentSession.restartResumable')
).toHaveLength(2)
expect(publishedRows).toBeLessThanOrEqual(2 * size)
candidateReads = 0
let action: Promise<void> | undefined
await act(async () => {
action = remote!.continueNativeChatRestartOffer(ids)
})
expect(candidateReads).toBeLessThanOrEqual(16 * size)
for (let frame = 0; frame < 10; frame++) {
lookups = candidateReads = 0
await act(async () =>
stream.emit({
type: 'status',
session: {
sessionId: ids[0],
workspaceId: 'workspace',
agent: 'codex',
status: 'working',
latestPrompt: 'unchanged prompt',
updatedAt: Date.now(),
restartResume: { phase: 'queued' }
}
})
)
// One offered pass and one progress pass; the compact view reads candidates linearly.
expect(lookups).toBeGreaterThan(0)
expect(lookups).toBeLessThanOrEqual(2 * size)
expect(candidateReads).toBeLessThanOrEqual(16 * size)
}
expect(history).toHaveBeenCalledTimes(1)
expect(
rpc.mock.calls.filter(([, method]) => method === 'agentSession.restartResumable')
).toHaveLength(2)
candidateReads = 0
await act(async () => {
firstReply.resolve()
await action
})
const chunks = rpc.mock.calls
.filter(([, method]) => method === 'agentSession.restartContinue')
.map(([, , params]) => params?.sessionIds ?? [])
expect(chunks).toHaveLength(Math.ceil(size / 512))
expect(chunks.every((chunk) => chunk.length <= 512)).toBe(true)
expect(chunks.flat()).toEqual(ids)
expect(peak).toBe(1)
const remainingVolume = chunks.reduce(
(total, _, index) => total + Math.max(size - 512 * (index + 1), 0),
0
)
expect(publishedRows).toBeLessThanOrEqual(2 * size + remainingVolume)
// Each reply republishes remaining offers and reprojects the fixed run in the compact view.
expect(candidateReads).toBeLessThanOrEqual(
16 * size * (chunks.length + 2) + 4 * remainingVolume
)
expect(remote!.getNativeChatRestartRun()?.continued).toHaveLength(size)
expect(document.body.textContent).toContain(`${size} chats resumed`)
expect(vi.mocked(toast)).toHaveBeenCalledTimes(1)
await act(async () => useAppStore.setState({ runtimeEnvironments: [] }))
expect(vi.getTimerCount()).toBe(0)
}
)
function store() {
const value = createNativeChatRestartStore({ kind: 'environment', environmentId: 'ssh' })
stores.push(value)
vi.stubGlobal('api', { runtimeEnvironments: { desktopCreatedChatIds: async () => ['a'] } })
return value
}
function pairedEnvironment(id: string) {
return {
id,
name: 'Server',
createdAt: 0,
updatedAt: 0,
lastUsedAt: null,
runtimeId: 'r1',
endpoints: [],
preferredEndpointId: 'none'
}
}
function ready(runtimeId: string): RuntimeEnvironmentStatus {
return {
checkedAt: 0,
status: {
runtimeId,
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0
}
}
}
it.each([
['Resume 1 chat started here', ['a']],
['Resume all 2 on Server', ['a', 'b']]
])(
'sends the server offer chosen by %s, including a chat from another client',
async (label, ids) => {
const remote = store()
rpc.mockResolvedValue({ sessions: offered })
await act(async () => remote.observeRuntimeStatus(ready('first')))
await mount(<NativeChatResumeRuntimeModal store={remote} machineName="Server" offerEnabled />)
expect(document.body.textContent).toContain('Server restarted')
expect(document.body.textContent).not.toContain("Don't ask again")
rpc.mockResolvedValue({ sessions: [offered[1]], failed: [failure('a')] })
await act(async () => button(String(label)).click())
expect(rpc).toHaveBeenLastCalledWith(
{ kind: 'environment', environmentId: 'ssh' },
'agentSession.restartContinue',
{ sessionIds: ids },
{ beforeSend: expect.any(Function) }
)
expect(remote.startedHere(remote.getNativeChatRestartOffer().failed[0].sessionId)).toBe(true)
expect(remote.startedHere(remote.getNativeChatRestartOffer().candidates[0].sessionId)).toBe(
false
)
}
)
it('snoozes server failures locally through reconnect and reopens for a new runtime', async () => {
const remote = store()
rpc.mockResolvedValue({ sessions: offered })
await act(async () => remote.observeRuntimeStatus(ready('first')))
await remote.dismissNativeChatRestartOffer(['a'])
expect(rpc).toHaveBeenCalledTimes(1)
expect(remote.getNativeChatRestartOffer().candidates).toHaveLength(2)
remote.observeRuntimeStatus(undefined)
await act(async () => remote.observeRuntimeStatus(ready('first')))
expect(remote.getNativeChatResumeOnRestartDialogRequest()).toBe(false)
await act(async () => remote.observeRuntimeStatus(ready('second')))
expect(remote.getNativeChatResumeOnRestartDialogRequest()).toBe(true)
})
it.each([undefined, { sessions: [] }])(
'shows no remote dialog when an older server has no offers: %s',
async (answer) => {
const remote = store()
if (answer) {
rpc.mockResolvedValue(answer)
} else {
rpc.mockRejectedValue(
new RuntimeRpcCallError({
id: 'list',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' }
})
)
}
await act(async () => remote.observeRuntimeStatus(ready('old')))
expect(remote.getNativeChatResumeOnRestartDialogRequest()).toBe(false)
expect(remote.getNativeChatRestartOffer().candidates).toEqual([])
}
)
it('replaces unseen run history coherently at the next runtime and retires late work', async () => {
const remote = store()
rpc.mockResolvedValue({ sessions: offered })
await act(async () => remote.observeRuntimeStatus(ready('r1')))
rpc.mockResolvedValue({ sessions: [], continued: [{ sessionId: 'a', outcome: 'continued' }] })
await act(async () => remote.continueNativeChatRestartOffer(['a']))
expect(remote.getNativeChatRestartRun()?.continued).toEqual([
{ sessionId: 'a', outcome: 'continued' }
])
rpc.mockResolvedValue({ sessions: offered })
await act(async () => remote.observeRuntimeStatus(ready('r2')))
expect(remote.getNativeChatRestartRun()).toBeNull()
await mount(<NativeChatResumeRuntimeModal store={remote} machineName="Server" offerEnabled />)
expect(button('Resume 1 chat started here').disabled).toBe(false)
const old = remote.getNativeChatRestartOccurrence()
const pending = Promise.withResolvers<unknown>()
rpc.mockReturnValueOnce(pending.promise)
const action = remote.continueNativeChatRestartOffer(['a'])
await act(async () => remote.observeRuntimeStatus(ready('r3')))
const snapshot = remote.getNativeChatRestartOffer()
await act(async () => {
pending.resolve({ sessions: [] })
await action
await old.reopenNativeChatRestartOffer()
})
expect(remote.getNativeChatRestartOffer()).toBe(snapshot)
expect(remote.getNativeChatResumeOnRestartDialogRequest()).toBe(true)
expect(remote.getNativeChatRestartRun()).toBeNull()
})
it('disposes pending discovery without resurrecting subscriptions or blocking another dialog', async () => {
const remote = store()
const pending = Promise.withResolvers<unknown>()
rpc.mockReturnValueOnce(pending.promise)
remote.observeRuntimeStatus(ready('r1'))
remote.dispose()
requestNativeChatResumeOnRestartDialog()
await act(async () => pending.resolve({ sessions: offered }))
expect(remote.getNativeChatRestartOffer().candidates).toEqual([])
expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true)
expect(rpc).toHaveBeenCalledTimes(1)
})
it('retries unsettled discovery finitely, keeps empty owners history-free and hydrates nonempty offers', async () => {
vi.useFakeTimers()
const remote = store()
const history = vi.fn(async () => ['a'])
vi.stubGlobal('api', { runtimeEnvironments: { desktopCreatedChatIds: history } })
rpc
.mockRejectedValueOnce(new Error('temporarily offline'))
.mockResolvedValue({ sessions: offered })
await act(async () => remote.observeRuntimeStatus(ready('r0')))
await act(async () => vi.advanceTimersByTimeAsync(100))
expect(remote.getNativeChatResumeOnRestartDialogRequest()).toBe(true)
expect(rpc).toHaveBeenCalledTimes(2)
rpc.mockReset()
history.mockClear()
rpc.mockRejectedValue(new Error('temporarily offline'))
await act(async () => remote.observeRuntimeStatus(ready('r1')))
await act(async () => vi.advanceTimersByTimeAsync(850))
expect(rpc).toHaveBeenCalledTimes(4)
expect(history).not.toHaveBeenCalled()
await act(async () => vi.advanceTimersByTimeAsync(10_000))
expect(rpc).toHaveBeenCalledTimes(4)
rpc.mockResolvedValue({ sessions: offered })
await act(async () => remote.observeRuntimeStatus({ ...ready('r1'), hostContactEpoch: 1 }))
expect(remote.getNativeChatResumeOnRestartDialogRequest()).toBe(true)
expect(history).toHaveBeenCalledTimes(1)
await act(async () => remote.refreshNativeChatRestartOffer())
expect(history).toHaveBeenCalledTimes(1)
rpc.mockResolvedValue({ sessions: [] })
await act(async () => remote.refreshNativeChatRestartOffer())
expect(remote.startedHere('a')).toBe(false)
rpc.mockResolvedValue({ sessions: offered })
await act(async () => remote.refreshNativeChatRestartOffer())
expect(history).toHaveBeenCalledTimes(2)
rpc.mockRejectedValue(new Error('offline'))
await act(async () => remote.observeRuntimeStatus(ready('r2')))
const calls = rpc.mock.calls.length
remote.dispose()
await act(async () => vi.advanceTimersByTimeAsync(850))
expect(rpc).toHaveBeenCalledTimes(calls)
})
it.each(['here', 'all'])(
'chunks the complete filtered %s choice into one action and notification',
async (scope) => {
const remote = store()
const many = Array.from({ length: 513 }, (_, i) => ({ ...offered[0], sessionId: `mine-${i}` }))
const retry = { ...failure('a', 'agent_session_conflict'), sessionId: 'retry' }
const excluded = {
...failure('a', 'structured_agent_session_unsupported'),
sessionId: 'excluded',
retryable: false
}
const manual = { ...failure('a'), sessionId: 'manual' }
let remaining = [...many, offered[1]]
let failures = [retry, excluded, manual]
vi.stubGlobal('api', {
runtimeEnvironments: {
desktopCreatedChatIds: async () => [...many.map((row) => row.sessionId), 'retry']
}
})
rpc.mockImplementation(async (_target, method, params) => {
if (method === 'agentSession.restartResumable') {
return { sessions: remaining, failed: failures }
}
const ids = params?.sessionIds ?? []
remaining = remaining.filter((row) => !ids.includes(row.sessionId))
failures = failures.filter((row) => !ids.includes(row.sessionId))
return {
sessions: remaining,
failed: failures,
continued: ids.map((sessionId) => ({ sessionId, outcome: 'continued' }))
}
})
await act(async () => remote.observeRuntimeStatus(ready('r1')))
await mount(<NativeChatResumeRuntimeModal store={remote} machineName="Server" offerEnabled />)
expect(document.querySelector('button[aria-label^="Dismiss "]')).toBeNull()
expect(
[...document.querySelectorAll('button')].filter(
(entry) => entry.textContent?.trim() === 'Dismiss'
)
).toHaveLength(1)
await act(async () =>
button(
scope === 'here' ? 'Resume 514 chats started here' : 'Resume all 515 on Server'
).click()
)
const chunks = rpc.mock.calls
.filter((call) => call[1] === 'agentSession.restartContinue')
.map((call) => call[2]?.sessionIds ?? [])
expect(chunks.map((chunk) => chunk.length)).toEqual([512, scope === 'here' ? 2 : 3])
expect(chunks.flat()).toEqual([
...many.map((row) => row.sessionId),
...(scope === 'all' ? ['b'] : []),
'retry'
])
expect(remote.getNativeChatRestartRun()?.continued).toHaveLength(scope === 'here' ? 514 : 515)
expect(vi.mocked(toast)).toHaveBeenCalledTimes(1)
}
)
it.each(['available', 'unavailable', 'no-tail', 'retired'])(
'keeps accepted-but-lost outcomes and untouched choices distinct (%s)',
async (mode) => {
const remote = store()
const many = Array.from({ length: mode === 'no-tail' ? 1024 : 1025 }, (_, i) => ({
...offered[0],
sessionId: `chat-${i}`
}))
let remaining = many
let chunks = 0
let failedRead = false
const pending = Promise.withResolvers<unknown>()
rpc.mockImplementation(async (_target, method, params) => {
if (method === 'agentSession.restartResumable') {
if (failedRead) {
throw new Error('reconciliation unavailable')
}
return { sessions: remaining, failed: [] }
}
const ids = params?.sessionIds ?? []
remaining = remaining.filter((row) => !ids.includes(row.sessionId))
if (++chunks === 2) {
return pending.promise
}
return {
sessions: remaining,
failed: [],
continued: ids.map((sessionId) => ({ sessionId, outcome: 'continued' }))
}
})
await act(async () => remote.observeRuntimeStatus(ready('r1')))
let action: Promise<void>
await act(async () => {
action = remote.continueNativeChatRestartOffer(many.map((row) => row.sessionId))
})
expect(chunks).toBe(2)
await act(async () => {
if (mode === 'retired') {
remote.dispose()
}
failedRead = mode === 'unavailable'
pending.reject(new Error('accepted reply lost'))
await action!
})
expect(chunks).toBe(2)
if (mode === 'retired') {
expect(vi.mocked(toast)).not.toHaveBeenCalled()
return
}
const run = remote.getNativeChatRestartRun()!
expect(run.entries).toHaveLength(1024)
expect(run.continued?.filter((row) => row.outcome === 'continued')).toHaveLength(512)
expect(run.continued?.filter((row) => row.outcome === 'unknown')).toHaveLength(512)
const view = resumeRunView(
run,
remote.getNativeChatRestartOffer().candidates,
() => undefined,
'all'
)
expect(view.counts).toMatchObject({ resumed: 512, attention: 512 })
expect(vi.mocked(toast).mock.calls).toEqual([
[
'Couldn’t confirm 512 chats were resumed',
expect.objectContaining({
description: 'Resumed 512 chats',
action: expect.objectContaining({ label: 'Review' })
})
]
])
await mount(<NativeChatResumeRuntimeModal store={remote} machineName="Server" offerEnabled />)
await act(async () => remote.reopenNativeChatRestartOffer())
if (mode === 'no-tail') {
// This run-only unknown also uses the existing lasting status/check presentation.
expect(remote.getNativeChatRestartOffer()).toMatchObject({ candidates: [], failed: [] })
expect(view.statusBySession.get('chat-512')?.kind).toBe('unconfirmed')
expect(document.body.textContent).toContain('Open the chat and check before sending another.')
expect(
[...document.querySelectorAll('button')].some((row) => row.textContent === 'Retry')
).toBe(false)
return
}
expect(remote.getNativeChatRestartOffer().candidates.map((row) => row.sessionId)).toEqual([
'chat-1024'
])
expect(button('Resume all 1 on Server').disabled).toBe(false)
failedRead = false
await act(async () => button('Resume all 1 on Server').click())
expect(chunks).toBe(3)
expect(
rpc.mock.calls.findLast((call) => call[1] === 'agentSession.restartContinue')?.[2]
).toEqual({ sessionIds: ['chat-1024'] })
}
)
it.each(['failure', 'success', 'history'])(
'supersedes an older ordinary read through all awaits (%s)',
async (mode) => {
const remote = store()
rpc.mockResolvedValue({ sessions: offered })
await act(async () => remote.observeRuntimeStatus(ready('r1')))
const old = Promise.withResolvers<unknown>()
const history = Promise.withResolvers<string[]>()
if (mode === 'history') {
vi.stubGlobal('api', {
runtimeEnvironments: { desktopCreatedChatIds: () => history.promise }
})
rpc.mockResolvedValueOnce({ sessions: [] })
await act(async () => remote.refreshNativeChatRestartOffer())
rpc.mockResolvedValue({ sessions: offered })
} else {
rpc.mockReturnValueOnce(old.promise)
}
let read: Promise<void>
await act(async () => {
read = remote.refreshNativeChatRestartOffer()
})
rpc.mockResolvedValue({ sessions: mode === 'failure' ? offered : [] })
remote.observeRuntimeStatus(undefined)
await act(async () => remote.observeRuntimeStatus(ready('r1')))
await act(async () => {
if (mode === 'failure') {
old.reject(new Error('obsolete failure'))
} else {
old.resolve({ sessions: offered })
}
history.resolve(['a'])
await read!
})
expect(remote.getNativeChatRestartOffer().candidates).toHaveLength(mode === 'failure' ? 2 : 0)
expect(remote.getNativeChatResumeOnRestartDialogRequest()).toBe(mode === 'failure')
}
)
it('enrolls an ordinary paired desktop once and shares resuming with target consumers without render discovery', async () => {
let owner: NativeChatRestartStore | undefined
const target = { kind: 'environment' as const, environmentId: 'paired-desktop' }
function Consumer({ lifecycle = true }: { lifecycle?: boolean }) {
return (
<>
{lifecycle && <Lifecycle />}
<output>{useNativeChatTargetRestartResuming(target).join(',')}</output>
</>
)
}
function Lifecycle() {
owner = useNativeChatRestartRuntimes()[0]?.store
return null
}
useAppStore.setState({
runtimeEnvironments: [{ ...pairedEnvironment(target.environmentId), name: 'Desktop' }],
runtimeStatusByEnvironmentId: new Map([[target.environmentId, ready('r1')]])
})
rpc.mockResolvedValue({ sessions: offered })
await mount(<Consumer />)
expect(owner?.getNativeChatRestartOffer().candidates).toHaveLength(2)
const pending = Promise.withResolvers<unknown>()
rpc.mockReturnValueOnce(pending.promise)
let action: Promise<void> | undefined
await act(async () => {
action = owner?.continueNativeChatRestartOffer(['a'])
})
expect(document.querySelector('output')?.textContent).toBe('a')
await act(async () => useAppStore.setState({ runtimeEnvironments: [] }))
expect(document.querySelector('output')?.textContent).toBe('')
await act(async () => {
pending.resolve({ sessions: [] })
await action
})
const count = rpc.mock.calls.length
await mount(<Consumer lifecycle={false} />)
expect(document.querySelector('output')?.textContent).toBe('')
expect(rpc).toHaveBeenCalledTimes(count)
})
it.each(['available', 'unavailable', 'other-desktop'])(
'retains unknown retry evidence over host observations (%s)',
async (mode) => {
let remote: NativeChatRestartStore | undefined
let inProgress = false
let hostFailure = { ...failure('a', 'agent_session_conflict'), failedAt: 10, retryable: true }
const reply = Promise.withResolvers<unknown>()
rpc.mockImplementation(async (_target, method, params) => {
if (method === 'agentSession.restartResumable') {
if (inProgress && mode === 'unavailable') {
throw new Error('read unavailable')
}
return { sessions: [], failed: [hostFailure] }
}
expect(params).toEqual({ sessionIds: ['a'] })
// Another desktop settles F20 before this request reserves it; reservation retains failure.
if (mode === 'other-desktop') {
hostFailure = { ...hostFailure, failedAt: 20 }
}
inProgress = true
return reply.promise
})
useAppStore.setState({
settings: {
...getDefaultSettings(''),
...useAppStore.getState().settings,
experimentalNativeChat: false
},
runtimeEnvironments: [pairedEnvironment('ssh')],
runtimeStatusByEnvironmentId: new Map([['ssh', ready('r1')]])
})
function Fixture() {
remote = useNativeChatRestartRuntimes()[0]?.store
return (
<>
<NativeChatResumeStatusSegment iconOnly={false} />
{remote && (
<NativeChatResumeRuntimeModal store={remote} machineName="Server" offerEnabled />
)}
</>
)
}
await mount(<Fixture />)
await act(async () => button('Server: 1 chat failed to resume. Click for details.').click())
await act(async () => button('Retry').click())
expect(inProgress).toBe(true)
expect(hostFailure.failedAt).toBe(mode === 'other-desktop' ? 20 : 10)
await act(async () => {
reply.reject(new Error('Timed out waiting for the remote Orca runtime to respond.'))
})
expect(remote!.getNativeChatRestartRun()?.continued).toEqual([
{ sessionId: 'a', outcome: 'unknown' }
])
expect(vi.mocked(toast.warning)).toHaveBeenCalledWith(
'Couldn’t confirm 1 chat was resumed',
expect.objectContaining({ action: expect.objectContaining({ label: 'Review' }) })
)
const statusLabel = 'Server: 1 chat to check after resuming. Click for details.'
expect(document.querySelectorAll(`button[aria-label="${statusLabel}"]`)).toHaveLength(1)
await act(async () => button(statusLabel).click())
expect(document.body.textContent).toContain('Open the chat and check before sending another.')
expect(
[...document.querySelectorAll('button')].some((row) => row.textContent === 'Retry')
).toBe(false)
expect(document.querySelector('[role="checkbox"][data-state="checked"]')).toBeNull()
expect(button('Open chat')).toBeTruthy()
await act(async () => remote!.reopenNativeChatRestartOffer())
expect(document.body.textContent).toContain('Open the chat and check before sending another.')
expect(
rpc.mock.calls.filter((call) => call[1] === 'agentSession.restartContinue')
).toHaveLength(1)
expect(remote!.getNativeChatRestartOffer().failed[0]).toEqual({
...hostFailure,
executionHostId: 'runtime:ssh'
})
await act(async () => button('Close').click())
expect(remote!.getNativeChatRestartRun()).toBeNull()
await act(async () => remote!.reopenNativeChatRestartOffer())
expect(button('Retry')).toBeTruthy()
expect(document.body.textContent).not.toContain(
'Open the chat and check before sending another.'
)
await act(async () => useAppStore.setState({ runtimeEnvironments: [] }))
}
)
@@ -0,0 +1,60 @@
import { Button } from './ui/button'
import { translate } from '@/i18n/i18n'
import type { NativeChatRestartStore } from './native-chat-resume-on-restart-store'
export function NativeChatRemoteResumeActions({
store,
machineName,
chosen,
busy,
resume,
dismiss,
resumeButtonRef
}: {
store: NativeChatRestartStore
machineName: string
chosen: readonly string[]
busy: boolean
resume: (ids: string[]) => void
dismiss: () => void
resumeButtonRef: React.Ref<HTMLButtonElement>
}): React.JSX.Element {
const all = [...chosen]
const startedHere = all.filter(store.startedHere)
return (
<>
<Button variant="ghost" size="sm" disabled={busy} onClick={dismiss}>
{translate('auto.components.NativeChatResumeOnRestartModal.serverDismiss', 'Dismiss')}
</Button>
<Button
variant="outline"
size="sm"
disabled={busy || all.length === 0}
onClick={() => resume(all)}
>
{translate(
'auto.components.NativeChatResumeOnRestartModal.serverResumeAll',
'Resume all {{count}} on {{machine}}',
{ count: all.length, machine: machineName }
)}
</Button>
<Button
ref={resumeButtonRef}
variant="default"
size="sm"
disabled={busy || startedHere.length === 0}
onClick={() => resume(startedHere)}
>
{translate(
startedHere.length === 1
? 'auto.components.NativeChatResumeOnRestartModal.serverResumeHereOne'
: 'auto.components.NativeChatResumeOnRestartModal.serverResumeHere',
startedHere.length === 1
? 'Resume 1 chat started here'
: 'Resume {{count}} chats started here',
{ count: startedHere.length }
)}
</Button>
</>
)
}
@@ -0,0 +1,64 @@
import { RotateCcw } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'
import type { ResumeRunPanel } from './NativeChatResumeRunPanel'
export function NativeChatResumeDialogHeader({
runPanel,
machineName,
remote,
interruptedByUpdate
}: {
runPanel: ResumeRunPanel | null
machineName?: string
remote: boolean
interruptedByUpdate: boolean
}): React.JSX.Element {
return (
<DialogHeader>
<DialogTitle>
{/* Plain wrapper owns the icon spacing; DialogTitle owns its own. */}
{remote && runPanel ? (
<>
{machineName}: {runPanel.title}
</>
) : (
(runPanel?.title ?? (
<span className="flex items-center gap-2">
<RotateCcw className="size-4 text-muted-foreground" />
{remote
? translate(
'auto.components.NativeChatResumeOnRestartModal.serverTitle',
'{{machine}} restarted',
{ machine: machineName }
)
: translate(
'auto.components.NativeChatResumeOnRestartModal.title',
'Resume interrupted chats?'
)}
</span>
))
)}
</DialogTitle>
<DialogDescription>
{runPanel?.description ??
(remote
? translate(
'auto.components.NativeChatResumeOnRestartModal.serverBody',
'These chats were working when Orca on {{machine}} restarted. Resuming restores each one where it stopped and asks the agent to carry on. Your own prompt is not re-sent.',
{ machine: machineName }
)
: interruptedByUpdate
? translate(
'auto.components.NativeChatResumeOnRestartModal.updateBody',
'These chats were working when Orca installed an update. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.'
)
: translate(
'auto.components.NativeChatResumeOnRestartModal.body',
'These chats were working when Orca closed. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.'
))}
</DialogDescription>
{runPanel?.summary}
</DialogHeader>
)
}
@@ -31,6 +31,7 @@ export function ResumeFailureStatus({
disabled: boolean
onAction: (action: ResumeFailureAction) => void
}): React.JSX.Element {
const canDismiss = failure.executionHostId === undefined || failure.executionHostId === 'local'
const status =
failure.outcome === 'unconfirmed'
? translate(
@@ -66,19 +67,21 @@ export function ResumeFailureStatus({
<span className="mt-0.5 block font-mono text-[10px] opacity-75">{failure.reason}</span>
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon-xs"
disabled={disabled}
aria-label={translate(
'auto.components.NativeChatResumeOutcomeRow.dismissChat',
'Dismiss "{{value0}}" in {{value1}}',
{ value0: title, value1: workspaceName }
)}
onClick={() => onAction('dismiss')}
>
<X className="size-3" />
</Button>
{canDismiss && (
<Button
variant="ghost"
size="icon-xs"
disabled={disabled}
aria-label={translate(
'auto.components.NativeChatResumeOutcomeRow.dismissChat',
'Dismiss "{{value0}}" in {{value1}}',
{ value0: title, value1: workspaceName }
)}
onClick={() => onAction('dismiss')}
>
<X className="size-3" />
</Button>
)}
</>
)
}
@@ -104,16 +107,19 @@ export function ResumeFailureGuidanceLine({
<Button size="xs" disabled={disabled} onClick={() => onAction(guidance.primary)}>
{actionLabel(guidance.primary)}
</Button>
{guidance.secondary && (
<Button
size="xs"
variant="secondary"
disabled={disabled}
onClick={() => onAction(guidance.secondary!)}
>
{actionLabel(guidance.secondary)}
</Button>
)}
{guidance.secondary &&
(guidance.secondary !== 'dismiss' ||
failure.executionHostId === undefined ||
failure.executionHostId === 'local') && (
<Button
size="xs"
variant="secondary"
disabled={disabled}
onClick={() => onAction(guidance.secondary!)}
>
{actionLabel(guidance.secondary)}
</Button>
)}
</div>
)
}
@@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useAppStore } from '../store'
import { getDefaultSettings } from '../../../shared/constants'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeRuntimeModal'
import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment'
import { TooltipProvider } from './ui/tooltip'
import {
@@ -11,7 +11,7 @@ import { toast } from 'sonner'
import { expect, it, vi, type Mock } from 'vitest'
import { useAppStore } from '../store'
import { getDefaultSettings } from '../../../shared/constants'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeRuntimeModal'
import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment'
import { lastToastShow } from './native-chat-resume-toast.test-support'
import {
@@ -384,7 +384,7 @@ it.each([
] as const)(
'reports a %s continuation once in a toast and in the status bar',
async (outcome, label, said) => {
fakeHost({}, () => (outcome === 'refused' ? 'refused' : 'unknown'))
fakeHost({}, () => (outcome === 'refused' ? 'refused' : 'unknown'), 'agent_session_conflict')
await mount(
<>
<NativeChatResumeOnRestartModal />
@@ -395,6 +395,11 @@ it.each([
expect(button(label)).toBeTruthy()
expect(toasts()).toEqual([[said]])
expect(rpc).toHaveBeenCalledTimes(2)
if (outcome === 'refused') {
await act(async () => lastToastShow()?.())
expect(button('Retry')).toBeTruthy()
expect(chatBox('a').getAttribute('data-state')).toBe('checked')
}
}
)
@@ -410,20 +415,22 @@ it('keeps the offer listed after an unreadable resume response', async () => {
expect(document.querySelector('[role="dialog"]')).toBeNull()
})
// A request lost before reservation has no host failure record; retain a local Retry marker until reconciliation or retry settles it.
it('reports the chats a lost resume request named once, as failed, until it is retried', async () => {
// A host observation cannot settle a lost action reply; closing its summary restores host facts.
it('keeps a lost request unconfirmed until its viewed summary retires', async () => {
let sessions = offered
let continueFails = true
let failures: ReturnType<typeof failure>[] = []
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const lost = new Error('response lost')
rpc.mockImplementation(async (_target, method) => {
if (method === 'agentSession.restartResumable') {
return { sessions, failed: [] }
return { sessions, failed: failures }
}
if (continueFails) {
throw lost
}
sessions = []
failures = []
return {
resumed: [{ sessionId: 'b', outcome: 'resumed' }],
continued: [{ sessionId: 'b', outcome: 'continued' }],
@@ -438,7 +445,8 @@ it('reports the chats a lost resume request named once, as failed, until it is r
</>
)
await act(async () => button('Resume 2 chats').click())
expect(toasts()).toEqual([['2 chats couldn’t be resumed']])
expect(toasts()).toEqual([['Couldn’t confirm 2 chats were resumed']])
// The re-read lists both chats, so the toast keeps Show.
const show = lastToastShow()
expect(show).toBeDefined()
// Reconcile a lost response read-only; never automatically repeat the action.
@@ -452,7 +460,7 @@ it('reports the chats a lost resume request named once, as failed, until it is r
warn.mockRestore()
expect(offerIds()).toEqual([])
expect(getNativeChatRestartOffer().failed.map((entry) => entry.sessionId)).toEqual(['a', 'b'])
expect(button('2 chats failed to resume. Click for details.')).toBeTruthy()
expect(button('2 chats to check after resuming. Click for details.')).toBeTruthy()
expect(document.body.textContent).not.toContain('chats to resume')
sessions = [offered[1]!]
@@ -464,8 +472,19 @@ it('reports the chats a lost resume request named once, as failed, until it is r
continueFails = false
await act(async () => show?.())
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
'Orca couldn’t resume this chat. Open it to continue manually.'
'Orca couldn’t confirm the “continue” message was delivered.'
)
expect([...document.querySelectorAll('button')].some((row) => row.textContent === 'Retry')).toBe(
false
)
sessions = []
failures = [failure('b', 'agent_session_conflict')]
await act(async () => refreshNativeChatRestartOffer())
expect([...document.querySelectorAll('button')].some((row) => row.textContent === 'Retry')).toBe(
false
)
await act(async () => button('Close').click())
await act(async () => show?.())
await act(async () => button('Retry').click())
expect(calls().at(-1)).toEqual(['agentSession.restartContinue', { sessionIds: ['b'] }])
expect(getNativeChatRestartOffer()).toMatchObject({ candidates: [], failed: [] })
@@ -485,8 +504,8 @@ it('answers a mixed Resume with one toast whose Show opens the dialog', async ()
expect(runStatus('Prompt a')).toBe('Prompt a: Resumed')
})
// An unconfirmed Dismiss cannot erase a failed-resume marker.
it('keeps a lost resume request marked failed when its Dismiss fails', async () => {
// A Dismiss the host never took leaves the chat shown as failed, not back as a plain offer.
it('keeps a lost resume request unconfirmed when its Dismiss fails', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
rpc.mockImplementation(async (_target, method) => {
if (method === 'agentSession.restartResumable') {
@@ -497,7 +516,7 @@ it('keeps a lost resume request marked failed when its Dismiss fails', async ()
await mount(<NativeChatResumeOnRestartModal />)
await act(async () => button('Resume 1 chat').click())
expect(getNativeChatRestartOffer().failed.map((entry) => entry.sessionId)).toEqual(['a'])
expect(toasts()).toEqual([['1 chat couldn’t be resumed']])
expect(toasts()).toEqual([['Couldn’t confirm 1 chat was resumed']])
await act(async () => requestNativeChatResumeOnRestartDialog())
await act(async () => button('Dismiss "Prompt a" in workspace').click())
@@ -1,16 +1,8 @@
import { NativeChatResumeDialogHeader } from './NativeChatResumeDialogHeader'
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { useNativeChatRestartOfferEnabled } from './native-chat-restart-offer-gate'
import { RotateCcw } from 'lucide-react'
import { Button } from './ui/button'
import { Checkbox } from './ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from './ui/dialog'
import { Dialog, DialogContent, DialogFooter } from './ui/dialog'
import { useAppStore } from '../store'
import { translate } from '@/i18n/i18n'
import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session'
@@ -28,21 +20,15 @@ import {
type ResumeFailure
} from './native-chat-resume-on-restart-grouping'
import {
consumeNativeChatResumeOnRestartDialogRequest,
getNativeChatResumeOnRestartDialogRequest,
subscribeNativeChatResumeOnRestartDialog
} from './native-chat-resume-on-restart-dialog'
import {
continueNativeChatRestartOffer,
dismissNativeChatRestartOffer,
markFinishedNativeChatRestartRunShown,
releaseFinishedNativeChatRestartRun,
useNativeChatRestartOffer,
useNativeChatRestartResuming,
useNativeChatRestartRun
useNativeChatRestartRun,
type NativeChatRestartStore
} from './native-chat-resume-on-restart-store'
import { useResumeRunPanel } from './NativeChatResumeRunPanel'
import { resumeRunFailures } from './native-chat-resume-run-view'
import { NativeChatResumeAutoNote } from './NativeChatResumeAutoNote'
import { NativeChatRemoteResumeActions } from './NativeChatRemoteResumeActions'
import { moveInTree } from './native-chat-resume-tree-keys'
/** Resume reattaches and sends a continuation, never the original user prompt; disclose this before sending. Automatic preference skips the prompt but uses the same host safety checks.
@@ -57,14 +43,29 @@ function selectedByDefault(failure: ResumeFailure | undefined): boolean {
return guidance.primary === 'retry' || guidance.secondary === 'retry'
}
export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
const offerEnabled = useNativeChatRestartOfferEnabled()
const { candidates, failed, listedAt } = useNativeChatRestartOffer(offerEnabled)
export function NativeChatResumeOccurrenceModal({
store,
machineName,
offerEnabled
}: {
store: NativeChatRestartStore
machineName?: string
offerEnabled: boolean
}): React.JSX.Element | null {
const {
continueNativeChatRestartOffer,
dismissNativeChatRestartOffer,
markFinishedNativeChatRestartRunShown,
releaseFinishedNativeChatRestartRun,
consumeNativeChatResumeOnRestartDialogRequest,
getNativeChatResumeOnRestartDialogRequest,
subscribeNativeChatResumeOnRestartDialog
} = store
const remote = store.target.kind === 'environment'
const { candidates, failed, listedAt } = useNativeChatRestartOffer(offerEnabled, store)
const run = useNativeChatRestartRun(store)
const rows = useMemo<ResumeCandidate[]>(() => [...candidates, ...failed], [candidates, failed])
const failureBySession = useMemo(
() => new Map(failed.map((failure) => [failure.sessionId, failure])),
[failed]
)
const failureBySession = useMemo(() => resumeRunFailures(run, failed), [run, failed])
// Read one-shot open requests directly; a local mirror could go stale between launch and status-bar requests.
const open = useSyncExternalStore(
subscribeNativeChatResumeOnRestartDialog,
@@ -78,7 +79,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
)
const resumeButtonRef = useRef<HTMLButtonElement>(null)
// The store's: the resume outlives this dialog, which can close or reopen mid-run.
const resuming = useNativeChatRestartResuming()
const resuming = useNativeChatRestartResuming(store)
const busy = resuming.length > 0
/** Track overrides over host-derived defaults because the live offer can arrive/shrink while open; avoid effect-seeded selections. */
const [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(() => new Map())
@@ -119,7 +120,6 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
(sessionId: string) => failureBySession.get(sessionId),
[failureBySession]
)
const run = useNativeChatRestartRun()
const runPanel = useResumeRunPanel({
run,
rows,
@@ -131,11 +131,17 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
if (offerEnabled && open && run && runPanel?.finishedRowsVisible) {
markFinishedNativeChatRestartRunShown(run)
}
}, [offerEnabled, open, run, runPanel?.finishedRowsVisible])
}, [
offerEnabled,
open,
run,
runPanel?.finishedRowsVisible,
markFinishedNativeChatRestartRunShown
])
const allSelection = resumeSelectionState(selectable, ticked)
const choosing = runPanel?.choosing ?? true
// While the setting is off the box stays, before and after Resume; an automatic run explains itself.
const showDontAskAgain = !autoResumeOn && runPanel?.startedBy !== 'launch'
const showDontAskAgain = !remote && !autoResumeOn && runPanel?.startedBy !== 'launch'
const toggleSelected = useCallback((sessionId: string, checked: boolean) => {
setOverrides((current) => new Map(current).set(sessionId, checked))
@@ -155,7 +161,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
void persistPreference()
await continueNativeChatRestartOffer(sessionIds)
},
[persistPreference]
[persistPreference, continueNativeChatRestartOffer]
)
/** Closing is a snooze: the host keeps the offer and the status bar keeps the way back to it. */
@@ -163,7 +169,11 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
consumeNativeChatResumeOnRestartDialogRequest()
releaseFinishedNativeChatRestartRun()
void persistPreference()
}, [persistPreference])
}, [
persistPreference,
consumeNativeChatResumeOnRestartDialogRequest,
releaseFinishedNativeChatRestartRun
])
const dismissAll = useCallback(async (): Promise<void> => {
void persistPreference()
@@ -171,7 +181,12 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
consumeNativeChatResumeOnRestartDialogRequest()
releaseFinishedNativeChatRestartRun()
await dismissNativeChatRestartOffer()
}, [persistPreference])
}, [
persistPreference,
consumeNativeChatResumeOnRestartDialogRequest,
releaseFinishedNativeChatRestartRun,
dismissNativeChatRestartOffer
])
const actOnFailure = async (action: ResumeFailureAction, sessionId: string): Promise<void> => {
if (action === 'dismiss') {
@@ -189,9 +204,10 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
// Opening is read-only; the user’s send settles the record. Clear the dialog so the chat is visible.
consumeNativeChatResumeOnRestartDialogRequest()
releaseFinishedNativeChatRestartRun()
await activateAiVaultStructuredSession({
structuredSession: { workspaceId: failure.workspaceId, sessionId }
})
const session = { structuredSession: { workspaceId: failure.workspaceId, sessionId } }
await (remote
? activateAiVaultStructuredSession(session, undefined, store.target)
: activateAiVaultStructuredSession(session))
}
if (!offerEnabled || !open || (rows.length === 0 && !runPanel)) {
@@ -228,33 +244,12 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
}
}}
>
<DialogHeader>
<DialogTitle>
{/* The inner wrapper owns icon spacing independently of DialogTitle. */}
{runPanel?.title ?? (
<span className="flex items-center gap-2">
<RotateCcw className="size-4 text-muted-foreground" />
{translate(
'auto.components.NativeChatResumeOnRestartModal.title',
'Resume interrupted chats?'
)}
</span>
)}
</DialogTitle>
<DialogDescription>
{runPanel?.description ??
(interruptedByUpdate
? translate(
'auto.components.NativeChatResumeOnRestartModal.updateBody',
'These chats were working when Orca installed an update. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.'
)
: translate(
'auto.components.NativeChatResumeOnRestartModal.body',
'These chats were working when Orca closed. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.'
))}
</DialogDescription>
{runPanel?.summary}
</DialogHeader>
<NativeChatResumeDialogHeader
runPanel={runPanel}
machineName={machineName}
remote={remote}
interruptedByUpdate={interruptedByUpdate}
/>
<div
role="tree"
@@ -275,7 +270,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
onKeyDown={moveInTree}
>
{/* One Select all divider for the whole tree, including multiple machines. */}
{choosing && (
{choosing && !remote && (
<div className="mb-1 border-b border-worktree-sidebar-border pb-0.5">
<ResumeTreeRow
depth={0}
@@ -314,7 +309,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
onFailureAction={(action, sessionId) => void actOnFailure(action, sessionId)}
renderStatus={runPanel?.renderStatus}
selectableIds={selectableIds}
selecting={choosing}
selecting={choosing && !remote}
/>
</div>
@@ -353,6 +348,19 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null {
<Button ref={resumeButtonRef} variant="default" size="sm" onClick={snooze}>
{translate('auto.components.NativeChatResumeOnRestartModal.done', 'Done')}
</Button>
) : remote ? (
<NativeChatRemoteResumeActions
resumeButtonRef={resumeButtonRef}
store={store}
machineName={machineName ?? ''}
chosen={chosen}
busy={busy}
resume={(ids) => {
consumeNativeChatResumeOnRestartDialogRequest()
void resume(ids)
}}
dismiss={snooze}
/>
) : (
<>
{/* Dismiss deletes durable records rather than snoozing them. */}
@@ -0,0 +1,50 @@
import { useSyncExternalStore } from 'react'
import {
localNativeChatRestartStore,
type NativeChatRestartStore
} from './native-chat-resume-on-restart-store'
import { NativeChatResumeOccurrenceModal } from './NativeChatResumeOnRestartModal'
import { useNativeChatRestartOfferEnabled } from './native-chat-restart-offer-gate'
import { useNativeChatRestartRuntimes } from './native-chat-restart-runtimes'
export function NativeChatResumeOnRestartModal(): React.JSX.Element {
const enabled = useNativeChatRestartOfferEnabled()
const runtimes = useNativeChatRestartRuntimes()
return (
<>
<NativeChatResumeRuntimeModal store={localNativeChatRestartStore} offerEnabled={enabled} />
{runtimes.map(({ environment, store }) => (
<NativeChatResumeRuntimeModal
key={environment.id}
store={store}
machineName={environment.name}
offerEnabled
/>
))}
</>
)
}
export function NativeChatResumeRuntimeModal({
store,
machineName,
offerEnabled
}: {
store: NativeChatRestartStore
machineName?: string
offerEnabled: boolean
}): React.JSX.Element | null {
const occurrence = useSyncExternalStore(
store.subscribe,
store.getNativeChatRestartOccurrence,
store.getNativeChatRestartOccurrence
)
return (
<NativeChatResumeOccurrenceModal
key={occurrence.identity}
store={store}
machineName={machineName}
offerEnabled={offerEnabled}
/>
)
}
@@ -0,0 +1,98 @@
import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import type { HostOfferRead } from './native-chat-restart-host-offer'
export const RESTART_READ_RETRY_DELAYS_MS = [100, 250, 500] as const
export async function readDesktopCreatedChatIds(
target: RuntimeClientTarget,
offer: HostOfferRead,
previous: Set<string> | null,
refresh: boolean
): Promise<Set<string> | null> {
if (target.kind === 'local' || offer.candidates.length + offer.failed.length === 0) {
return null
}
if (previous !== null && !refresh) {
return previous
}
try {
return new Set((await window.api.runtimeEnvironments.desktopCreatedChatIds?.()) ?? [])
} catch {
return new Set()
}
}
export function remoteRestartRuntimeId(status: RuntimeEnvironmentStatus | undefined) {
const snapshot = status?.snapshot
const verified = snapshot
? snapshot.verification === 'verified' && snapshot.transport === 'ready'
: !!status?.status
return verified ? status?.status?.runtimeId : undefined
}
/** Contact attempts belong to one occurrence; only a confirmed observation spends its first offer. */
export function createRemoteRestartOfferLifecycle({
read,
requestDialog,
currentAction
}: {
read: (current: () => boolean) => Promise<HostOfferRead>
requestDialog: () => void
currentAction: () => number
}) {
let readyKey: string | undefined
let observed = false
let attempt: object | undefined
let cancelWait: (() => void) | undefined
function cancel() {
attempt = undefined
cancelWait?.()
cancelWait = undefined
}
async function discover() {
const token = (attempt = {})
const issued = currentAction()
const current = () => attempt === token && currentAction() === issued
for (const delay of [0, ...RESTART_READ_RETRY_DELAYS_MS]) {
if (delay) {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, delay)
cancelWait = () => {
clearTimeout(timer)
resolve()
}
})
}
if (!current()) {
return
}
const answer = await read(current)
if (!current()) {
return
}
if (answer.available) {
if (!observed && answer.candidates.length > 0) {
requestDialog()
}
observed = true
return
}
}
}
return {
dispose: cancel,
observeRuntimeStatus: (status: RuntimeEnvironmentStatus | undefined) => {
const id = remoteRestartRuntimeId(status)
const nextKey = id ? `${id}:${status?.hostContactEpoch ?? 0}` : undefined
if (nextKey === readyKey) {
return
}
readyKey = nextKey
cancel()
if (id) {
void discover()
}
}
}
}
@@ -133,19 +133,25 @@ it('counts every chat as unconfirmed when the answer carried no outcomes', () =>
expect(titles()).toEqual(['Couldn’t confirm 2 chats were resumed'])
})
it('counts an unconfirmed chat the host no longer lists as resumed', () => {
// The host retires an unconfirmed send once the agent is seen carrying on it; the resume must still
// report the chat, and as resumed, not as a failure the list can no longer show.
it('preserves normalized uncertainty when a pending-list read omits the chat', () => {
announceRestartResults(['a'], [{ sessionId: 'a', outcome: 'unknown' }], [], show)
expect(titles()).toEqual(['Resumed 1 chat'])
expect(titles()).toEqual(['Couldn’t confirm 1 chat was resumed'])
})
// Unconfirmed sends may already be running; use the host’s recorded outcome to avoid inviting duplicate sends.
it('counts a chat the host filed as unconfirmed on its own line, as the list does', () => {
// Unconfirmed means the agent may well be working; "couldn't be resumed" would invite a second send.
// The normalized action result takes precedence over a different host observation.
it('counts normalized uncertainty separately from matching refusal', () => {
announceRestartResults(
['a', 'b'],
[{ sessionId: 'a', outcome: 'refused' }],
[
{ sessionId: 'a', outcome: 'refused' },
{ sessionId: 'b', outcome: 'unconfirmed' }
{ sessionId: 'b', outcome: 'unknown' }
],
[
{ sessionId: 'a', outcome: 'unconfirmed' },
{ sessionId: 'b', outcome: 'refused' }
],
show
)
@@ -161,27 +161,24 @@ export function announceRestartResults(
/** Opens the dialog over a fresh read; never rejects. */
show: () => Promise<void>
): void {
const results = restartContinuationHistory(requested, reportedResults, hostFailed)
const results =
reportedResults ?? requested.map((sessionId) => ({ sessionId, outcome: 'unknown' as const }))
const notContinued = restartChatsNotContinued(requested, results)
const failed = new Map(hostFailed?.map((failure) => [failure.sessionId, failure.outcome]))
// Count only failures still listed by the host; withdrawn failures cannot be shown in the dialog.
const reported =
hostFailed === undefined
? notContinued
: notContinued.filter((sessionId) => failed.has(sessionId))
const outcomes = new Map(results.map((result) => [result.sessionId, result.outcome]))
const sentUnconfirmed = (sessionId: string): boolean =>
outcomes.get(sessionId) === 'pending' || outcomes.get(sessionId) === 'unknown'
// The filed outcome is what the list shows, so the toast uses it too.
const unconfirmed = (sessionId: string): boolean =>
(failed.get(sessionId) ?? (sentUnconfirmed(sessionId) ? 'unconfirmed' : 'refused')) ===
'unconfirmed'
const unconfirmedCount = reported.filter(unconfirmed).length
const reported =
hostFailed === undefined
? notContinued
: notContinued.filter((sessionId) => failed.has(sessionId) || sentUnconfirmed(sessionId))
// Only normalized action outcomes settle delivery; a host list is an observation.
const unconfirmedCount = reported.filter(sentUnconfirmed).length
const continuedCount = new Set(requested).size - notContinued.length
announceResume(
continuedCount,
reported.length - unconfirmedCount,
unconfirmedCount,
hostFailed === undefined && continuedCount === 0 ? undefined : show
hostFailed === undefined && continuedCount === 0 && unconfirmedCount === 0 ? undefined : show
)
}
@@ -0,0 +1,101 @@
import type { ResumeFailure } from './native-chat-resume-on-restart-grouping'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { createRuntimeRpcAbortError } from '@/runtime/abortable-runtime-environment-call'
import {
failedFrom,
type HostOfferRead,
type HostOfferPayload,
type NativeChatRestartOffer
} from './native-chat-restart-host-offer'
import {
restartContinuationHistory,
type RestartContinuationOutcome
} from './native-chat-restart-action-notifications'
export type RestartContinuationBatchResult = {
attempted: string[]
continued: RestartContinuationOutcome[]
skipped: Set<string>
failed?: readonly ResumeFailure[]
}
/** One immutable selection; uncertainty ends dispatch without attributing an outcome to its tail. */
export async function continueRestartChunks(
target: RuntimeClientTarget,
requested: readonly string[],
owner: {
current: () => boolean
publish: (offer: NativeChatRestartOffer) => void
refresh: () => Promise<HostOfferRead>
failures: () => readonly ResumeFailure[]
markUnsent: (ids: readonly string[], at: number) => void
}
): Promise<RestartContinuationBatchResult> {
const result: RestartContinuationBatchResult = {
attempted: [],
continued: [],
skipped: new Set()
}
for (let offset = 0; offset < requested.length && owner.current(); offset += 512) {
const chunk = requested.slice(offset, offset + 512)
result.attempted.push(...chunk)
try {
const answer = await callStructuredAgentSession<
HostOfferPayload & {
continued?: RestartContinuationOutcome[]
skipped?: string[]
}
>(
target,
'agentSession.restartContinue',
{ sessionIds: chunk },
{
beforeSend: () => {
if (!owner.current()) {
throw createRuntimeRpcAbortError()
}
}
}
)
if (!owner.current()) {
break
}
const listed = Array.isArray(answer.failed) ? failedFrom(answer) : undefined
result.failed = listed
if (Array.isArray(answer.sessions)) {
owner.publish({ candidates: answer.sessions, failed: listed ?? [], listedAt: Date.now() })
} else {
await owner.refresh()
}
if (!owner.current()) {
break
}
for (const id of answer.skipped ?? []) {
result.skipped.add(id)
}
result.continued.push(
...restartContinuationHistory(
chunk.filter((id) => !result.skipped.has(id)),
answer.continued,
listed
)
)
} catch (error) {
if (!owner.current()) {
break
}
console.warn('[native-chat-resume] continuation reply unavailable', error)
result.continued.push(
...chunk.map((sessionId) => ({ sessionId, outcome: 'unknown' as const }))
)
owner.markUnsent(chunk, Date.now())
const read = await owner.refresh()
if (owner.current()) {
result.failed = read.available ? owner.failures() : undefined
}
break
}
}
return result
}
@@ -0,0 +1,76 @@
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { executionHostIdForStructuredTarget } from '@/runtime/structured-agent-session-owner'
import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping'
export type HostOfferRead = {
candidates: readonly ResumeCandidate[]
failed: readonly ResumeFailure[]
available: boolean
}
/** The host's answer as this side understands it. `failed` is optional on the wire: an older host
* never sends it, and its absence means nothing to show, not an invalid answer. */
export type HostOfferPayload = { sessions?: unknown; failed?: unknown }
export function failedFrom(payload: HostOfferPayload): ResumeFailure[] {
// SAFETY: the host is the single writer of this shape; a malformed row is a host bug, not input.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above.
return Array.isArray(payload.failed) ? (payload.failed as ResumeFailure[]) : []
}
export type NativeChatRestartOffer = Readonly<{
candidates: readonly ResumeCandidate[]
/** Acted-on offers whose agent did not carry on, as the host still records them. */
failed: readonly ResumeFailure[]
/** Stamped when the list arrived. Row ages read against this rather than a render-time
* `Date.now()`, so they stay stable across re-renders and the render stays pure. */
listedAt: number
}>
export const EMPTY: NativeChatRestartOffer = { candidates: [], failed: [], listedAt: 0 }
export function restartOfferOnTarget(
answer: NativeChatRestartOffer,
target: RuntimeClientTarget
): NativeChatRestartOffer {
if (target.kind === 'local') {
return answer
}
const onThisHost = <T extends ResumeCandidate>(rows: readonly T[]): T[] =>
rows.map((row) => ({ ...row, executionHostId: executionHostIdForStructuredTarget(target) }))
return { ...answer, candidates: onThisHost(answer.candidates), failed: onThisHost(answer.failed) }
}
/** Missing methods settle quietly; transport failures leave discovery unsettled. */
export async function readRestartHostOffer(target: RuntimeClientTarget): Promise<HostOfferRead> {
try {
const offered = await callStructuredAgentSession<HostOfferPayload>(
target,
'agentSession.restartResumable'
)
if (!Array.isArray(offered.sessions)) {
throw new Error('agent_session_restart_offer_invalid')
}
return { candidates: offered.sessions, failed: failedFrom(offered), available: true }
} catch (error) {
return {
candidates: [],
failed: [],
available: error instanceof RuntimeRpcCallError && error.code === 'method_not_found'
}
}
}
export async function dismissRestartHostOffer(
target: RuntimeClientTarget,
sessionIds?: readonly string[]
): Promise<NativeChatRestartOffer | null> {
const result = await callStructuredAgentSession<HostOfferPayload>(
target,
'agentSession.restartResumableDismiss',
sessionIds ? { sessionIds: [...sessionIds] } : {}
)
return Array.isArray(result.sessions)
? { candidates: result.sessions, failed: failedFrom(result), listedAt: Date.now() }
: null
}
@@ -0,0 +1,344 @@
import {
EMPTY,
dismissRestartHostOffer,
readRestartHostOffer,
restartOfferOnTarget,
type NativeChatRestartOffer,
type HostOfferRead
} from './native-chat-restart-host-offer'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed'
import { markNativeChatLaunchResumeDecided } from './native-chat-launch-resume-decision'
import { announceRestartResults } from './native-chat-restart-action-notifications'
import {
beginResumeRun,
resumeRunInFlight,
resumeRunPendingIds,
type ResumeRun,
type ResumeRunStarter
} from './native-chat-resume-run'
import { createUnsentResumes } from './native-chat-resume-unsent-requests'
import * as dialog from './native-chat-resume-on-restart-dialog'
import { createOfferedChatWatch } from './native-chat-resume-offered-chat-watch'
import {
createRemoteRestartOfferLifecycle,
readDesktopCreatedChatIds
} from './native-chat-remote-restart-offer'
import {
continueRestartChunks,
type RestartContinuationBatchResult
} from './native-chat-restart-continuation-chunks'
import { watchResumeRunStatus } from './native-chat-resume-run-status-watch'
let nextOccurrence = 0
export function createNativeChatRestartOccurrence(
target: RuntimeClientTarget,
key: string,
listeners: Set<() => void>
) {
const requestNativeChatResumeOnRestartDialog = () =>
dialog.requestNativeChatResumeOnRestartDialog(key)
const consumeNativeChatResumeOnRestartDialogRequest = () =>
dialog.consumeNativeChatResumeOnRestartDialogRequest(key)
const identity = ++nextOccurrence
let live = true
const current = () => live
let createdHere: Set<string> | null = null
const { forgetUnsentResumes, markUnsentResumes, withUnsentResumes } = createUnsentResumes()
let offer: NativeChatRestartOffer = EMPTY
/** Continue and dismiss calls, begun and settled. Each ends by publishing the host's answer, which
* a re-read that raced it must neither pre-empt nor undo. */
let actionsBegun = 0
let actionsSettled = 0
let readSequence = 0
/** Memory-only history: retires after viewing and closing its finished summary, or on replacement. */
let run: ResumeRun | null = null
let releaseRunStatus: (() => void) | undefined
let resuming: readonly string[] = resumeRunPendingIds(null)
function emit(): void {
for (const listener of listeners) {
listener()
}
}
/** The snapshot object is replaced HERE and nowhere else — never during a render — so every
* `useSyncExternalStore` reader sees the same reference until a host answer or a user action
* actually moves the offer. */
function publish(next: NativeChatRestartOffer): void {
if (!current()) {
return
}
offer = next
offeredChatWatch.sync()
emit()
}
/** A confirmed host answer. One with nothing left also retires any open request for the dialog,
* unless it is showing a run; a failed read only hides rows, so it keeps the request. */
function publishAnswer(answer: NativeChatRestartOffer): void {
if (!current()) {
return
}
const next = withUnsentResumes(restartOfferOnTarget(answer, target))
if (next.candidates.length + next.failed.length === 0) {
createdHere = null
}
publish(next)
if (next.candidates.length === 0 && next.failed.length === 0 && run === null) {
consumeNativeChatResumeOnRestartDialogRequest()
}
}
function setRun(next: ResumeRun | null): void {
if (!current()) {
return
}
run = next
const pending = resumeRunPendingIds(next)
// Same ids, same array, so readers of `resuming` re-render only when it changes.
resuming = pending.join('\0') === resuming.join('\0') ? resuming : pending
emit()
}
// Re-reads once an offered or failed chat shows new activity. Skipped while an action is in
// flight: that action ends by publishing the host's answer, which a racing re-read must not undo.
const offeredChatWatch = createOfferedChatWatch({
feed: () => getStructuredAgentSessionStatusFeed(target),
offeredIds: () =>
new Set([...offer.candidates, ...offer.failed].map((entry) => entry.sessionId)),
listedAt: () => offer.listedAt,
refresh: () => {
if (current() && actionsBegun === actionsSettled) {
const issued = actionsBegun
void readNativeChatRestartOffer(() => actionsBegun === issued)
}
}
})
/** A finished summary must be shown before closing can retire it. */
function markFinishedNativeChatRestartRunShown(shown: ResumeRun): void {
if (run === shown && !shown.inFlight && !shown.finishedViewShown) {
setRun({ ...shown, finishedViewShown: true })
}
}
function releaseFinishedNativeChatRestartRun(): void {
if (run?.finishedViewShown && !resumeRunInFlight(run)) {
setRun(null)
}
}
/**
* Re-reads the host's answer.
*
* Called before the dialog is reopened, so a count can never name a chat the host would now refuse.
*/
async function readNativeChatRestartOffer(
allowed = () => true,
refreshAttribution = false
): Promise<HostOfferRead> {
const issued = actionsBegun
const settled = actionsSettled
const reading = ++readSequence
const mayPublish = () =>
current() &&
allowed() &&
reading === readSequence &&
issued === actionsBegun &&
settled === actionsSettled
if (!mayPublish()) {
return { ...EMPTY, available: false }
}
const read = await readRestartHostOffer(target)
if (!mayPublish()) {
return { ...EMPTY, available: false }
}
if (!read.available) {
return read
}
const ids = await readDesktopCreatedChatIds(target, read, createdHere, refreshAttribution)
if (mayPublish()) {
createdHere = ids
publishAnswer({ candidates: read.candidates, failed: read.failed, listedAt: Date.now() })
}
return mayPublish() ? read : { ...EMPTY, available: false }
}
async function refreshNativeChatRestartOffer(): Promise<void> {
await readNativeChatRestartOffer()
}
/** The status bar entry and the toast's action: re-read, then open only over rows, since the dialog
* draws nothing without them. Opening a chat from it is read-only and keeps the offer. */
async function reopenNativeChatRestartOffer(): Promise<void> {
if (!current()) {
return
}
// Mid-resume the host's answer is already on its way; a re-read racing it could undo it.
if (resuming.length === 0) {
await readNativeChatRestartOffer()
}
if (current() && (run !== null || offer.candidates.length > 0 || offer.failed.length > 0)) {
requestNativeChatResumeOnRestartDialog()
}
}
/** One host action owns the selection; its status feed reports each chat before the reply. */
async function continueNativeChatRestartOffer(
sessionIds: readonly string[],
startedBy: ResumeRunStarter = 'user'
): Promise<void> {
const requested = [...new Set(sessionIds)]
if (!current() || requested.length === 0) {
return
}
const issued = ++actionsBegun
const actionCurrent = () => current() && actionsBegun === issued
forgetUnsentResumes(requested)
const rows = new Map([...offer.candidates, ...offer.failed].map((row) => [row.sessionId, row]))
let actionRun = beginResumeRun(
requested.flatMap((sessionId) => rows.get(sessionId) ?? []),
Date.now(),
startedBy
)
setRun(actionRun)
releaseRunStatus?.()
const release = watchResumeRunStatus(
() => (actionCurrent() && run === actionRun ? actionRun : null),
(next) => {
actionRun = next
setRun(next)
},
target
)
releaseRunStatus = release
if (target.kind === 'local') {
markNativeChatLaunchResumeDecided()
}
let outcomes: RestartContinuationBatchResult = {
attempted: [],
continued: [],
skipped: new Set()
}
try {
outcomes = await continueRestartChunks(target, requested, {
current: actionCurrent,
publish: (answer) => {
if (actionCurrent()) {
publishAnswer(answer)
}
},
refresh: readNativeChatRestartOffer,
failures: () => offer.failed,
markUnsent: (ids, at) => {
markUnsentResumes(ids, at)
publishAnswer(offer)
}
})
} finally {
release()
if (current()) {
if (releaseRunStatus === release) {
releaseRunStatus = undefined
}
actionsSettled += 1
const attemptedIds = new Set(outcomes.attempted)
if (run === actionRun) {
setRun({
...actionRun,
entries: actionRun.entries.filter(
(entry) =>
attemptedIds.has(entry.candidate.sessionId) &&
!outcomes.skipped.has(entry.candidate.sessionId)
),
inFlight: false,
continued: outcomes.continued
})
}
}
}
if (actionCurrent()) {
announceRestartResults(
outcomes.attempted.filter((id) => !outcomes.skipped.has(id)),
outcomes.continued,
outcomes.failed,
reopenNativeChatRestartOffer
)
}
}
/**
* Turning the offer down for good, which explicitly deletes the pending durable records.
*
* A failed write or unreachable host leaves the durable record untouched. The re-read puts it back
* in the status bar; if that read fails too, the entry stays hidden until the next launch reads it.
*/
async function dismissNativeChatRestartOffer(sessionIds?: readonly string[]): Promise<void> {
if (!current()) {
return
}
if (target.kind !== 'local') {
consumeNativeChatResumeOnRestartDialogRequest()
releaseFinishedNativeChatRestartRun()
return
}
actionsBegun += 1
try {
const result = await dismissRestartHostOffer(target, sessionIds)
if (!current()) {
return
}
// Only a dismissal the host took ends the mark; a failed one leaves the chat shown as failed.
forgetUnsentResumes(sessionIds)
if (result) {
publishAnswer(result)
} else {
await refreshNativeChatRestartOffer()
}
} catch {
if (current()) {
await refreshNativeChatRestartOffer()
}
} finally {
if (current()) {
actionsSettled += 1
}
}
}
const discovery = createRemoteRestartOfferLifecycle({
read: (allowed) => readNativeChatRestartOffer(allowed, true),
requestDialog: () => {
if (current()) {
requestNativeChatResumeOnRestartDialog()
}
},
currentAction: () => actionsBegun
})
function dispose(): void {
live = false
discovery.dispose()
releaseRunStatus?.()
offeredChatWatch.release()
consumeNativeChatResumeOnRestartDialogRequest()
}
return {
getNativeChatRestartOffer: (): NativeChatRestartOffer => offer,
getNativeChatRestartResuming: (): readonly string[] => resuming,
getNativeChatRestartRun: (): ResumeRun | null => run,
markFinishedNativeChatRestartRunShown,
releaseFinishedNativeChatRestartRun,
refreshNativeChatRestartOffer,
reopenNativeChatRestartOffer,
continueNativeChatRestartOffer,
dismissNativeChatRestartOffer,
identity,
dispose,
startedHere: (id: string) => createdHere?.has(id) === true,
observeRuntimeStatus: discovery.observeRuntimeStatus,
readNativeChatRestartOffer
}
}
@@ -0,0 +1,86 @@
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status'
import { remoteRestartRuntimeId } from './native-chat-remote-restart-offer'
import { createNativeChatRestartOccurrence } from './native-chat-restart-occurrence'
import { EMPTY } from './native-chat-restart-host-offer'
import {
resumeRunPendingIds,
type ResumeRun,
type ResumeRunStarter
} from './native-chat-resume-run'
import * as dialog from './native-chat-resume-on-restart-dialog'
const NO_RESUMING = resumeRunPendingIds(null)
/** Subscriptions survive server replacement; all recovery state and work belongs to its occurrence. */
export function createNativeChatRestartStore(target: RuntimeClientTarget) {
const key = target.kind === 'local' ? 'local' : target.environmentId
const consumeNativeChatResumeOnRestartDialogRequest = () =>
dialog.consumeNativeChatResumeOnRestartDialogRequest(key)
const getNativeChatResumeOnRestartDialogRequest = () =>
dialog.getNativeChatResumeOnRestartDialogRequest(key)
const subscribeNativeChatResumeOnRestartDialog = dialog.subscribeNativeChatResumeOnRestartDialog
const listeners = new Set<() => void>()
let disposed = false
let runtimeId: string | undefined
let occurrence = createNativeChatRestartOccurrence(target, key, listeners)
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
function dispose(): void {
disposed = true
occurrence.dispose()
listeners.forEach((listener) => listener())
}
return {
target,
subscribe,
dispose,
getNativeChatRestartOccurrence: () => occurrence,
getNativeChatRestartOffer: () => (disposed ? EMPTY : occurrence.getNativeChatRestartOffer()),
getNativeChatRestartResuming: () =>
disposed ? NO_RESUMING : occurrence.getNativeChatRestartResuming(),
getNativeChatRestartRun: () => (disposed ? null : occurrence.getNativeChatRestartRun()),
markFinishedNativeChatRestartRunShown: (run: ResumeRun) =>
occurrence.markFinishedNativeChatRestartRunShown(run),
releaseFinishedNativeChatRestartRun: () => occurrence.releaseFinishedNativeChatRestartRun(),
refreshNativeChatRestartOffer: () => occurrence.refreshNativeChatRestartOffer(),
reopenNativeChatRestartOffer: () => occurrence.reopenNativeChatRestartOffer(),
continueNativeChatRestartOffer: (ids: readonly string[], starter?: ResumeRunStarter) =>
occurrence.continueNativeChatRestartOffer(ids, starter),
dismissNativeChatRestartOffer: (ids?: readonly string[]) =>
occurrence.dismissNativeChatRestartOffer(ids),
readNativeChatRestartOffer: () => occurrence.readNativeChatRestartOffer(),
startedHere: (id: string) => !disposed && occurrence.startedHere(id),
consumeNativeChatResumeOnRestartDialogRequest: () => {
if (!disposed) {
consumeNativeChatResumeOnRestartDialogRequest()
}
},
getNativeChatResumeOnRestartDialogRequest,
subscribeNativeChatResumeOnRestartDialog,
observeRuntimeStatus: (status: RuntimeEnvironmentStatus | undefined) => {
if (disposed) {
return
}
const id = remoteRestartRuntimeId(status)
if (id && runtimeId !== id) {
occurrence.dispose()
runtimeId = id
occurrence = createNativeChatRestartOccurrence(target, key, listeners)
listeners.forEach((listener) => listener())
}
occurrence.observeRuntimeStatus(status)
},
_resetNativeChatRestartOffer: () => {
occurrence.dispose()
disposed = false
runtimeId = undefined
occurrence = createNativeChatRestartOccurrence(target, key, listeners)
listeners.clear()
}
}
}
@@ -0,0 +1,71 @@
import { useEffect, useSyncExternalStore } from 'react'
import { useAppStore } from '../store'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import {
createNativeChatRestartStore,
localNativeChatRestartStore,
type NativeChatRestartStore
} from './native-chat-resume-on-restart-store'
const stores = new Map<string, NativeChatRestartStore>()
const listeners = new Set<() => void>()
let revision = 0
const subscribe = (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
const empty: readonly string[] = []
const emptySnapshot = () => empty
const noSubscribe = () => () => {}
/** Consumers only subscribe; enrollment and discovery belong to the lifecycle effect. */
export function useNativeChatTargetRestartResuming(target: RuntimeClientTarget) {
const owner = () =>
target.kind === 'local' ? localNativeChatRestartStore : stores.get(target.environmentId)
const store = useSyncExternalStore(subscribe, owner, owner)
return useSyncExternalStore(
store?.subscribe ?? noSubscribe,
store?.getNativeChatRestartResuming ?? emptySnapshot,
emptySnapshot
)
}
export function useNativeChatRestartRuntimes() {
const environments = useAppStore((state) => state.runtimeEnvironments)
const statuses = useAppStore((state) => state.runtimeStatusByEnvironmentId)
useSyncExternalStore(
subscribe,
() => revision,
() => revision
)
useEffect(() => {
const enrolled = new Set(environments.map((env) => env.id))
let changed = false
for (const [id, store] of stores) {
if (!enrolled.has(id)) {
store.dispose()
stores.delete(id)
changed = true
}
}
for (const environment of environments) {
let store = stores.get(environment.id)
if (!store) {
store = createNativeChatRestartStore({ kind: 'environment', environmentId: environment.id })
stores.set(environment.id, store)
changed = true
}
store.observeRuntimeStatus(statuses.get(environment.id))
}
if (changed) {
revision++
listeners.forEach((listener) => listener())
}
}, [environments, statuses])
return environments.flatMap((environment) => {
const store = stores.get(environment.id)
return store ? [{ environment, store }] : []
})
}
@@ -8,7 +8,7 @@ import {
} from './native-chat-resume-modal.test-support'
import { act } from 'react'
import { expect, it, vi, type Mock } from 'vitest'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeRuntimeModal'
import { chatBox } from './native-chat-resume-on-restart-modal.test-support'
import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment'
import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping'
@@ -292,7 +292,7 @@ it.each(['unknown', 'pending'] as const)(
}
)
it('dismissing a failure after the reply never promotes it to resumed history', async () => {
it('dismissing a host failure preserves the uncertain action history', async () => {
fakeHost({ sessions: [offered[0]!] }, () => 'unknown')
await mount(<NativeChatResumeOnRestartModal />)
await act(async () => continueNativeChatRestartOffer(['a']))
@@ -301,8 +301,9 @@ it('dismissing a failure after the reply never promotes it to resumed history',
expect(dismiss).not.toBeNull()
await act(async () => dismiss?.click())
expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 0 of 1 chat')
expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Need you0')
expect(runStatus('Prompt a')).toBeNull()
expect(getNativeChatRestartOffer().failed).toEqual([])
expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Need you1')
expect(runStatus('Prompt a')).toBe('Prompt a: Couldn’t confirm the chat was resumed')
expect(toasts()).toEqual([['Couldn’t confirm 1 chat was resumed']])
})
@@ -315,7 +316,7 @@ it('keeps an unknown reply unconfirmed when it carries no confirmed failure list
await mount(<NativeChatResumeOnRestartModal />)
await act(async () => continueNativeChatRestartOffer(['a']))
expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Resumed 0 of 1 chat')
expect(runStatus('Prompt a')).toBeNull()
expect(runStatus('Prompt a')).toBe('Prompt a: Couldn’t confirm the chat was resumed')
expect(toasts()).toEqual([['Couldn’t confirm 1 chat was resumed']])
})
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { expect, it, vi, type Mock } from 'vitest'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeRuntimeModal'
import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment'
import {
createResumeModalFixture,
@@ -1,4 +1,4 @@
let pendingOpen = false
const pendingOpen = new Set<string>()
const listeners = new Set<() => void>()
function notify(): void {
@@ -8,21 +8,21 @@ function notify(): void {
}
// Keep opens as an external snapshot so requests arriving before subscription survive mount ordering.
export function requestNativeChatResumeOnRestartDialog(): void {
pendingOpen = true
export function requestNativeChatResumeOnRestartDialog(key = 'local'): void {
pendingOpen.add(key)
notify()
}
export function consumeNativeChatResumeOnRestartDialogRequest(): void {
if (!pendingOpen) {
export function consumeNativeChatResumeOnRestartDialogRequest(key = 'local'): void {
if (!pendingOpen.has(key)) {
return
}
pendingOpen = false
pendingOpen.delete(key)
notify()
}
export function getNativeChatResumeOnRestartDialogRequest(): boolean {
return pendingOpen
export function getNativeChatResumeOnRestartDialogRequest(key = 'local'): boolean {
return pendingOpen.values().next().value === key
}
export function subscribeNativeChatResumeOnRestartDialog(listener: () => void): () => void {
@@ -62,17 +62,28 @@ const resumes = {
launch: () => continueNativeChatRestartOffer(offered.map((candidate) => candidate.sessionId))
}
// An unavailable host cannot settle delivery; Review still opens the retained action summary.
it.each(['click', 'launch'] as const)(
'counts every chat of a lost %s resume when the host cannot be read either',
'keeps every chat of a lost %s resume unconfirmed when the host cannot be read either',
async (caller) => {
hostLostAfterListing()
await refreshNativeChatRestartOffer()
await resumes[caller]()
expect(vi.mocked(toast).mock.calls).toEqual([['2 chats couldn’t be resumed', {}]])
expect(vi.mocked(toast).mock.calls).toEqual([
[
'Couldn’t confirm 2 chats were resumed',
expect.objectContaining({ action: expect.objectContaining({ label: 'Review' }) })
]
])
expect(lastToastShow()).toBeDefined()
lastToastShow()?.()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true)
}
)
it('answers an opted-in launch that loses its resume request with one toast and Show', async () => {
// A fresh host offer is an observation, so the lost request still needs Review.
it('answers an opted-in launch that loses its resume request with one unconfirmed toast', async () => {
rpc.mockImplementation(async (_target, method) => {
if (method === 'agentSession.restartResumable') {
return { sessions: offered, failed: [] }
@@ -83,7 +94,7 @@ it('answers an opted-in launch that loses its resume request with one toast and
await resumes.launch()
expect(getNativeChatRestartOffer().failed.map((entry) => entry.sessionId)).toEqual(['a', 'b'])
expect(vi.mocked(toast).mock.calls.map(([title]) => title)).toEqual([
'2 chats couldn’t be resumed'
'Couldn’t confirm 2 chats were resumed'
])
expect(lastToastShow()).toBeDefined()
})
@@ -1,305 +1,67 @@
import { useEffect, useSyncExternalStore } from 'react'
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed'
import { RESTART_READ_RETRY_DELAYS_MS } from './native-chat-remote-restart-offer'
import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog'
import { useAppStore } from '../store'
import { markNativeChatLaunchResumeDecided } from './native-chat-launch-resume-decision'
import {
announceRestartResults,
restartContinuationHistory,
type RestartContinuationOutcome
} from './native-chat-restart-action-notifications'
import {
allResumeSessionIds,
type ResumeCandidate,
type ResumeFailure
} from './native-chat-resume-on-restart-grouping'
import {
beginResumeRun,
resumeRunInFlight,
resumeRunPendingIds,
type ResumeRun,
type ResumeRunStarter
} from './native-chat-resume-run'
import {
forgetUnsentResumes,
markUnsentResumes,
withUnsentResumes
} from './native-chat-resume-unsent-requests'
import {
consumeNativeChatResumeOnRestartDialogRequest,
requestNativeChatResumeOnRestartDialog
} from './native-chat-resume-on-restart-dialog'
import { createOfferedChatWatch } from './native-chat-resume-offered-chat-watch'
import { watchResumeRunStatus } from './native-chat-resume-run-status-watch'
import { allResumeSessionIds } from './native-chat-resume-on-restart-grouping'
import { useEffect, useSyncExternalStore } from 'react'
import { createNativeChatRestartStore } from './native-chat-restart-runtime-store'
export type { NativeChatRestartOffer } from './native-chat-restart-host-offer'
export { createNativeChatRestartStore } from './native-chat-restart-runtime-store'
export type NativeChatRestartStore = ReturnType<typeof createNativeChatRestartStore>
export const localNativeChatRestartStore = createNativeChatRestartStore({ kind: 'local' })
export const {
getNativeChatRestartOffer,
getNativeChatRestartResuming,
getNativeChatRestartRun,
markFinishedNativeChatRestartRunShown,
releaseFinishedNativeChatRestartRun,
refreshNativeChatRestartOffer,
reopenNativeChatRestartOffer,
continueNativeChatRestartOffer,
dismissNativeChatRestartOffer
} = localNativeChatRestartStore
/** Share the host’s durable offer across dialog/status bar; opening is read-only, while snooze and launch preference belong to this client. */
export function useNativeChatRestartOffer(enabled: boolean, store = localNativeChatRestartStore) {
useEffect(() => {
if (enabled && store.target.kind === 'local') {
launch ??= loadLaunchOffer(store).finally(markNativeChatLaunchResumeDecided)
}
}, [enabled, store])
return useSyncExternalStore(
store.subscribe,
store.getNativeChatRestartOffer,
store.getNativeChatRestartOffer
)
}
export function useNativeChatRestartResuming(store = localNativeChatRestartStore) {
return useSyncExternalStore(
store.subscribe,
store.getNativeChatRestartResuming,
store.getNativeChatRestartResuming
)
}
export function useNativeChatRestartRun(store = localNativeChatRestartStore) {
return useSyncExternalStore(
store.subscribe,
store.getNativeChatRestartRun,
store.getNativeChatRestartRun
)
}
// This store owns this machine’s restart offer; structured launch resolution keeps that runtime local.
const LOCAL = { kind: 'local' } as const
export type NativeChatRestartOffer = Readonly<{
candidates: readonly ResumeCandidate[]
/** Acted-on offers whose agent did not carry on, as the host still records them. */
failed: readonly ResumeFailure[]
/** Timestamp the received list so row ages remain stable and renders need not read Date.now(). */
listedAt: number
}>
const EMPTY: NativeChatRestartOffer = { candidates: [], failed: [], listedAt: 0 }
let offer: NativeChatRestartOffer = EMPTY
let launch: Promise<void> | undefined
/** Fence rereads against action replies so a racing list cannot preempt or undo the action’s authoritative result. */
let actionsBegun = 0
let actionsSettled = 0
/** Memory-only history: retires after viewing and closing its finished summary, or on replacement. */
let run: ResumeRun | null = null
let releaseRunStatus: (() => void) | undefined
let resuming: readonly string[] = resumeRunPendingIds(null)
const listeners = new Set<() => void>()
const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const
function emit(): void {
for (const listener of listeners) {
listener()
}
}
/** Replace snapshots only when publishing changes so external-store readers share stable references between notifications. */
function publish(next: NativeChatRestartOffer): void {
offer = next
offeredChatWatch.sync()
emit()
}
/** Confirmed empty answers retire dialog requests unless showing a run; failed reads hide rows but keep the request. */
function publishAnswer(answer: NativeChatRestartOffer): void {
const next = withUnsentResumes(answer)
publish(next)
if (next.candidates.length === 0 && next.failed.length === 0 && run === null) {
consumeNativeChatResumeOnRestartDialogRequest()
}
}
function setRun(next: ResumeRun | null): void {
run = next
const pending = resumeRunPendingIds(next)
// Same ids, same array, so readers of `resuming` re-render only when it changes.
resuming = pending.join('\0') === resuming.join('\0') ? resuming : pending
emit()
}
// Refresh on offered-chat activity only outside actions; a racing reread must not undo the action’s reply.
const offeredChatWatch = createOfferedChatWatch({
feed: () => getStructuredAgentSessionStatusFeed(LOCAL),
offeredIds: () => new Set([...offer.candidates, ...offer.failed].map((entry) => entry.sessionId)),
listedAt: () => offer.listedAt,
refresh: () => {
if (actionsBegun === actionsSettled) {
const issued = actionsBegun
void readNativeChatRestartOffer(() => actionsBegun === issued)
}
}
})
export function getNativeChatRestartOffer(): NativeChatRestartOffer {
return offer
}
export function getNativeChatRestartResuming(): readonly string[] {
return resuming
}
export function getNativeChatRestartRun(): ResumeRun | null {
return run
}
/** A finished summary must be shown before closing can retire it. */
export function markFinishedNativeChatRestartRunShown(shown: ResumeRun): void {
if (run === shown && !shown.inFlight && !shown.finishedViewShown) {
setRun({ ...shown, finishedViewShown: true })
}
}
export function releaseFinishedNativeChatRestartRun(): void {
if (run?.finishedViewShown && !resumeRunInFlight(run)) {
setRun(null)
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
type HostOfferRead = {
candidates: readonly ResumeCandidate[]
failed: readonly ResumeFailure[]
available: boolean
}
/** Older hosts may omit failed; treat absence as an empty failure list. */
type HostOfferPayload = { sessions?: unknown; failed?: unknown }
function failedFrom(payload: HostOfferPayload): ResumeFailure[] {
// SAFETY: the host is the single writer of this shape; a malformed row is a host bug, not input.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above.
return Array.isArray(payload.failed) ? (payload.failed as ResumeFailure[]) : []
}
async function readNativeChatRestartOffer(current = () => true): Promise<HostOfferRead> {
try {
const offered = await callStructuredAgentSession<HostOfferPayload>(
LOCAL,
'agentSession.restartResumable'
)
if (!Array.isArray(offered.sessions)) {
throw new Error('agent_session_restart_offer_invalid')
}
const failed = failedFrom(offered)
if (current()) {
publishAnswer({ candidates: offered.sessions, failed, listedAt: Date.now() })
}
return { candidates: offered.sessions, failed, available: true }
} catch {
// Hide unconfirmed rows on read failure without deleting durable offers; a later refresh may restore them.
if (current()) {
publish({ ...EMPTY, listedAt: Date.now() })
}
return { candidates: [], failed: [], available: false }
}
}
export async function refreshNativeChatRestartOffer(): Promise<void> {
await readNativeChatRestartOffer()
}
/** Reread before opening so the dialog exposes only current offers; opening a chat itself remains read-only. */
export async function reopenNativeChatRestartOffer(): Promise<void> {
// Mid-resume the host's answer is already on its way; a re-read racing it could undo it.
if (resuming.length === 0) {
await readNativeChatRestartOffer()
}
if (run !== null || offer.candidates.length > 0 || offer.failed.length > 0) {
requestNativeChatResumeOnRestartDialog()
}
}
/** One host action owns the selection; its status feed reports each chat before the reply. */
export async function continueNativeChatRestartOffer(
sessionIds: readonly string[],
startedBy: ResumeRunStarter = 'user'
): Promise<void> {
const requested = [...new Set(sessionIds)]
if (requested.length === 0) {
return
}
actionsBegun += 1
forgetUnsentResumes(requested)
const rows = new Map([...offer.candidates, ...offer.failed].map((row) => [row.sessionId, row]))
let actionRun = beginResumeRun(
requested.flatMap((sessionId) => rows.get(sessionId) ?? []),
Date.now(),
startedBy
)
setRun(actionRun)
releaseRunStatus?.()
const release = watchResumeRunStatus(
() => (run === actionRun ? actionRun : null),
(next) => {
actionRun = next
setRun(next)
}
)
releaseRunStatus = release
markNativeChatLaunchResumeDecided()
let outcome: Parameters<typeof announceRestartResults>
let continued: readonly RestartContinuationOutcome[] | undefined
let skipped = new Set<string>()
try {
const result = await callStructuredAgentSession<
HostOfferPayload & { continued?: RestartContinuationOutcome[]; skipped?: string[] }
>(LOCAL, 'agentSession.restartContinue', { sessionIds: requested })
const listed = Array.isArray(result.failed) ? failedFrom(result) : undefined
if (Array.isArray(result.sessions)) {
publishAnswer({ candidates: result.sessions, failed: listed ?? [], listedAt: Date.now() })
} else {
await refreshNativeChatRestartOffer()
}
skipped = new Set(result.skipped)
const actedOn = requested.filter((sessionId) => !skipped.has(sessionId))
continued = restartContinuationHistory(actedOn, result.continued, listed)
outcome = [actedOn, continued, listed, reopenNativeChatRestartOffer]
} catch (error) {
console.warn('[native-chat-resume] resume request failed before reaching the chats', error)
markUnsentResumes(requested, Date.now())
const read = await readNativeChatRestartOffer()
outcome = [
requested,
[],
read.available ? offer.failed : undefined,
reopenNativeChatRestartOffer
]
} finally {
release()
if (releaseRunStatus === release) {
releaseRunStatus = undefined
}
actionsSettled += 1
// Publish the offer first: reopening must never expose the old selection as actionable.
if (run === actionRun) {
setRun({
...actionRun,
entries: actionRun.entries.filter((entry) => !skipped.has(entry.candidate.sessionId)),
inFlight: false,
continued
})
}
}
announceRestartResults(...outcome)
}
/** Dismiss deletes durable offers only on host success; failed writes preserve records for a later reread or launch. */
export async function dismissNativeChatRestartOffer(sessionIds?: readonly string[]): Promise<void> {
actionsBegun += 1
try {
const result = await callStructuredAgentSession<HostOfferPayload>(
LOCAL,
'agentSession.restartResumableDismiss',
// Name failed rows explicitly, including locally lost requests the host still lists as offers.
sessionIds ? { sessionIds: [...sessionIds] } : {}
)
// Only a dismissal the host took ends the mark; a failed one leaves the chat shown as failed.
forgetUnsentResumes(sessionIds)
if (Array.isArray(result.sessions)) {
publishAnswer({
candidates: result.sessions,
failed: failedFrom(result),
listedAt: Date.now()
})
} else {
await refreshNativeChatRestartOffer()
}
} catch {
await refreshNativeChatRestartOffer()
} finally {
actionsSettled += 1
}
}
/** Read and decide once per launch; automatic resume uses the same action as Resume, while inspecting chats stays read-only. */
async function loadLaunchOffer(): Promise<void> {
async function loadLaunchOffer(store: NativeChatRestartStore): Promise<void> {
// The preference belongs to this launch's request; later saves cannot dispatch another.
const autoResume = useAppStore.getState().settings?.nativeChatResumeWorkOnRestart === true
let read = await readNativeChatRestartOffer()
// Retry startup read failures only, never confirmed empty offers, without adding steady-state polling.
for (const delay of LAUNCH_READ_RETRY_DELAYS_MS) {
let read = await store.readNativeChatRestartOffer()
// Host startup can race the renderer. Retry only failed reads, never a confirmed empty result,
// so a transient startup gap does not strand a durable offer or add steady-state polling.
for (const delay of RESTART_READ_RETRY_DELAYS_MS) {
if (read.available) {
break
}
await new Promise<void>((resolve) => setTimeout(resolve, delay))
read = await readNativeChatRestartOffer()
read = await store.readNativeChatRestartOffer()
}
const offered = read.candidates
// Failures left from an earlier launch are the status bar's to show; only a fresh offer asks.
@@ -310,39 +72,10 @@ async function loadLaunchOffer(): Promise<void> {
requestNativeChatResumeOnRestartDialog()
return
}
await continueNativeChatRestartOffer(allResumeSessionIds(offered), 'launch')
await store.continueNativeChatRestartOffer(allResumeSessionIds(offered), 'launch')
}
/** Load once when enabled; delayed settings must enable the initial read rather than lose it. */
export function useNativeChatRestartOffer(enabled: boolean): NativeChatRestartOffer {
useEffect(() => {
if (enabled) {
// Fetched after mount, never awaited by startup: the workspace is usable first.
launch ??= loadLaunchOffer().finally(markNativeChatLaunchResumeDecided)
}
}, [enabled])
return useSyncExternalStore(subscribe, getNativeChatRestartOffer, getNativeChatRestartOffer)
}
export function useNativeChatRestartResuming(): readonly string[] {
return useSyncExternalStore(subscribe, getNativeChatRestartResuming, getNativeChatRestartResuming)
}
export function useNativeChatRestartRun(): ResumeRun | null {
return useSyncExternalStore(subscribe, getNativeChatRestartRun, getNativeChatRestartRun)
}
/** @internal - tests need a clean module between cases. */
export function _resetNativeChatRestartOffer(): void {
releaseRunStatus?.()
releaseRunStatus = undefined
offeredChatWatch.release()
offer = EMPTY
forgetUnsentResumes(undefined)
run = null
resuming = resumeRunPendingIds(null)
actionsBegun = 0
actionsSettled = 0
launch = undefined
listeners.clear()
localNativeChatRestartStore._resetNativeChatRestartOffer()
}
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { expect, it, vi, type Mock } from 'vitest'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeRuntimeModal'
import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment'
import {
createResumeModalFixture,
@@ -152,7 +152,7 @@ it('reopens a collapsed result group with tree keys', async () => {
})
it('shows a settled nonretryable failure as a result with a Done action', async () => {
const failed = { ...failure('a'), outcome: 'unconfirmed' as const, retryable: false }
const failed = { ...failure('a'), outcome: 'refused' as const, retryable: false }
let acted = false
rpc.mockImplementation(async (_target, method) => {
if (method === 'agentSession.restartResumable') {
@@ -162,7 +162,7 @@ it('shows a settled nonretryable failure as a result with a Done action', async
return {
sessions: [],
failed: [failed],
continued: [{ sessionId: 'a', outcome: 'unknown' }]
continued: [{ sessionId: 'a', outcome: 'refused' }]
}
})
await mountSurfaces()
@@ -1,12 +1,14 @@
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed'
import { observeResumeRun, type ResumeRun } from './native-chat-resume-run'
/** Capture every frame, even while the dialog is closed or React batches renders. */
export function watchResumeRunStatus(
current: () => ResumeRun | null,
publish: (run: ResumeRun) => void
publish: (run: ResumeRun) => void,
target: RuntimeClientTarget = { kind: 'local' }
): () => void {
const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' })
const feed = getStructuredAgentSessionStatusFeed(target)
const notice = () => {
const run = current()
if (!run) {
@@ -29,6 +29,34 @@ export type ResumeRunView = {
const ORDER: Record<Category, number> = { attention: 0, 'in-progress': 1, resumed: 2, other: 3 }
/** A host observation cannot settle this action’s unresolved delivery. */
export function resumeRunFailures(run: ResumeRun | null, failed: readonly ResumeFailure[]) {
const unknown = new Set(
run?.inFlight
? []
: run?.continued
?.filter((entry) => entry.outcome === 'unknown' || entry.outcome === 'pending')
.map((entry) => entry.sessionId)
)
return new Map<string, ResumeFailure>([
...failed.map((failure) => [failure.sessionId, failure] as const),
...(run?.entries ?? [])
.filter((entry) => unknown.has(entry.candidate.sessionId))
.map(
({ candidate }) =>
[
candidate.sessionId,
{
...candidate,
failedAt: run?.startedAt ?? 0,
outcome: 'unconfirmed' as const,
reason: 'agent_session_restart_request_failed'
}
] as const
)
])
}
export function resumeRunView(
run: ResumeRun,
listed: readonly ResumeCandidate[],
@@ -74,6 +102,12 @@ export function resumeRunView(
placed.push({ row, category: 'attention', index: placed.length })
} else if (failure) {
placed.push({ row, category: 'attention', index: placed.length })
} else if (
!run.inFlight &&
['unknown', 'pending'].includes(continuedBySession.get(sessionId) ?? '')
) {
statusBySession.set(sessionId, { kind: 'unconfirmed' })
placed.push({ row, category: 'attention', index: placed.length })
} else if (listedById.has(sessionId)) {
placed.push({ row, category: 'other', index: placed.length })
}
@@ -118,7 +118,7 @@ describe('the dialog over a bulk action', () => {
expect(view.statusBySession.get('a')?.kind).toBe('in-flight')
})
it('withdrawn and dismissed failures leave Need you after the reply', () => {
it('keeps an unknown action in Need you after its host failure is withdrawn', () => {
const finished = {
...run,
inFlight: false,
@@ -132,8 +132,17 @@ describe('the dialog over a bulk action', () => {
)
expect(failed.rows).toEqual([failure])
const withdrawn = resumeRunView(finished, [], () => undefined, 'attention')
expect(withdrawn.rows).toEqual([])
expect(withdrawn.counts.attention).toBe(0)
expect(withdrawn.rows).toEqual([row('a')])
expect(withdrawn.counts.attention).toBe(1)
expect(withdrawn.statusBySession.get('a')).toEqual({ kind: 'unconfirmed' })
const refused = resumeRunView(
{ ...finished, continued: [{ sessionId: 'a', outcome: 'refused' }] },
[],
() => undefined,
'attention'
)
expect(refused.rows).toEqual([])
expect(refused.counts.attention).toBe(0)
})
it('retains successful history but always shows current host failures', () => {
@@ -8,7 +8,7 @@ import {
type RestartRpc,
type ResumeStatusStream
} from './native-chat-resume-modal.test-support'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal'
import { NativeChatResumeOnRestartModal } from './NativeChatResumeRuntimeModal'
import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment'
import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog'
import {
@@ -154,9 +154,13 @@ it.each(['returned', 'lost'] as const)(
)
).toHaveLength(2)
expect(document.querySelectorAll('button[aria-label^="Dismiss"]')).toHaveLength(2)
expect(runStatus('Prompt a')).toBe('Prompt a: Couldn’t resume')
expect(runStatus('Prompt a')).toBe(
delivery === 'lost'
? 'Prompt a: Couldn’t confirm the chat was resumed'
: 'Prompt a: Couldn’t resume'
)
if (delivery === 'lost') {
expect(toasts()).toEqual([['1 chat couldn’t be resumed']])
expect(toasts()).toEqual([['Couldn’t confirm 1 chat was resumed']])
}
} finally {
warn.mockRestore()
@@ -1,53 +1,59 @@
import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping'
// Record requests lost before host reservation locally; reconcile their failure marks with each authoritative host answer.
// A lost reply is not a delivery receipt; retain uncertainty until the host supplies current facts.
const unsentResumes = new Map<string, number>()
const UNSENT_RESUME_REASON = 'agent_session_restart_request_failed'
export function createUnsentResumes() {
const unsentResumes = new Map<string, number>()
const UNSENT_RESUME_REASON = 'agent_session_restart_request_failed'
type HostAnswer = { candidates: readonly ResumeCandidate[]; failed: readonly ResumeFailure[] }
type HostAnswer = { candidates: readonly ResumeCandidate[]; failed: readonly ResumeFailure[] }
export function markUnsentResumes(sessionIds: readonly string[], failedAt: number): void {
sessionIds.forEach((sessionId) => unsentResumes.set(sessionId, failedAt))
}
/** Retry, Dismiss and a launch's own resume each settle the chats they name afresh. */
export function forgetUnsentResumes(sessionIds: readonly string[] | undefined): void {
if (sessionIds === undefined) {
unsentResumes.clear()
return
function markUnsentResumes(sessionIds: readonly string[], failedAt: number): void {
sessionIds.forEach((sessionId) => unsentResumes.set(sessionId, failedAt))
}
sessionIds.forEach((sessionId) => unsentResumes.delete(sessionId))
}
/** Move locally unsent offers into failures; drop local marks when the host no longer offers the chat. */
export function withUnsentResumes<T extends HostAnswer>(answer: T): T {
const offered = new Set(answer.candidates.map((candidate) => candidate.sessionId))
for (const sessionId of unsentResumes.keys()) {
if (!offered.has(sessionId)) {
unsentResumes.delete(sessionId)
/** Retry, Dismiss and a launch's own resume each settle the chats they name afresh. */
function forgetUnsentResumes(sessionIds: readonly string[] | undefined): void {
if (sessionIds === undefined) {
unsentResumes.clear()
return
}
sessionIds.forEach((sessionId) => unsentResumes.delete(sessionId))
}
/** Move locally unsent offers into failures; drop local marks when the host no longer offers the chat. */
function withUnsentResumes<T extends HostAnswer>(answer: T): T {
const offered = new Set(answer.candidates.map((candidate) => candidate.sessionId))
for (const sessionId of unsentResumes.keys()) {
if (!offered.has(sessionId)) {
unsentResumes.delete(sessionId)
}
}
if (unsentResumes.size === 0) {
return answer
}
const unsent = answer.candidates.flatMap((candidate): ResumeFailure[] => {
const failedAt = unsentResumes.get(candidate.sessionId)
return failedAt === undefined
? []
: [
{
...candidate,
failedAt,
outcome: 'unconfirmed',
reason: UNSENT_RESUME_REASON,
retryable: true
}
]
})
return {
...answer,
candidates: answer.candidates.filter((candidate) => !unsentResumes.has(candidate.sessionId)),
failed: [...answer.failed, ...unsent]
}
}
if (unsentResumes.size === 0) {
return answer
}
const unsent = answer.candidates.flatMap((candidate): ResumeFailure[] => {
const failedAt = unsentResumes.get(candidate.sessionId)
return failedAt === undefined
? []
: [
{
...candidate,
failedAt,
outcome: 'refused',
reason: UNSENT_RESUME_REASON,
retryable: true
}
]
})
return {
...answer,
candidates: answer.candidates.filter((candidate) => !unsentResumes.has(candidate.sessionId)),
failed: [...answer.failed, ...unsent]
}
return { markUnsentResumes, forgetUnsentResumes, withUnsentResumes }
}
export const { markUnsentResumes, forgetUnsentResumes, withUnsentResumes } = createUnsentResumes()
@@ -43,8 +43,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({
vi.mock('@/runtime/structured-agent-session-host-capability', () => ({
useStructuredAgentSessionHostCapabilityState: () => mocks.capability
}))
vi.mock('../native-chat-resume-on-restart-store', () => ({
useNativeChatRestartResuming: () => mocks.resuming
vi.mock('../native-chat-restart-runtimes', () => ({
useNativeChatTargetRestartResuming: () => mocks.resuming
}))
vi.mock('../native-chat-launch-resume-decision', () => ({
useNativeChatLaunchResumePending: () => mocks.launchPending
@@ -27,7 +27,7 @@ import {
import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-continue-interrupted-capability'
import type { NativeChatOrcaStopView } from './native-chat-orca-stop-context'
import { useStructuredAgentSessionHostLabel } from './use-structured-agent-session-host-label'
import { useNativeChatRestartResuming } from '../native-chat-resume-on-restart-store'
import { useNativeChatTargetRestartResuming } from '../native-chat-restart-runtimes'
import { useNativeChatLaunchResumePending } from '../native-chat-launch-resume-decision'
import type { NativeChatComposerNoticeContent } from './native-chat-composer-notice'
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
@@ -74,7 +74,7 @@ export function useNativeChatInterruptedContinuation(input: {
)
// Suppress continuation during local launch resume; paired servers own their own chats.
const launchPending = useNativeChatLaunchResumePending() && target.kind === 'local'
const resuming = useNativeChatRestartResuming().includes(sessionId) || launchPending
const resuming = useNativeChatTargetRestartResuming(target).includes(sessionId) || launchPending
const cut = useMemo(
() => latestNativeChatOrcaStopCut(input.journalItems, input.submissions),
[input.journalItems, input.submissions]
@@ -11,7 +11,10 @@ import {
consumeNativeChatResumeOnRestartDialogRequest,
getNativeChatResumeOnRestartDialogRequest
} from '../native-chat-resume-on-restart-dialog'
import { _resetNativeChatRestartOffer } from '../native-chat-resume-on-restart-store'
import {
_resetNativeChatRestartOffer,
localNativeChatRestartStore
} from '../native-chat-resume-on-restart-store'
import { NativeChatResumeStatusSegment } from './NativeChatResumeStatusSegment'
const rpc = vi.hoisted(() => vi.fn())
@@ -247,4 +250,28 @@ describe('NativeChatResumeStatusSegment', () => {
expect(screen.getByRole('button').textContent).toContain('2')
expect(screen.queryByText('2 chats to resume')).toBeNull()
})
it('keeps run-only uncertainty reachable until its viewed summary is closed', async () => {
rpc.mockResolvedValue({ sessions: [candidates[0]], failed: [] })
await mount()
rpc.mockImplementation(async (_target, method) => {
if (method === 'agentSession.restartContinue') {
throw new Error('accepted reply lost')
}
return { sessions: [], failed: [] }
})
await act(async () => localNativeChatRestartStore.continueNativeChatRestartOffer(['a']))
const entry = screen.getByRole('button', {
name: '1 chat to check after resuming. Click for details.'
})
expect(screen.queryByText(/failed to resume/)).toBeNull()
await act(async () => entry.click())
expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true)
const run = localNativeChatRestartStore.getNativeChatRestartRun()!
act(() => {
localNativeChatRestartStore.markFinishedNativeChatRestartRunShown(run)
localNativeChatRestartStore.releaseFinishedNativeChatRestartRun()
})
expect(screen.queryByRole('button')).toBeNull()
})
})
@@ -3,12 +3,14 @@ import { useNativeChatRestartOfferEnabled } from '../native-chat-restart-offer-g
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import {
reopenNativeChatRestartOffer,
localNativeChatRestartStore,
useNativeChatRestartOffer,
useNativeChatRestartRun
useNativeChatRestartRun,
type NativeChatRestartStore
} from '../native-chat-resume-on-restart-store'
import { useNativeChatRestartRuntimes } from '../native-chat-restart-runtimes'
import { resumeRunInFlight } from '../native-chat-resume-run'
import { resumeRunView } from '../native-chat-resume-run-view'
import { resumeRunFailures, resumeRunView } from '../native-chat-resume-run-view'
// Keep offers and in-flight/results visible after dialog snooze; statusBarItems must not hide the remaining resume surface.
@@ -18,7 +20,9 @@ function Segment({
ariaLabel,
tooltip,
iconOnly,
count
count,
onOpen,
machineName
}: {
icon: React.ReactNode
label: string
@@ -26,22 +30,26 @@ function Segment({
tooltip: string
iconOnly: boolean
count: number
onOpen: () => Promise<void>
machineName?: string
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => void reopenNativeChatRestartOffer()}
onClick={() => void onOpen()}
className="inline-flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 hover:bg-accent/70"
aria-label={ariaLabel}
aria-label={machineName ? `${machineName}: ${ariaLabel}` : ariaLabel}
>
{icon}
<span className="text-[11px]">{iconOnly ? count : label}</span>
<span className="text-[11px]">
{iconOnly ? count : machineName ? `${machineName}: ${label}` : label}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{tooltip}
{machineName ? `${machineName}: ${tooltip}` : tooltip}
</TooltipContent>
</Tooltip>
)
@@ -165,11 +173,43 @@ export function NativeChatResumeStatusSegment({
iconOnly
}: {
iconOnly: boolean
}): React.JSX.Element {
const enabled = useNativeChatRestartOfferEnabled()
const runtimes = useNativeChatRestartRuntimes()
return (
<>
<NativeChatRuntimeResumeStatusSegment
iconOnly={iconOnly}
store={localNativeChatRestartStore}
offerEnabled={enabled}
/>
{runtimes.map(({ environment, store }) => (
<NativeChatRuntimeResumeStatusSegment
key={environment.id}
iconOnly={iconOnly}
store={store}
machineName={environment.name}
offerEnabled
/>
))}
</>
)
}
function NativeChatRuntimeResumeStatusSegment({
iconOnly,
store,
machineName,
offerEnabled
}: {
iconOnly: boolean
store: NativeChatRestartStore
machineName?: string
offerEnabled: boolean
}): React.JSX.Element | null {
const offerEnabled = useNativeChatRestartOfferEnabled()
const { candidates, failed } = useNativeChatRestartOffer(offerEnabled)
const run = useNativeChatRestartRun()
const failureBySession = new Map(failed.map((entry) => [entry.sessionId, entry]))
const { candidates, failed } = useNativeChatRestartOffer(offerEnabled, store)
const run = useNativeChatRestartRun(store)
const failureBySession = resumeRunFailures(run, failed)
const view = run ? resumeRunView(run, candidates, (id) => failureBySession.get(id), 'all') : null
if (!offerEnabled) {
return null
@@ -178,7 +218,7 @@ export function NativeChatResumeStatusSegment({
// Count the selection once until the action publishes the host's remaining list.
const running = run !== null && resumeRunInFlight(run)
const inRun = new Set(running ? run.entries.map((entry) => entry.candidate.sessionId) : [])
const waiting = failed.filter((failure) => !inRun.has(failure.sessionId))
const waiting = [...failureBySession.values()].filter((failure) => !inRun.has(failure.sessionId))
const pending = candidates.filter((candidate) => !inRun.has(candidate.sessionId)).length
const failures = waiting.length
// An unconfirmed chat may be working, so "failed" would invite a duplicate "continue".
@@ -195,6 +235,8 @@ export function NativeChatResumeStatusSegment({
<>
{running && (
<Segment
onOpen={store.reopenNativeChatRestartOffer}
machineName={machineName}
iconOnly={iconOnly}
count={view?.counts.total ?? 0}
icon={<Loader2 className="size-3 animate-spin text-muted-foreground" />}
@@ -203,6 +245,8 @@ export function NativeChatResumeStatusSegment({
)}
{pending > 0 && (
<Segment
onOpen={store.reopenNativeChatRestartOffer}
machineName={machineName}
iconOnly={iconOnly}
count={pending}
icon={<RotateCcw className="size-3 text-muted-foreground" />}
@@ -239,6 +283,8 @@ export function NativeChatResumeStatusSegment({
{failures > 0 && (
// Report failed outcomes separately from the remaining offer, using the shared failure-warning tone.
<Segment
onOpen={store.reopenNativeChatRestartOffer}
machineName={machineName}
iconOnly={iconOnly}
count={failures}
icon={<AlertCircle className="size-3 text-status-warning" />}
@@ -247,6 +293,8 @@ export function NativeChatResumeStatusSegment({
)}
{resumed > 0 && (
<Segment
onOpen={store.reopenNativeChatRestartOffer}
machineName={machineName}
iconOnly={iconOnly}
count={resumed}
icon={<CheckCircle2 className="size-3 text-status-success" />}
+4
View File
@@ -73,6 +73,10 @@
"af6e02c44a": "page",
"f5a6b38a14": "sheet"
},
"NativeChatResumeOnRestartModal": {
"serverResumeHere": "Resume {{count}} chats started here",
"serverResumeHereOne": "Resume 1 chat started here"
},
"NewWorkspaceComposerCard": {
"0e587e31fb": "yaml",
"2132b670da": "both",
+6
View File
@@ -2383,6 +2383,12 @@
"9b40d7b018": "Copy"
},
"NativeChatResumeOnRestartModal": {
"serverTitle": "{{machine}} restarted",
"serverBody": "These chats were working when Orca on {{machine}} restarted. Resuming restores each one where it stopped and asks the agent to carry on. Your own prompt is not re-sent.",
"serverDismiss": "Dismiss",
"serverResumeAll": "Resume all {{count}} on {{machine}}",
"serverResumeHereOne": "Resume 1 chat started here",
"serverResumeHere": "Resume {{count}} chats started here",
"title": "Resume interrupted chats?",
"body": "These chats were working when Orca closed. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.",
"updateBody": "These chats were working when Orca installed an update. Resuming restores each one where it stopped, with its full context, and asks the agent to check what it was doing before carrying on. Your own prompt is not re-sent.",
@@ -3,10 +3,22 @@ import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../../shared/protocol-version'
import { createNativeChatRestartStore } from '../components/native-chat-restart-runtime-store'
import { toast } from 'sonner'
import { callRuntimeRpc, clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision'
import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture'
vi.mock('./structured-agent-session-status-feed', () => ({
getStructuredAgentSessionStatusFeed: () => ({
activate: () => () => {},
subscribe: () => () => {},
getSnapshot: () => new Map()
})
}))
vi.mock('sonner', async () =>
(await import('../components/native-chat-resume-sonner-mock.test-support')).resumeSonnerMock()
)
const runtimeEnvironmentCall = vi.fn()
beforeEach(() => {
@@ -64,3 +76,86 @@ it('captures the pairing revision before awaiting the compatibility probe', asyn
expectedEnvironmentPairingRevision: 10
})
})
it.each([true, false])(
'checks occurrence authority after real compatibility preparation (retired: %s)',
async (retired) => {
const store = createNativeChatRestartStore({ kind: 'environment', environmentId: 'env-cas' })
const status = Promise.withResolvers<unknown>()
let holdCompatibility = false
const candidate = {
sessionId: 'a',
workspaceId: 'workspace',
agent: 'codex',
trigger: 'update',
latestPrompt: 'Fix it',
recordedAt: 1
}
const response = (method: string, result: unknown) => ({
id: method,
ok: true,
result,
_meta: { runtimeId: 'r1' }
})
runtimeEnvironmentCall.mockImplementation(async ({ method, params }) => {
if (method === 'status.get') {
return holdCompatibility
? status.promise
: response(method, {
runtimeId: 'r1',
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
})
}
return response(
method,
method === 'agentSession.restartResumable'
? { sessions: [candidate], failed: [] }
: {
sessions: [],
failed: [],
continued: params.sessionIds.map((sessionId: string) => ({
sessionId,
outcome: 'continued'
}))
}
)
})
try {
await store.readNativeChatRestartOffer()
clearRuntimeCompatibilityCacheForTests()
holdCompatibility = true
vi.mocked(toast).mockClear()
const action = store.continueNativeChatRestartOffer(['a'])
await vi.waitFor(() =>
expect(
runtimeEnvironmentCall.mock.calls.filter(([args]) => args.method === 'status.get')
).toHaveLength(2)
)
if (retired) {
store.dispose()
}
status.resolve(
response('status.get', {
runtimeId: 'r1',
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
})
)
await action
const sent = runtimeEnvironmentCall.mock.calls.filter(
([args]) => args.method === 'agentSession.restartContinue'
)
expect(sent).toHaveLength(retired ? 0 : 1)
if (retired) {
expect(toast).not.toHaveBeenCalled()
} else {
expect(sent[0][0].params).toEqual({ sessionIds: ['a'] })
}
} finally {
store.dispose()
}
}
)
@@ -144,6 +144,21 @@ describe('callStructuredAgentSession timeouts', () => {
expect(options.timeoutMs).toBeGreaterThan(80_000)
})
it('allows restart dispatch settlement while preserving dispatch options', async () => {
const params = { sessionIds: ['a'] }
const options = { skipCompatibilityCheck: true as const, beforeSend: vi.fn() }
await callStructuredAgentSession(target, 'agentSession.restartContinue', params, options)
expect(mocks.call).toHaveBeenCalledExactlyOnceWith(
target,
'agentSession.restartContinue',
params,
{
timeoutMs: 600_000,
...options
}
)
})
it('leaves other reads on the default timeout', async () => {
await callStructuredAgentSession(target, 'agentSession.options', { sessionId: 'session-1' })
expect(mocks.call).toHaveBeenCalledExactlyOnceWith(target, 'agentSession.options', {
@@ -134,7 +134,9 @@ const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap<string, number> =
['agentSession.rewind', 195_000],
// Waiting model catalogs can take Claude’s 60s listing plus 15s account switch and 5s login environment.
['agentSession.modelCatalog', 90_000],
['agentSession.sleepWorkspace', 120_000]
['agentSession.sleepWorkspace', 120_000],
// All bounded-concurrency provider starts and dispatch settlements precede the reply.
['agentSession.restartContinue', 600_000]
])
export async function callStructuredAgentSession<TResult>(
@@ -142,7 +144,7 @@ export async function callStructuredAgentSession<TResult>(
method: string,
params?: unknown,
/** For a caller that checked the remote host's compatibility itself, just before. */
options: { skipCompatibilityCheck?: true } = {}
options: { skipCompatibilityCheck?: true; beforeSend?: () => void } = {}
): Promise<TResult> {
if (
method === 'agentSession.rewind' &&
@@ -155,7 +157,7 @@ export async function callStructuredAgentSession<TResult>(
throw new Error('Rewinding requires a newer Orca server. Update the server and try again.')
}
const timeoutMs = STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS.get(method)
return timeoutMs === undefined && !options.skipCompatibilityCheck
return timeoutMs === undefined && !options.skipCompatibilityCheck && !options.beforeSend
? callRuntimeRpc<TResult>(target, method, params)
: callRuntimeRpc<TResult>(target, method, params, {
...(timeoutMs === undefined ? {} : { timeoutMs }),
+74 -4
View File
@@ -394,6 +394,68 @@ describe('sendRemoteRuntimeRequest', () => {
})
})
it.each(['refused', 'timeout'])(
'observes a slow restart response finitely (%s)',
async (mode) => {
const observed = Promise.withResolvers<void>()
const requests = vi.fn((_request: Record<string, unknown>) => observed.resolve())
const close = vi.spyOn(WebSocketClient.prototype, 'close')
const server = await createOneShotServer({
onRequest: requests,
keepalive: false,
responseDelayMs: mode === 'refused' ? 20_000 : null,
response: (id) => ({
id,
ok: true,
result: { continued: [{ sessionId: 'a', outcome: 'refused' }] },
_meta: { runtimeId: 'runtime-test' }
})
})
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
const timers = vi.spyOn(globalThis, 'setTimeout')
const clearTimer = vi.spyOn(globalThis, 'clearTimeout')
try {
const settled = vi.fn()
const request = sendRemoteRuntimeRequest(
server.pairing,
'agentSession.restartContinue',
{ sessionIds: ['a'] },
600_000
)
const result = request.catch((error: unknown) => error)
void result.then(settled)
await observed.promise
await vi.advanceTimersByTimeAsync(15_001)
expect(settled).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(mode === 'refused' ? 4_999 : 584_998)
if (mode === 'refused') {
await expect(result).resolves.toMatchObject({
ok: true,
result: { continued: [{ sessionId: 'a', outcome: 'refused' }] }
})
} else {
expect(settled).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await expect(result).resolves.toMatchObject({ code: 'runtime_timeout' })
}
expect(requests).toHaveBeenCalledOnce()
expect(requests.mock.calls[0]?.[0]).toMatchObject({
method: 'agentSession.restartContinue',
params: { sessionIds: ['a'] }
})
expect(close).toHaveBeenCalled()
const timeoutIndex = timers.mock.calls.findIndex(([, delay]) => delay === 600_000)
expect(timeoutIndex).toBeGreaterThanOrEqual(0)
expect(clearTimer).toHaveBeenCalledWith(timers.mock.results[timeoutIndex].value)
} finally {
timers.mockRestore()
clearTimer.mockRestore()
vi.useRealTimers()
close.mockRestore()
}
}
)
it('aborts and closes an in-flight one-shot socket', async () => {
let requestObserved: () => void = () => {}
const observed = new Promise<void>((resolve) => {
@@ -687,6 +749,8 @@ async function createOneShotServer(
onAuth?: (auth: Record<string, unknown>) => void
onRequest?: (request: Record<string, unknown>) => void
sendUndecryptableResponse?: boolean
keepalive?: boolean
responseDelayMs?: number | null
} = {}
): Promise<{ pairing: PairedRuntimeConnection }> {
const serverKeyPair = generateKeyPair()
@@ -729,10 +793,16 @@ async function createOneShotServer(
ws.send('not-an-encrypted-frame')
return
}
if (options.responseDelayMs === null) {
return
}
const key = sharedKey
const keepalive = setInterval(() => {
sendEncrypted(ws, key, { _keepalive: true })
}, 100)
const keepalive =
options.keepalive === false
? undefined
: setInterval(() => {
sendEncrypted(ws, key, { _keepalive: true })
}, 100)
ws.once('close', () => clearInterval(keepalive))
setTimeout(() => {
clearInterval(keepalive)
@@ -746,7 +816,7 @@ async function createOneShotServer(
_meta: { runtimeId: 'runtime-test' }
}
)
}, 550)
}, options.responseDelayMs ?? 550)
})
})