feat(coding-agent/session): supported importing session compactions in Codex session store
- Support parsing compacted records with replacement history and conversion to compaction entries. - Add test coverage for foreign session import of Codex session compactions.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
### Added
|
||||
|
||||
- Added server-name autocomplete for `/mcp` commands (`enable`, `disable`, `test`, `remove`, `reconnect`, `reauth`, `unauth`) using configured and runtime-discovered MCP servers.
|
||||
- Added `--from-claude` and `--from-codex` session imports, also available from `/resume @claude` and `/resume @codex`.
|
||||
- Added `--from-claude` and `--from-codex` session imports (including compaction state for Codex), also available from `/resume @claude` and `/resume @codex`.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
import { isRecord } from "@oh-my-pi/pi-utils";
|
||||
import { readForeignJsonRecords } from "./foreign-session-jsonl";
|
||||
import type { ForeignSessionInfo, ForeignSessionStore } from "./foreign-session-store";
|
||||
import type { ModelChangeEntry, SessionEntry, SessionMessageEntry } from "./session-entries";
|
||||
import type { CompactionEntry, ModelChangeEntry, SessionEntry, SessionMessageEntry } from "./session-entries";
|
||||
import { SessionManager } from "./session-manager";
|
||||
|
||||
interface CodexThreadRow {
|
||||
@@ -33,6 +33,12 @@ interface CodexIndexRow {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CodexCompaction {
|
||||
summary: string;
|
||||
replacementHistory?: Array<Record<string, unknown>>;
|
||||
compactionItem?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ConvertedRecord {
|
||||
message?: UserMessage | AssistantMessage | ToolResultMessage;
|
||||
followingMessage?: ToolResultMessage;
|
||||
@@ -40,6 +46,7 @@ interface ConvertedRecord {
|
||||
rollbackTurns?: number;
|
||||
title?: string;
|
||||
timestamp?: number;
|
||||
compaction?: CodexCompaction;
|
||||
}
|
||||
|
||||
const EMPTY_USAGE: AssistantMessage["usage"] = {
|
||||
@@ -577,6 +584,28 @@ export class CodexSessionStore implements ForeignSessionStore {
|
||||
canonical.toolCalls,
|
||||
toolNames,
|
||||
);
|
||||
} else if (record.type === "compacted") {
|
||||
const sourceSummary = stringField(record.payload, "message")?.trim();
|
||||
const rawReplacementHistory = record.payload.replacement_history;
|
||||
const replacementHistory =
|
||||
Array.isArray(rawReplacementHistory) && rawReplacementHistory.every(isRecord)
|
||||
? rawReplacementHistory
|
||||
: undefined;
|
||||
if (sourceSummary || replacementHistory) {
|
||||
const compactionItem = replacementHistory?.findLast(
|
||||
candidate =>
|
||||
(candidate.type === "compaction" && typeof candidate.encrypted_content === "string") ||
|
||||
candidate.type === "compaction_summary",
|
||||
);
|
||||
item = {
|
||||
compaction: {
|
||||
summary: sourceSummary || "Context compacted by Codex.",
|
||||
replacementHistory,
|
||||
compactionItem,
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!item) continue;
|
||||
if (item.rollbackTurns) rollback(converted, item.rollbackTurns);
|
||||
@@ -609,6 +638,30 @@ export class CodexSessionStore implements ForeignSessionStore {
|
||||
model: `openai-codex/${item.model}`,
|
||||
};
|
||||
entry = modelEntry;
|
||||
} else if (item.compaction) {
|
||||
let preserveData: Record<string, unknown> | undefined;
|
||||
if (item.compaction.replacementHistory) {
|
||||
const remoteCompaction: Record<string, unknown> = {
|
||||
provider: "openai-codex",
|
||||
replacementHistory: item.compaction.replacementHistory,
|
||||
};
|
||||
if (item.compaction.compactionItem) {
|
||||
remoteCompaction.compactionItem = item.compaction.compactionItem;
|
||||
}
|
||||
preserveData = { openaiRemoteCompaction: remoteCompaction };
|
||||
}
|
||||
const compactionEntry: CompactionEntry = {
|
||||
type: "compaction",
|
||||
id,
|
||||
parentId,
|
||||
timestamp,
|
||||
summary: item.compaction.summary,
|
||||
shortSummary: "Imported Codex compaction",
|
||||
firstKeptEntryId: id,
|
||||
tokensBefore: 0,
|
||||
preserveData,
|
||||
};
|
||||
entry = compactionEntry;
|
||||
}
|
||||
if (!entry) continue;
|
||||
manager.ingestReplicatedEntry(entry);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ClaudeSessionStore } from "../src/session/claude-session-store";
|
||||
import { CodexSessionStore } from "../src/session/codex-session-store";
|
||||
import { persistForeignSession } from "../src/session/foreign-session-import";
|
||||
import type { ForeignSessionInfo } from "../src/session/foreign-session-store";
|
||||
import { buildSessionContext } from "../src/session/session-context";
|
||||
import { SessionManager } from "../src/session/session-manager";
|
||||
import { FileSessionStorage } from "../src/session/session-storage";
|
||||
|
||||
@@ -170,8 +171,12 @@ describe("CodexSessionStore", () => {
|
||||
const id = "44444444-4444-4444-8444-444444444444";
|
||||
const sessionPath = path.join(root, ".sessions", "2025", "01", "01", `rollout-${id}.jsonl`);
|
||||
await writeJsonl(path.join(root, "session_index.jsonl"), [
|
||||
{ id, thread_name: "Legacy Codex", updated_at: "2025-01-01T00:00:09.000Z" },
|
||||
{ id, thread_name: "Legacy Codex", updated_at: "2025-01-01T00:00:12.000Z" },
|
||||
]);
|
||||
const replacementHistory = [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "Retained context" }] },
|
||||
{ type: "compaction", encrypted_content: "encrypted-compaction" },
|
||||
];
|
||||
await writeJsonl(sessionPath, [
|
||||
{ type: "session_meta", timestamp: "2025-01-01T00:00:00.000Z", payload: { id, cwd } },
|
||||
{ type: "turn_context", timestamp: "2025-01-01T00:00:01.000Z", payload: { model: "gpt-5.3-codex" } },
|
||||
@@ -196,18 +201,29 @@ describe("CodexSessionStore", () => {
|
||||
payload: { type: "function_call_output", call_id: "call-codex", output: "file contents" },
|
||||
},
|
||||
{
|
||||
type: "event_msg",
|
||||
type: "compacted",
|
||||
timestamp: "2025-01-01T00:00:06.000Z",
|
||||
payload: { message: "", replacement_history: replacementHistory },
|
||||
},
|
||||
{ type: "turn_context", timestamp: "2025-01-01T00:00:07.000Z", payload: { model: "gpt-5.3-codex" } },
|
||||
{
|
||||
type: "response_item",
|
||||
timestamp: "2025-01-01T00:00:08.000Z",
|
||||
payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||
},
|
||||
{
|
||||
type: "event_msg",
|
||||
timestamp: "2025-01-01T00:00:09.000Z",
|
||||
payload: { type: "web_search_end", call_id: "call-web", query: "docs", action: { query: "docs" } },
|
||||
},
|
||||
{
|
||||
type: "response_item",
|
||||
timestamp: "2025-01-01T00:00:07.000Z",
|
||||
timestamp: "2025-01-01T00:00:10.000Z",
|
||||
payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "Done" }] },
|
||||
},
|
||||
{
|
||||
type: "event_msg",
|
||||
timestamp: "2025-01-01T00:00:08.000Z",
|
||||
timestamp: "2025-01-01T00:00:11.000Z",
|
||||
payload: { type: "thread_name_updated", thread_name: "Imported Codex" },
|
||||
},
|
||||
]);
|
||||
@@ -224,6 +240,51 @@ describe("CodexSessionStore", () => {
|
||||
expect(entries.some(entry => entry.type === "model_change" && entry.model === "openai-codex/gpt-5.3-codex")).toBe(
|
||||
true,
|
||||
);
|
||||
const compaction = entries.find(entry => entry.type === "compaction");
|
||||
if (compaction?.type !== "compaction") throw new Error("Missing imported Codex compaction");
|
||||
expect(compaction.firstKeptEntryId).toBe(compaction.id);
|
||||
expect(compaction.preserveData).toEqual({
|
||||
openaiRemoteCompaction: {
|
||||
provider: "openai-codex",
|
||||
replacementHistory,
|
||||
compactionItem: replacementHistory[1],
|
||||
},
|
||||
});
|
||||
const activeContext = buildSessionContext(entries);
|
||||
expect(activeContext.messages.map(message => message.role)).toEqual([
|
||||
"compactionSummary",
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
expect(activeContext.messages[1]).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Continue" }],
|
||||
});
|
||||
const activeSummary = activeContext.messages[0];
|
||||
if (activeSummary?.role !== "compactionSummary") throw new Error("Missing active Codex compaction context");
|
||||
expect(activeSummary.providerPayload).toEqual({
|
||||
type: "openaiResponsesHistory",
|
||||
provider: "openai-codex",
|
||||
items: replacementHistory,
|
||||
});
|
||||
const transcript = buildSessionContext(entries, undefined, undefined, { transcript: true });
|
||||
expect(transcript.messages.map(message => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"compactionSummary",
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
expect(transcript.messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Inspect" }],
|
||||
});
|
||||
const calls = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
||||
|
||||
Reference in New Issue
Block a user