Files
hapi/cli/src/agent/sessionFactory.test.ts
T
1cd4d1137a feat(hub,cli,web): fleet runner version governance (skew, self-upgrade, soft-fail reopen) (#1108)
* fix(hub): govern runner capabilities so Cursor reopen soft-fails on skew

Hub↔runner protocol drift was reported as missing Cursor chat data when
cursor-chat-store-status was unregistered. Soft-fail reopen on probe errors,
advertise required machine capabilities, surface an unmissable upgrade banner,
and stop-runner when a newer CLI binary is already on disk.

Fixes #1084

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web,hub): make runner skew banner dismissible; gate auto-upgrade

Compact the out-of-date banner (minimize + 1h snooze + per-host Restart)
so it no longer blocks the session list. Auto stop-runner on skew stays
opt-in via HAPI_AUTO_UPGRADE_RUNNERS / autoUpgradeRunners (default off).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): tolerate full sessionStorage on skew banner minimize

QuotaExceededError from setItem aborted minimize before React state
updated, leaving the banner stuck over the session list. Persist to
memory when storage fails; only enable Restart when a newer CLI is
already on disk; clarify opt-in is stop-runner only, not package push.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): drop redundant autoUpgradeRunners; runners already self-restart

CLI version handoff already reloads the runner when the on-disk binary
mtime changes. Hub-driven stop-runner on skew duplicated that. Keep the
skew banner and manual Restart only as a stuck/disabled-handoff escape.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli,hub,web): runner-only caps ads; gate Restart on supervisor

Address #1108 bot Majors on the thin tip: terminal/lazy bootstraps no
longer merge CURRENT_MACHINE_CAPABILITIES into the machine row (only
asRunner registration does). Banner Restart refuses unsupervised hosts
so stop-runner cannot leave a detached laptop offline; supervised
runners advertise supervisedRestart via HAPI_RUNNER_SUPERVISED=1.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub,cli,web): clear sticky runner ads; docs SUPERVISED; i18n skew label

Omit-means-clear on runner registration so rollback cannot leave
supervisedRestart/capabilities sticky; always advertise boolean
supervisedRestart from asRunner. Document HAPI_RUNNER_SUPERVISED=1
and localize MachineSelector UPDATE REQUIRED.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:24:39 +08:00

405 lines
14 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Session } from '@/api/types'
const {
getSessionMock,
getOrCreateSessionMock,
getOrCreateMachineMock,
sessionSyncClientMock,
notifyRunnerSessionStartedMock,
readSettingsMock
} = vi.hoisted(() => ({
getSessionMock: vi.fn(),
getOrCreateSessionMock: vi.fn(),
getOrCreateMachineMock: vi.fn(),
sessionSyncClientMock: vi.fn(),
notifyRunnerSessionStartedMock: vi.fn(async () => ({})),
readSettingsMock: vi.fn()
}))
vi.mock('@/api/api', () => ({
ApiClient: {
create: async () => ({
getSession: getSessionMock,
getOrCreateSession: getOrCreateSessionMock,
getOrCreateMachine: getOrCreateMachineMock,
sessionSyncClient: sessionSyncClientMock
})
}
}))
vi.mock('@/runner/controlClient', () => ({
notifyRunnerSessionStarted: notifyRunnerSessionStartedMock,
getInstalledCliMtimeMs: () => 1_700_000_000_000,
}))
vi.mock('@/persistence', () => ({
readSettings: readSettingsMock
}))
vi.mock('@/configuration', () => ({
configuration: {
happyHomeDir: '/tmp/.hapi',
logsDir: '/tmp/.hapi/logs',
isRunnerProcess: false
}
}))
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn()
}
}))
import {
HAPI_SESSION_ID_ENV,
bootstrapExistingSession,
bootstrapLazySession,
bootstrapSession,
buildMachineMetadata,
buildSessionMetadata
} from './sessionFactory'
function createSession(): Session {
return {
id: 'hapi-session-1',
namespace: 'default',
seq: 1,
createdAt: 1,
updatedAt: 1,
active: false,
activeAt: 1,
metadata: {
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
metadataVersion: 1,
agentState: { controlledByUser: false },
agentStateVersion: 1,
thinking: false,
thinkingAt: 1,
todos: [],
model: null,
modelReasoningEffort: null,
effort: null,
serviceTier: null,
permissionMode: undefined,
collaborationMode: undefined
}
}
describe('bootstrapExistingSession', () => {
beforeEach(() => {
getSessionMock.mockReset()
getOrCreateSessionMock.mockReset()
getOrCreateMachineMock.mockReset()
sessionSyncClientMock.mockReset()
notifyRunnerSessionStartedMock.mockClear()
readSettingsMock.mockReset()
delete process.env[HAPI_SESSION_ID_ENV]
})
it('loads an existing HAPI session and reports it to the runner', async () => {
const session = createSession()
const sessionClient = {
updateMetadata: vi.fn()
}
getSessionMock.mockResolvedValue(session)
getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' })
sessionSyncClientMock.mockReturnValue(sessionClient)
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
const result = await bootstrapExistingSession({
sessionId: 'hapi-session-1',
flavor: 'codex',
workingDirectory: '/tmp/project'
})
expect(result.sessionInfo.id).toBe('hapi-session-1')
expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hapi-session-1')
expect(result.workingDirectory).toBe('/tmp/project')
expect(sessionSyncClientMock).toHaveBeenCalledWith(session)
expect(sessionClient.updateMetadata).toHaveBeenCalledOnce()
expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
'hapi-session-1',
expect.objectContaining({
path: '/tmp/project',
flavor: 'codex',
startedBy: 'terminal',
startedFromRunner: false,
machineId: 'machine-1'
})
)
})
it('preserves existing native resume metadata when reactivating a session', async () => {
const session = createSession()
const existingMetadata = session.metadata
if (!existingMetadata) throw new Error('expected test session metadata')
session.metadata = {
...existingMetadata,
claudeSessionId: 'claude-thread-1',
codexSessionId: 'codex-thread-1',
geminiSessionId: 'gemini-thread-1',
opencodeSessionId: 'opencode-thread-1',
grokSessionId: 'grok-thread-1',
cursorSessionId: 'cursor-thread-1',
cursorSessionProtocol: 'acp',
piSessionId: 'pi-thread-1',
piResumeAttempt: {
state: 'resuming',
machineId: 'machine-1',
startedAt: 123,
},
ptyResumeAttempt: {
state: 'quarantined',
machineId: 'machine-1',
startedAt: 456,
},
summary: {
text: 'resume me',
updatedAt: 100
},
tools: ['read_file'],
slashCommands: ['/compact'],
conversationHistoryPoints: { 'local-user-1': true },
conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' },
capabilities: {
terminal: true,
conversationHistory: { forkCurrent: true }
}
}
const sessionClient = {
updateMetadata: vi.fn()
}
getSessionMock.mockResolvedValue(session)
getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' })
sessionSyncClientMock.mockReturnValue(sessionClient)
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
const result = await bootstrapExistingSession({
sessionId: 'hapi-session-1',
flavor: 'codex',
workingDirectory: '/tmp/project'
})
expect(result.metadata).toEqual(expect.objectContaining({
claudeSessionId: 'claude-thread-1',
codexSessionId: 'codex-thread-1',
geminiSessionId: 'gemini-thread-1',
opencodeSessionId: 'opencode-thread-1',
grokSessionId: 'grok-thread-1',
cursorSessionId: 'cursor-thread-1',
cursorSessionProtocol: 'acp',
piSessionId: 'pi-thread-1',
piResumeAttempt: {
state: 'resuming',
machineId: 'machine-1',
startedAt: 123,
},
ptyResumeAttempt: {
state: 'quarantined',
machineId: 'machine-1',
startedAt: 456,
},
summary: {
text: 'resume me',
updatedAt: 100
},
tools: ['read_file'],
slashCommands: ['/compact'],
conversationHistoryPoints: { 'local-user-1': true },
conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' },
capabilities: {
terminal: true,
conversationHistory: { forkCurrent: true }
}
}))
expect(sessionClient.updateMetadata).toHaveBeenCalledOnce()
const updateHandler = sessionClient.updateMetadata.mock.calls[0][0]
expect(updateHandler(session.metadata)).toEqual(expect.objectContaining({
codexSessionId: 'codex-thread-1',
grokSessionId: 'grok-thread-1',
conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' }
}))
expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
'hapi-session-1',
expect.objectContaining({
codexSessionId: 'codex-thread-1',
grokSessionId: 'grok-thread-1',
conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' }
})
)
})
it('advertises remote terminal capability in session metadata', () => {
const metadata = buildSessionMetadata({
flavor: 'codex',
startedBy: 'terminal',
workingDirectory: '/tmp/project',
machineId: 'machine-1',
now: 123
})
expect(metadata.capabilities?.terminal).toBe(true)
})
})
describe('bootstrapLazySession', () => {
beforeEach(() => {
getOrCreateSessionMock.mockReset()
getOrCreateMachineMock.mockReset()
sessionSyncClientMock.mockReset()
notifyRunnerSessionStartedMock.mockClear()
readSettingsMock.mockReset()
delete process.env[HAPI_SESSION_ID_ENV]
})
it('does not export HAPI_SESSION_ID until the hub row is materialized', async () => {
const pendingClient = { isPending: () => true }
sessionSyncClientMock.mockReturnValue(pendingClient)
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
const result = await bootstrapLazySession({
flavor: 'codex',
startedBy: 'terminal',
workingDirectory: '/tmp/project',
agentState: { controlledByUser: false }
})
expect(process.env[HAPI_SESSION_ID_ENV]).toBeUndefined()
expect(result.sessionInfo.id).toMatch(/^[0-9a-f-]{36}$/)
const [, options] = sessionSyncClientMock.mock.calls[0]
const materialized = createSession()
materialized.id = result.sessionInfo.id
options.onMaterialized(materialized, {
metadata: result.metadata,
agentState: { controlledByUser: false }
})
expect(process.env[HAPI_SESSION_ID_ENV]).toBe(result.sessionInfo.id)
})
it('does not persist a machine or session until materialization', async () => {
const pendingClient = { isPending: () => true }
sessionSyncClientMock.mockReturnValue(pendingClient)
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
const result = await bootstrapLazySession({
flavor: 'codex',
startedBy: 'terminal',
workingDirectory: '/tmp/project',
agentState: { controlledByUser: false }
})
expect(result.session).toBe(pendingClient)
expect(getOrCreateMachineMock).not.toHaveBeenCalled()
expect(getOrCreateSessionMock).not.toHaveBeenCalled()
expect(notifyRunnerSessionStartedMock).not.toHaveBeenCalled()
const [provisional, options] = sessionSyncClientMock.mock.calls[0]
expect(provisional.id).toMatch(/^[0-9a-f-]{36}$/)
expect(provisional.metadata).toEqual(expect.objectContaining({
machineId: 'machine-1',
path: '/tmp/project',
flavor: 'codex'
}))
const materialized = createSession()
materialized.id = provisional.id
getOrCreateSessionMock.mockResolvedValue(materialized)
const snapshot = {
metadata: {
...provisional.metadata,
codexSessionId: 'codex-thread-1'
},
agentState: { controlledByUser: true }
}
await options.materialize(snapshot, new AbortController().signal)
expect(getOrCreateSessionMock).toHaveBeenCalledWith(expect.objectContaining({
id: provisional.id,
metadata: snapshot.metadata,
state: snapshot.agentState,
timeoutMs: 10_000,
machine: expect.objectContaining({ id: 'machine-1' })
}))
options.onMaterialized(materialized, snapshot)
expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
provisional.id,
expect.objectContaining({ codexSessionId: 'codex-thread-1' })
)
})
})
describe('bootstrapSession HAPI_SESSION_ID export', () => {
beforeEach(() => {
getOrCreateSessionMock.mockReset()
getOrCreateMachineMock.mockReset()
sessionSyncClientMock.mockReset()
notifyRunnerSessionStartedMock.mockClear()
readSettingsMock.mockReset()
delete process.env[HAPI_SESSION_ID_ENV]
})
it('exports the hub session id so spawned agents inherit it', async () => {
const session = createSession()
session.id = 'hub-session-42'
getOrCreateSessionMock.mockResolvedValue(session)
getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' })
sessionSyncClientMock.mockReturnValue({ isPending: () => false })
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
const result = await bootstrapSession({
flavor: 'claude',
workingDirectory: '/tmp/project'
})
expect(result.sessionInfo.id).toBe('hub-session-42')
expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hub-session-42')
})
})
describe('buildMachineMetadata runner-only capabilities', () => {
const originalSupervised = process.env.HAPI_RUNNER_SUPERVISED
afterEach(() => {
if (originalSupervised === undefined) {
delete process.env.HAPI_RUNNER_SUPERVISED
} else {
process.env.HAPI_RUNNER_SUPERVISED = originalSupervised
}
})
it('omits machine RPC capabilities for terminal bootstrap metadata', () => {
delete process.env.HAPI_RUNNER_SUPERVISED
const metadata = buildMachineMetadata()
expect(metadata.capabilities).toBeUndefined()
expect(metadata.startedCliMtimeMs).toBeUndefined()
expect(metadata.installedCliMtimeMs).toBeUndefined()
expect(metadata.supervisedRestart).toBeUndefined()
})
it('advertises capabilities and supervisedRestart only for asRunner', () => {
process.env.HAPI_RUNNER_SUPERVISED = '1'
const metadata = buildMachineMetadata({ asRunner: true, startedCliMtimeMs: 42 })
expect(metadata.capabilities).toEqual(expect.arrayContaining(['cursor-chat-store-status', 'stop-runner']))
expect(metadata.startedCliMtimeMs).toBe(42)
expect(metadata.installedCliMtimeMs).toBe(1_700_000_000_000)
expect(metadata.supervisedRestart).toBe(true)
})
it('always sends supervisedRestart boolean for asRunner so sticky true can clear', () => {
delete process.env.HAPI_RUNNER_SUPERVISED
const metadata = buildMachineMetadata({ asRunner: true })
expect(metadata.capabilities).toEqual(expect.arrayContaining(['stop-runner']))
expect(metadata.supervisedRestart).toBe(false)
})
})