Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/src/codex/codexLocalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
}
const createdScanner = await createCodexSessionScanner({
transcriptPath,
replayExistingEvents: session.importHistory,
Copy link
Copy Markdown

@github-actions github-actions Bot Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] runCodex already imports the transcript before the loop starts when --hapi-import-history is set, so passing the same flag into the local scanner makes it replay the same persisted JSONL again. This duplicates the restored conversation for terminal resumes, and also after a web-restored session switches to local mode.

Suggested fix:

const createdScanner = await createCodexSessionScanner({
    transcriptPath,
    replayExistingEvents: false,
    onSessionId: (sessionId) => {
        session.onSessionFound(sessionId);
    },
    onEvent: (event) => {
        // forward newly appended transcript events only
    }
});

onSessionId: (sessionId) => {
session.onSessionFound(sessionId);
},
Expand Down
164 changes: 164 additions & 0 deletions cli/src/codex/importHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { importCodexSessionHistory } from './importHistory';
import type { ApiSessionClient } from '@/lib';
import type { Metadata } from '@hapi/protocol';

describe('importCodexSessionHistory', () => {
const originalCodexHome = process.env.CODEX_HOME;
let codexHome: string;

beforeEach(async () => {
codexHome = join(tmpdir(), `hapi-codex-history-${Date.now()}-${Math.random().toString(16).slice(2)}`);
process.env.CODEX_HOME = codexHome;
await mkdir(join(codexHome, 'sessions', '2026', '04', '27'), { recursive: true });
});

afterEach(async () => {
if (originalCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = originalCodexHome;
}
await rm(codexHome, { recursive: true, force: true });
});

it('imports user and agent messages from the matching Codex transcript', async () => {
const transcriptPath = join(codexHome, 'sessions', '2026', '04', '27', 'session.jsonl');
await writeFile(
transcriptPath,
[
JSON.stringify({ type: 'session_meta', payload: { id: 'thread-1' } }),
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'old prompt' } }),
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old answer' } })
].join('\n') + '\n'
);
await writeFile(
join(codexHome, 'session_index.jsonl'),
`${JSON.stringify({ id: 'thread-1', thread_name: 'codex generated title', updated_at: '2026-04-27T00:00:00.000Z' })}\n`
);

const userMessages: string[] = [];
const agentMessages: unknown[] = [];
const updateMetadata = vi.fn();
const session = {
updateMetadata,
sendUserMessage: (message: string) => userMessages.push(message),
sendAgentMessage: (message: unknown) => agentMessages.push(message),
} as unknown as ApiSessionClient;

const result = await importCodexSessionHistory({
session,
codexSessionId: 'thread-1',
});

expect(result).toEqual({ imported: 2, filePath: transcriptPath });
expect(updateMetadata).toHaveBeenCalledTimes(2);
const metadata = updateMetadata.mock.calls.reduce<Metadata>(
(current, call) => call[0](current),
{ path: '/repo', host: 'test' }
);
expect(metadata).toMatchObject({
codexSessionId: 'thread-1',
summary: { text: 'codex generated title' }
});
expect(userMessages).toEqual(['old prompt']);
expect(agentMessages).toMatchObject([
{ type: 'message', message: 'old answer' }
]);
});

it('restores Codex session metadata from transcript model, reasoning effort, and latest usage', async () => {
const transcriptPath = join(codexHome, 'sessions', '2026', '04', '27', 'session.jsonl');
await writeFile(
transcriptPath,
[
JSON.stringify({ type: 'session_meta', payload: { id: 'thread-usage', model: 'gpt-5.4' } }),
JSON.stringify({
type: 'event_msg',
payload: {
type: 'turn_context',
model: 'gpt-5.4',
reasoning_effort: 'high'
}
}),
JSON.stringify({
type: 'event_msg',
payload: {
type: 'token_count',
info: {
model_context_window: 100_000,
total_token_usage: {
input_tokens: 1000,
cached_input_tokens: 500,
output_tokens: 250,
reasoning_output_tokens: 250,
total_tokens: 2000
}
},
rate_limits: {
primary: {
used_percent: 25,
window_minutes: 300
}
}
}
})
].join('\n') + '\n'
);

const updateMetadata = vi.fn();
const applySessionConfig = vi.fn();
const session = {
updateMetadata,
applySessionConfig,
sendUserMessage: vi.fn(),
sendAgentMessage: vi.fn(),
} as unknown as ApiSessionClient;

const result = await importCodexSessionHistory({
session,
codexSessionId: 'thread-usage',
});

expect(result).toMatchObject({
imported: 1,
filePath: transcriptPath,
model: 'gpt-5.4',
modelReasoningEffort: 'high'
});
expect(applySessionConfig).toHaveBeenCalledWith({
model: 'gpt-5.4',
modelReasoningEffort: 'high'
});
const metadata = updateMetadata.mock.calls.reduce<Metadata>(
(current, call) => call[0](current),
{ path: '/repo', host: 'test' }
);
expect(metadata).toMatchObject({
codexSessionId: 'thread-usage',
codexUsage: {
contextWindow: {
usedTokens: 2000,
limitTokens: 100_000,
percent: 2
},
rateLimits: {
fiveHour: {
usedPercent: 25,
windowMinutes: 300
}
},
totalTokenUsage: {
inputTokens: 1000,
cachedInputTokens: 500,
outputTokens: 250,
reasoningOutputTokens: 250,
totalTokens: 2000
}
}
});
});
});
143 changes: 143 additions & 0 deletions cli/src/codex/importHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { readFile } from 'node:fs/promises';
import type { ApiSessionClient } from '@/lib';
import { findCodexSessionFile, findCodexSessionTitle, formatCodexSessionTitle } from '@/modules/common/codexSessions';
import { logger } from '@/ui/logger';
import { convertCodexEvent, type CodexSessionEvent } from './utils/codexEventConverter';
import { normalizeCodexUsage } from './utils/codexUsage';

type TitleSource = 'index' | 'user' | 'agent';
type ImportSessionConfig = {
model?: string;
modelReasoningEffort?: string;
};
type ImportSessionClient = ApiSessionClient & {
applySessionConfig?: (config: ImportSessionConfig) => void;
};

function parseCodexSessionEvent(line: string): CodexSessionEvent | null {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') {
return null;
}
const record = parsed as Record<string, unknown>;
if (typeof record.type !== 'string' || record.type.length === 0) {
return null;
}
return {
timestamp: typeof record.timestamp === 'string' ? record.timestamp : undefined,
type: record.type,
payload: record.payload
};
}

export async function importCodexSessionHistory(args: {
session: ImportSessionClient;
codexSessionId: string;
}): Promise<{ imported: number; filePath: string | null; model?: string; modelReasoningEffort?: string }> {
const filePath = await findCodexSessionFile(args.codexSessionId);
if (!filePath) {
logger.debug(`[codex-history-import] No transcript found for Codex session ${args.codexSessionId}`);
return { imported: 0, filePath: null };
}

const content = await readFile(filePath, 'utf8');
let imported = 0;
let title = await findCodexSessionTitle(args.codexSessionId);
let titleSource: TitleSource | null = title ? 'index' : null;
let restoredModel: string | undefined;
let restoredModelReasoningEffort: string | undefined;
for (const line of content.split('\n')) {
if (!line.trim()) {
continue;
}
const event = parseCodexSessionEvent(line);
if (!event) {
continue;
}
const converted = convertCodexEvent(event);
if (converted?.sessionId) {
const payload = event.payload && typeof event.payload === 'object'
? event.payload as Record<string, unknown>
: null;
if (typeof payload?.model === 'string' && payload.model.length > 0) {
restoredModel = payload.model;
}
const sessionReasoningEffort = payload?.model_reasoning_effort ?? payload?.modelReasoningEffort ?? payload?.reasoning_effort ?? payload?.reasoningEffort;
if (typeof sessionReasoningEffort === 'string' && sessionReasoningEffort.length > 0) {
restoredModelReasoningEffort = sessionReasoningEffort;
}
args.session.updateMetadata((metadata) => ({
...metadata,
codexSessionId: converted.sessionId
}));
}
if (event.type === 'event_msg' && event.payload && typeof event.payload === 'object') {
const payload = event.payload as Record<string, unknown>;
if (payload.type === 'turn_context') {
if (typeof payload.model === 'string' && payload.model.length > 0) {
restoredModel = payload.model;
}
const reasoningEffort = payload.reasoning_effort ?? payload.reasoningEffort ?? payload.model_reasoning_effort ?? payload.modelReasoningEffort;
if (typeof reasoningEffort === 'string' && reasoningEffort.length > 0) {
restoredModelReasoningEffort = reasoningEffort;
}
}
}
if (converted?.userMessage) {
const userTitle = formatCodexSessionTitle(converted.userMessage);
if (userTitle && titleSource !== 'index' && titleSource !== 'user') {
title = userTitle;
titleSource = 'user';
}
args.session.sendUserMessage(converted.userMessage);
imported += 1;
}
if (converted?.message) {
if (converted.message.type === 'token_count') {
const codexUsage = normalizeCodexUsage(converted.message);
if (codexUsage) {
args.session.updateMetadata((metadata) => ({
...metadata,
codexUsage
}));
}
}
if (converted.message.type === 'message' && !title) {
title = formatCodexSessionTitle(converted.message.message);
titleSource = 'agent';
}
args.session.sendAgentMessage(converted.message);
imported += 1;
}
}

if (title) {
args.session.updateMetadata((metadata) => ({
...metadata,
summary: {
text: title,
updatedAt: Date.now()
}
}));
}

const restoredConfig: ImportSessionConfig = {
...(restoredModel ? { model: restoredModel } : {}),
...(restoredModelReasoningEffort ? { modelReasoningEffort: restoredModelReasoningEffort } : {})
};
if (restoredConfig.model || restoredConfig.modelReasoningEffort) {
args.session.applySessionConfig?.(restoredConfig);
}

logger.debug(`[codex-history-import] Imported ${imported} messages from ${filePath}`);
return {
imported,
filePath,
...restoredConfig
};
}
4 changes: 3 additions & 1 deletion cli/src/codex/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface LoopOptions {
modelReasoningEffort?: ReasoningEffort;
collaborationMode?: CodexCollaborationMode;
resumeSessionId?: string;
importHistory?: boolean;
onSessionReady?: (session: CodexSession) => void;
}

Expand All @@ -56,7 +57,8 @@ export async function loop(opts: LoopOptions): Promise<void> {
permissionMode: opts.permissionMode ?? 'default',
model: opts.model,
modelReasoningEffort: opts.modelReasoningEffort,
collaborationMode: opts.collaborationMode ?? 'default'
collaborationMode: opts.collaborationMode ?? 'default',
importHistory: opts.importHistory
});

await runLocalRemoteSession({
Expand Down
28 changes: 28 additions & 0 deletions cli/src/codex/runCodex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protoc
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { getInvokedCwd } from '@/utils/invokedCwd';
import type { ReasoningEffort } from './appServerTypes';
import { importCodexSessionHistory } from './importHistory';

export { emitReadyIfIdle } from './utils/emitReadyIfIdle';

Expand All @@ -23,6 +24,7 @@ export async function runCodex(opts: {
codexArgs?: string[];
permissionMode?: PermissionMode;
resumeSessionId?: string;
importHistory?: boolean;
model?: string;
modelReasoningEffort?: ReasoningEffort;
}): Promise<void> {
Expand Down Expand Up @@ -71,6 +73,31 @@ export async function runCodex(opts: {
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit);

if (opts.importHistory && opts.resumeSessionId) {
try {
const importedHistory = await importCodexSessionHistory({
session,
codexSessionId: opts.resumeSessionId
});
if (!opts.model && importedHistory.model) {
currentModel = importedHistory.model;
}
if (
!opts.modelReasoningEffort
&& importedHistory.modelReasoningEffort
&& REASONING_EFFORTS.has(importedHistory.modelReasoningEffort as ReasoningEffort)
) {
currentModelReasoningEffort = importedHistory.modelReasoningEffort as ReasoningEffort;
}
} catch (error) {
logger.debug('[codex] Failed to import Codex session history:', error);
session.sendAgentMessage({
type: 'message',
message: `Failed to import Codex session history: ${error instanceof Error ? error.message : String(error)}`
});
}
}

const applyCurrentConfigToSession = (options?: { syncModel?: boolean }) => {
const sessionInstance = sessionWrapperRef.current;
if (!sessionInstance) {
Expand Down Expand Up @@ -231,6 +258,7 @@ export async function runCodex(opts: {
modelReasoningEffort: currentModelReasoningEffort,
collaborationMode: currentCollaborationMode,
resumeSessionId: opts.resumeSessionId,
importHistory: opts.importHistory,
onModeChange: createModeChangeHandler(session),
onSessionReady: (instance) => {
sessionWrapperRef.current = instance;
Expand Down
Loading
Loading