feat(codex): import local Codex sessions into Hapi (#796)

* local: add Codex Desktop session sync controls

* feat(codex): import local Codex sessions into Hapi

---------

Co-authored-by: Codex Local <codex-local@example.invalid>
This commit is contained in:
DolphinZZZZZ
2026-06-04 17:53:12 +08:00
committed by GitHub
co-authored by Codex Local
parent 39fba5292c
commit f9ef3a4489
25 changed files with 3156 additions and 15 deletions
@@ -32,6 +32,37 @@ describe('convertCodexEvent', () => {
expect(result?.userMessage).toBe('hello user');
});
it('converts response_item user messages', () => {
const result = convertCodexEvent({
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello from response_item user' }]
}
});
expect(result).toEqual({
userMessage: 'hello from response_item user'
});
});
it('converts response_item assistant messages', () => {
const result = convertCodexEvent({
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'hello from response_item assistant' }]
}
});
expect(result?.message).toMatchObject({
type: 'message',
message: 'hello from response_item assistant'
});
});
it('converts reasoning events', () => {
const result = convertCodexEvent({
type: 'event_msg',
@@ -55,6 +55,30 @@ function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function extractCodexText(value: unknown): string {
if (typeof value === 'string') {
return value.trim();
}
if (Array.isArray(value)) {
return value
.map((item) => {
const record = asRecord(item);
if (record?.type === 'input_text' && typeof record.text === 'string') return record.text;
if (record?.type === 'output_text' && typeof record.text === 'string') return record.text;
if (record?.type === 'text' && typeof record.text === 'string') return record.text;
return null;
})
.filter((part): part is string => Boolean(part))
.join(' ')
.trim();
}
const record = asRecord(value);
if (record?.type === 'input_text' && typeof record.text === 'string') return record.text.trim();
if (record?.type === 'output_text' && typeof record.text === 'string') return record.text.trim();
if (record?.type === 'text' && typeof record.text === 'string') return record.text.trim();
return '';
}
function parseArguments(value: unknown): unknown {
if (typeof value !== 'string') {
return value;
@@ -194,6 +218,27 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu
return null;
}
if (itemType === 'message') {
const role = asString(payloadRecord.role);
const text = extractCodexText(payloadRecord.content);
if (!text) {
return null;
}
if (role === 'user') {
return { userMessage: text };
}
if (role === 'assistant') {
return {
message: {
type: 'message',
message: text,
id: randomUUID()
}
};
}
return null;
}
if (itemType === 'function_call') {
const name = asString(payloadRecord.name);
const callId = extractCallId(payloadRecord);
@@ -59,6 +59,27 @@ describe('codexSessionScanner', () => {
expect(events[0]?.type).toBe('event_msg');
});
it('can replay existing transcript history on first attach', async () => {
await writeFile(
transcriptPath,
[
JSON.stringify({ type: 'session_meta', payload: { id: 'session-replay' } }),
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old' } })
].join('\n') + '\n'
);
scanner = await createCodexSessionScanner({
transcriptPath,
replayExistingHistory: true,
onEvent: (event) => events.push(event)
});
await wait(300);
expect(events).toHaveLength(2);
expect(events[0]?.type).toBe('session_meta');
expect(events[1]?.payload).toEqual({ type: 'agent_message', message: 'old' });
});
it('reports session id from the transcript metadata', async () => {
await writeFile(
transcriptPath,
+16 -2
View File
@@ -7,6 +7,7 @@ interface CodexSessionScannerOptions {
transcriptPath: string | null;
onEvent: (event: CodexSessionEvent) => void;
onSessionId?: (sessionId: string) => void;
replayExistingHistory?: boolean;
}
export interface CodexSessionScanner {
@@ -34,6 +35,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
private readonly onSessionId?: (sessionId: string) => void;
private readonly fileEpochByPath = new Map<string, number>();
private readonly fileSizeByPath = new Map<string, number>();
private replayExistingHistoryOnNextAttach: boolean;
private observedSessionId: string | null = null;
constructor(opts: CodexSessionScannerOptions) {
@@ -41,6 +43,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
this.transcriptPath = opts.transcriptPath;
this.onEvent = opts.onEvent;
this.onSessionId = opts.onSessionId;
this.replayExistingHistoryOnNextAttach = opts.replayExistingHistory ?? false;
}
async setTranscriptPath(transcriptPath: string): Promise<void> {
@@ -48,14 +51,14 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
return;
}
this.transcriptPath = transcriptPath;
await this.primeTranscript(transcriptPath);
await this.prepareTranscript(transcriptPath);
this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []);
this.invalidate();
}
protected async initialize(): Promise<void> {
if (this.transcriptPath) {
await this.primeTranscript(this.transcriptPath);
await this.prepareTranscript(this.transcriptPath);
}
}
@@ -89,6 +92,17 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []);
}
private async prepareTranscript(filePath: string): Promise<void> {
if (this.replayExistingHistoryOnNextAttach) {
// 中文注释:导入既有 Codex thread 时,首次挂接 transcript 不能先 prime 到 EOF,
// 否则 Hapi 只会看到后续增量,客户端里已经存在的最新消息会被跳过。
this.replayExistingHistoryOnNextAttach = false;
return;
}
await this.primeTranscript(filePath);
}
private async primeTranscript(filePath: string): Promise<void> {
const { events, nextCursor } = await this.readSessionFile(filePath, 0);
const keys = events.map((entry) => this.generateEventKey(entry.event, { filePath, lineIndex: entry.lineIndex }));