mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
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:
co-authored by
Codex Local
parent
39fba5292c
commit
f9ef3a4489
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user