mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
feat: add hapi resume command (#647)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user