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:
weishu
2026-09-12 10:59:17 +08:00
parent 25af3f8e13
commit 0c4abcb3d1
111 changed files with 4684 additions and 960 deletions
+12 -309
View File
@@ -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]));
});
});