mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-28 23:01:34 +00:00
feat(codex): share sessions across terminal and web
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle. Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes. Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
This commit is contained in:
+12
-309
@@ -1,310 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { runCodex } from './runCodex'
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
const run = vi.hoisted(() => vi.fn(async (_options: unknown) => {}));
|
||||
vi.mock('./shared/frontend', () => ({ runSharedCodex: run }));
|
||||
import { runCodex } from './runCodex';
|
||||
|
||||
const mockCodexSession = vi.hoisted(() => ({
|
||||
getPermissionMode: vi.fn<() => string | undefined>(),
|
||||
getModel: vi.fn<() => string | null | undefined>(),
|
||||
getModelReasoningEffort: vi.fn<() => string | null | undefined>(),
|
||||
getServiceTier: vi.fn<() => string | null | undefined>(),
|
||||
getCollaborationMode: vi.fn<() => string | undefined>(),
|
||||
setPermissionMode: vi.fn(),
|
||||
setModel: vi.fn(),
|
||||
setModelReasoningEffort: vi.fn(),
|
||||
setServiceTier: vi.fn(),
|
||||
setCollaborationMode: vi.fn(),
|
||||
stopKeepAlive: vi.fn()
|
||||
}))
|
||||
|
||||
const harness = vi.hoisted(() => ({
|
||||
bootstrapArgs: [] as Array<Record<string, unknown>>,
|
||||
loopArgs: [] as Array<Record<string, unknown>>,
|
||||
sessionInfo: { serviceTier: null as string | null } as Record<string, unknown>,
|
||||
session: {
|
||||
onUserMessage: vi.fn(),
|
||||
onCancelQueuedMessage: vi.fn(),
|
||||
onRetryQueuedMessage: vi.fn(),
|
||||
rpcHandlerManager: {
|
||||
registerHandler: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/agent/sessionFactory', () => ({
|
||||
bootstrapSession: vi.fn(async (options: Record<string, unknown>) => {
|
||||
harness.bootstrapArgs.push(options)
|
||||
return {
|
||||
api: {},
|
||||
session: harness.session,
|
||||
sessionInfo: harness.sessionInfo
|
||||
}
|
||||
}),
|
||||
bootstrapLazySession: vi.fn(async (options: Record<string, unknown>) => {
|
||||
harness.bootstrapArgs.push({ ...options, lazy: true })
|
||||
return {
|
||||
api: {},
|
||||
session: harness.session,
|
||||
sessionInfo: harness.sessionInfo
|
||||
}
|
||||
}),
|
||||
bootstrapExistingSession: vi.fn(async (options: Record<string, unknown>) => {
|
||||
harness.bootstrapArgs.push(options)
|
||||
return {
|
||||
api: {},
|
||||
session: harness.session,
|
||||
sessionInfo: harness.sessionInfo
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./loop', () => ({
|
||||
loop: vi.fn(async (options: Record<string, unknown>) => {
|
||||
harness.loopArgs.push(options)
|
||||
const onSessionReady = options.onSessionReady as ((session: unknown) => void) | undefined
|
||||
onSessionReady?.(mockCodexSession)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/claude/registerKillSessionHandler', () => ({
|
||||
registerKillSessionHandler: vi.fn()
|
||||
}))
|
||||
|
||||
const lifecycleMock = vi.hoisted(() => ({
|
||||
registerProcessHandlers: vi.fn(),
|
||||
cleanupAndExit: vi.fn(async () => {}),
|
||||
markCrash: vi.fn(),
|
||||
setExitCode: vi.fn(),
|
||||
setArchiveReason: vi.fn(),
|
||||
setSessionEndReason: vi.fn(),
|
||||
hasExplicitSessionEndReason: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/agent/runnerLifecycle', () => ({
|
||||
createModeChangeHandler: vi.fn(() => vi.fn()),
|
||||
createRunnerLifecycle: vi.fn(() => lifecycleMock),
|
||||
setControlledByUser: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/agent/localHandoff', () => ({
|
||||
registerLocalHandoffHandler: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/ui/logger', () => ({
|
||||
logger: {
|
||||
debug: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/attachmentFormatter', () => ({
|
||||
formatMessageWithAttachments: vi.fn((text: string) => text)
|
||||
}))
|
||||
|
||||
vi.mock('@/modules/common/slashCommands', () => ({
|
||||
listSlashCommands: vi.fn(async () => [])
|
||||
}))
|
||||
|
||||
vi.mock('./utils/slashCommands', () => ({
|
||||
resolveCodexSlashCommand: vi.fn(() => ({
|
||||
kind: 'passthrough'
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('./codexSpecialCommands', () => ({
|
||||
parseCodexSpecialCommand: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('./utils/codexCliOverrides', () => ({
|
||||
parseCodexCliOverrides: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
import { runCodex as runCodexImpl } from './runCodex'
|
||||
import { RPC_METHODS } from '@hapi/protocol/rpcMethods'
|
||||
|
||||
describe('runCodex', () => {
|
||||
beforeEach(() => {
|
||||
harness.bootstrapArgs.length = 0
|
||||
harness.loopArgs.length = 0
|
||||
harness.sessionInfo = { serviceTier: null }
|
||||
harness.session.onUserMessage.mockReset()
|
||||
harness.session.onCancelQueuedMessage.mockReset()
|
||||
harness.session.rpcHandlerManager.registerHandler.mockReset()
|
||||
mockCodexSession.getPermissionMode.mockReset()
|
||||
mockCodexSession.getModel.mockReset()
|
||||
mockCodexSession.getModelReasoningEffort.mockReset()
|
||||
mockCodexSession.getServiceTier.mockReset()
|
||||
mockCodexSession.getCollaborationMode.mockReset()
|
||||
mockCodexSession.setPermissionMode.mockReset()
|
||||
mockCodexSession.setModel.mockReset()
|
||||
mockCodexSession.setModelReasoningEffort.mockReset()
|
||||
mockCodexSession.setServiceTier.mockReset()
|
||||
mockCodexSession.setCollaborationMode.mockReset()
|
||||
lifecycleMock.registerProcessHandlers.mockClear()
|
||||
lifecycleMock.cleanupAndExit.mockClear()
|
||||
lifecycleMock.markCrash.mockClear()
|
||||
lifecycleMock.setExitCode.mockClear()
|
||||
lifecycleMock.setArchiveReason.mockClear()
|
||||
lifecycleMock.setSessionEndReason.mockClear()
|
||||
})
|
||||
|
||||
it('uses the requested collaboration mode when resuming locally', async () => {
|
||||
const options = {
|
||||
existingSessionId: 'hapi-session-1',
|
||||
workingDirectory: '/tmp/project',
|
||||
resumeSessionId: 'codex-thread-1',
|
||||
collaborationMode: 'plan'
|
||||
} as Parameters<typeof runCodex>[0] & { collaborationMode: 'plan' }
|
||||
|
||||
await runCodexImpl(options)
|
||||
|
||||
expect(harness.bootstrapArgs[0]).toEqual(expect.objectContaining({
|
||||
sessionId: 'hapi-session-1',
|
||||
workingDirectory: '/tmp/project'
|
||||
}))
|
||||
expect(harness.loopArgs[0]).toEqual(expect.objectContaining({
|
||||
resumeSessionId: 'codex-thread-1',
|
||||
collaborationMode: 'plan',
|
||||
replayTranscriptHistoryOnStart: false
|
||||
}))
|
||||
expect(mockCodexSession.setCollaborationMode).toHaveBeenLastCalledWith('plan')
|
||||
})
|
||||
|
||||
it('preserves a persisted Fast service tier on startup', async () => {
|
||||
harness.sessionInfo = { serviceTier: 'fast' }
|
||||
|
||||
await runCodexImpl({
|
||||
existingSessionId: 'hapi-session-1',
|
||||
workingDirectory: '/tmp/project',
|
||||
resumeSessionId: 'codex-thread-1'
|
||||
} as Parameters<typeof runCodex>[0])
|
||||
|
||||
// The first keepalive sync must re-assert Fast, not collapse it.
|
||||
expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('fast')
|
||||
expect(mockCodexSession.setServiceTier).not.toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('keeps an explicit Standard service tier sticky on startup', async () => {
|
||||
harness.sessionInfo = { serviceTier: 'standard' }
|
||||
|
||||
await runCodexImpl({
|
||||
existingSessionId: 'hapi-session-1',
|
||||
workingDirectory: '/tmp/project',
|
||||
resumeSessionId: 'codex-thread-1'
|
||||
} as Parameters<typeof runCodex>[0])
|
||||
|
||||
// Explicit Standard must survive resume (not be dropped to untouched),
|
||||
// so later turns keep sending app-server serviceTier: null.
|
||||
expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('standard')
|
||||
})
|
||||
|
||||
it('prefers the spawn-time service tier override when resuming (hub passes Fast)', async () => {
|
||||
// On resume the hub spawns a fresh session (serviceTier null in the new
|
||||
// row) and passes the old tier via opts; the override must win so the
|
||||
// resumed thread immediately runs Fast.
|
||||
harness.sessionInfo = { serviceTier: null }
|
||||
|
||||
await runCodexImpl({
|
||||
workingDirectory: '/tmp/project',
|
||||
resumeSessionId: 'codex-thread-1',
|
||||
serviceTier: 'fast'
|
||||
} as Parameters<typeof runCodex>[0])
|
||||
|
||||
expect(mockCodexSession.setServiceTier).toHaveBeenCalledWith('fast')
|
||||
})
|
||||
|
||||
it('does not collapse an untouched service tier into explicit Standard on startup', async () => {
|
||||
harness.sessionInfo = { serviceTier: null }
|
||||
|
||||
await runCodexImpl({
|
||||
workingDirectory: '/tmp/project'
|
||||
} as Parameters<typeof runCodex>[0])
|
||||
|
||||
// Untouched (account-default) sessions must omit the tier entirely so
|
||||
// the keepalive never persists serviceTier: null over the default.
|
||||
expect(mockCodexSession.setServiceTier).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not collapse inherited Codex reasoning effort into explicit default on startup', async () => {
|
||||
await runCodexImpl({ workingDirectory: '/tmp/project' })
|
||||
|
||||
expect(mockCodexSession.setModelReasoningEffort).not.toHaveBeenCalled()
|
||||
expect(harness.loopArgs[0]?.modelReasoningEffort).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses lazy bootstrap for a fresh terminal launch', async () => {
|
||||
await runCodexImpl({ workingDirectory: '/tmp/project' })
|
||||
|
||||
expect(harness.bootstrapArgs[0]).toEqual(expect.objectContaining({
|
||||
workingDirectory: '/tmp/project',
|
||||
lazy: true
|
||||
}))
|
||||
expect(harness.loopArgs[0]).toEqual(expect.objectContaining({
|
||||
replayTranscriptHistoryOnStart: true
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps eager bootstrap for runner launches', async () => {
|
||||
await runCodexImpl({
|
||||
startedBy: 'runner',
|
||||
workingDirectory: '/tmp/project'
|
||||
})
|
||||
|
||||
expect(harness.bootstrapArgs[0]).not.toHaveProperty('lazy')
|
||||
})
|
||||
|
||||
it('replays transcript history when attaching a new Hapi session to an existing Codex thread', async () => {
|
||||
await runCodexImpl({
|
||||
workingDirectory: '/tmp/project',
|
||||
resumeSessionId: 'codex-thread-2'
|
||||
})
|
||||
|
||||
expect(harness.loopArgs[0]).toEqual(expect.objectContaining({
|
||||
resumeSessionId: 'codex-thread-2',
|
||||
replayTranscriptHistoryOnStart: true
|
||||
}))
|
||||
})
|
||||
|
||||
it('accepts and normalizes model-reported reasoning efforts from session config', async () => {
|
||||
await runCodexImpl({ workingDirectory: '/tmp/project' })
|
||||
|
||||
const registration = harness.session.rpcHandlerManager.registerHandler.mock.calls.find(
|
||||
([method]) => method === RPC_METHODS.SetSessionConfig
|
||||
)
|
||||
const handler = registration?.[1] as ((payload: unknown) => Promise<unknown>) | undefined
|
||||
expect(handler).toBeTypeOf('function')
|
||||
|
||||
await handler?.({ modelReasoningEffort: 'max' })
|
||||
await handler?.({ modelReasoningEffort: ' EXTREME ' })
|
||||
|
||||
expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(1, 'max')
|
||||
expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(2, 'extreme')
|
||||
})
|
||||
|
||||
it('preserves native fallback settings when changing another session setting', async () => {
|
||||
await runCodexImpl({ workingDirectory: '/tmp/project', model: 'gpt-5.6', modelReasoningEffort: 'high' })
|
||||
mockCodexSession.getModel.mockReturnValue('gpt-5.6-luna')
|
||||
mockCodexSession.getModelReasoningEffort.mockReturnValue('medium')
|
||||
mockCodexSession.getServiceTier.mockReturnValue('standard')
|
||||
const handler = harness.session.rpcHandlerManager.registerHandler.mock.calls.find(
|
||||
([method]) => method === RPC_METHODS.SetSessionConfig
|
||||
)?.[1] as (payload: unknown) => Promise<unknown>
|
||||
expect(await handler({ permissionMode: 'yolo' })).toEqual({
|
||||
applied: expect.objectContaining({ modelReasoningEffort: 'medium', serviceTier: 'standard' })
|
||||
})
|
||||
expect(mockCodexSession.setModelReasoningEffort).toHaveBeenLastCalledWith('medium')
|
||||
})
|
||||
|
||||
it('still persists an explicit reasoning effort reset as null', async () => {
|
||||
await runCodexImpl({ workingDirectory: '/tmp/project' })
|
||||
|
||||
const registration = harness.session.rpcHandlerManager.registerHandler.mock.calls.find(
|
||||
([method]) => method === RPC_METHODS.SetSessionConfig
|
||||
)
|
||||
const handler = registration?.[1] as ((payload: unknown) => Promise<unknown>) | undefined
|
||||
const result = await handler?.({ modelReasoningEffort: null })
|
||||
|
||||
expect(mockCodexSession.setModelReasoningEffort).toHaveBeenCalledTimes(1)
|
||||
expect(mockCodexSession.setModelReasoningEffort).toHaveBeenCalledWith(null)
|
||||
expect(result).toEqual({
|
||||
applied: expect.objectContaining({ modelReasoningEffort: null })
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('Codex entrypoint', () => {
|
||||
it('uses the shared runtime for terminal, runner and resume (no exclusive-mode branch)', async () => {
|
||||
const launches = [{ startedBy: 'terminal' as const }, { startedBy: 'runner' as const },
|
||||
{ existingSessionId: 'hapi', resumeSessionId: 'native', collaborationMode: 'plan' as const }];
|
||||
for (const options of launches) await runCodex(options);
|
||||
expect(run.mock.calls).toEqual(launches.map(options => [options]));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user