feat: add hapi resume command (#647)

This commit is contained in:
leko
2026-05-20 06:18:42 +08:00
committed by GitHub
parent 79d919675e
commit 197f327590
29 changed files with 1777 additions and 93 deletions
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest'
import { registerLocalHandoffHandler } from './localHandoff'
describe('registerLocalHandoffHandler', () => {
it('registers handoff-local and schedules clean exit', async () => {
const handlers = new Map<string, (params?: unknown) => unknown>()
const rpcHandlerManager: Parameters<typeof registerLocalHandoffHandler>[0] = {
registerHandler: (method, handler) => {
handlers.set(method, handler as (params?: unknown) => unknown)
}
}
const lifecycle = {
setArchiveReason: vi.fn(),
setSessionEndReason: vi.fn(),
cleanupAndExit: vi.fn(async () => {})
}
registerLocalHandoffHandler(rpcHandlerManager, lifecycle)
const handler = handlers.get('handoff-local')
expect(handler).toBeDefined()
expect(await handler?.()).toEqual({ ok: true })
await new Promise((resolve) => setImmediate(resolve))
expect(lifecycle.setArchiveReason).toHaveBeenCalledWith('Handed off to local terminal')
expect(lifecycle.setSessionEndReason).toHaveBeenCalledWith('handoff')
expect(lifecycle.cleanupAndExit).toHaveBeenCalledWith(0)
})
})
+28
View File
@@ -0,0 +1,28 @@
import type { SessionEndReason } from '@hapi/protocol'
type RpcHandlerManagerLike = {
registerHandler<TRequest = unknown, TResponse = unknown>(
method: string,
handler: (params: TRequest) => Promise<TResponse> | TResponse
): void
}
type LocalHandoffLifecycle = {
setArchiveReason: (reason: string) => void
setSessionEndReason: (reason: SessionEndReason) => void
cleanupAndExit: (codeOverride?: number) => Promise<void>
}
export function registerLocalHandoffHandler(
rpcHandlerManager: RpcHandlerManagerLike,
lifecycle: LocalHandoffLifecycle
): void {
rpcHandlerManager.registerHandler('handoff-local', () => {
lifecycle.setArchiveReason('Handed off to local terminal')
lifecycle.setSessionEndReason('handoff')
setImmediate(() => {
void lifecycle.cleanupAndExit(0)
})
return { ok: true }
})
}
+171 -18
View File
@@ -1,29 +1,182 @@
import { afterEach, describe, expect, it } from 'vitest'
import { buildSessionMetadata } from './sessionFactory'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Session } from '@/api/types'
describe('buildSessionMetadata', () => {
const originalHostname = process.env.HAPI_HOSTNAME
const {
getSessionMock,
getOrCreateMachineMock,
sessionSyncClientMock,
notifyRunnerSessionStartedMock,
readSettingsMock
} = vi.hoisted(() => ({
getSessionMock: vi.fn(),
getOrCreateMachineMock: vi.fn(),
sessionSyncClientMock: vi.fn(),
notifyRunnerSessionStartedMock: vi.fn(async () => ({})),
readSettingsMock: vi.fn()
}))
afterEach(() => {
if (originalHostname === undefined) {
delete process.env.HAPI_HOSTNAME
} else {
process.env.HAPI_HOSTNAME = originalHostname
}
vi.mock('@/api/api', () => ({
ApiClient: {
create: async () => ({
getSession: getSessionMock,
getOrCreateMachine: getOrCreateMachineMock,
sessionSyncClient: sessionSyncClientMock
})
}
}))
vi.mock('@/runner/controlClient', () => ({
notifyRunnerSessionStarted: notifyRunnerSessionStartedMock
}))
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 { bootstrapExistingSession, 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,
permissionMode: undefined,
collaborationMode: undefined
}
}
describe('bootstrapExistingSession', () => {
beforeEach(() => {
getSessionMock.mockReset()
getOrCreateMachineMock.mockReset()
sessionSyncClientMock.mockReset()
notifyRunnerSessionStartedMock.mockClear()
readSettingsMock.mockReset()
})
it('uses HAPI_HOSTNAME for session metadata host when provided', () => {
process.env.HAPI_HOSTNAME = 'custom-session-host'
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 metadata = buildSessionMetadata({
const result = await bootstrapExistingSession({
sessionId: 'hapi-session-1',
flavor: 'codex',
startedBy: 'terminal',
workingDirectory: '/tmp/project',
machineId: 'machine-1',
now: 123
workingDirectory: '/tmp/project'
})
expect(metadata.host).toBe('custom-session-host')
expect(result.sessionInfo.id).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',
cursorSessionId: 'cursor-thread-1',
summary: {
text: 'resume me',
updatedAt: 100
},
tools: ['read_file'],
slashCommands: ['/compact']
}
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',
cursorSessionId: 'cursor-thread-1',
summary: {
text: 'resume me',
updatedAt: 100
},
tools: ['read_file'],
slashCommands: ['/compact']
}))
expect(sessionClient.updateMetadata).toHaveBeenCalledOnce()
const updateHandler = sessionClient.updateMetadata.mock.calls[0][0]
expect(updateHandler(session.metadata)).toEqual(expect.objectContaining({
codexSessionId: 'codex-thread-1'
}))
expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
'hapi-session-1',
expect.objectContaining({
codexSessionId: 'codex-thread-1'
})
)
})
it('advertises remote terminal capability in session metadata', () => {
+69
View File
@@ -86,6 +86,25 @@ export function buildSessionMetadata(options: {
}
}
function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Partial<Metadata> {
if (!metadata) return {}
const preserved: Partial<Metadata> = {}
if (metadata.name !== undefined) preserved.name = metadata.name
if (metadata.summary !== undefined) preserved.summary = metadata.summary
if (metadata.claudeSessionId !== undefined) preserved.claudeSessionId = metadata.claudeSessionId
if (metadata.codexSessionId !== undefined) preserved.codexSessionId = metadata.codexSessionId
if (metadata.geminiSessionId !== undefined) preserved.geminiSessionId = metadata.geminiSessionId
if (metadata.opencodeSessionId !== undefined) preserved.opencodeSessionId = metadata.opencodeSessionId
if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId
if (metadata.tools !== undefined) preserved.tools = metadata.tools
if (metadata.slashCommands !== undefined) preserved.slashCommands = metadata.slashCommands
if (metadata.worktree !== undefined) preserved.worktree = metadata.worktree
return preserved
}
async function getMachineIdOrExit(): Promise<string> {
const settings = await readSettings()
const machineId = settings?.machineId
@@ -156,3 +175,53 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis
workingDirectory
}
}
export async function bootstrapExistingSession(options: {
sessionId: string
flavor: string
startedBy?: SessionStartedBy
workingDirectory: string
metadataOverrides?: Partial<Metadata>
}): Promise<SessionBootstrapResult> {
const startedBy = options.startedBy ?? 'terminal'
const api = await ApiClient.create()
const machineId = await getMachineIdOrExit()
await api.getOrCreateMachine({
machineId,
metadata: buildMachineMetadata()
})
const sessionInfo = await api.getSession(options.sessionId)
const baseMetadata = buildSessionMetadata({
flavor: options.flavor,
startedBy,
workingDirectory: options.workingDirectory,
machineId
})
const metadata = {
...baseMetadata,
...pickExistingSessionMetadata(sessionInfo.metadata),
...options.metadataOverrides
}
const buildUpdatedMetadata = (current: Metadata): Metadata => ({
...baseMetadata,
...pickExistingSessionMetadata(current),
...options.metadataOverrides
})
const session = api.sessionSyncClient(sessionInfo)
session.updateMetadata(buildUpdatedMetadata)
await reportSessionStarted(sessionInfo.id, metadata)
return {
api,
session,
sessionInfo,
metadata,
machineId,
startedBy,
workingDirectory: options.workingDirectory
}
}