fix(omp): reopened chats show a compaction row instead of "Unsupported history record" (#6215)

* fix(omp): reopened chats show a compaction row instead of "Unsupported history record"

OMP stores a compaction as a top-level session entry. History replay sent
it to the unknown-record fallback, so after a daemon restart the chat
showed a "Custom message" row reading "[compaction] Unsupported history
record". Replay now maps the entry to a completed compaction row.

Closes #6206

* test(omp): assert on the replayed custom-message rows directly

* test(omp): match only the unsupported-record row after restart

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-06 16:25:59 +02:00
committed by GitHub
parent a700d7875c
commit f5c55dfc55
3 changed files with 144 additions and 5 deletions
@@ -829,6 +829,64 @@ describe("OMP history mapper", () => {
]);
});
test("replays a compaction entry as a completed compaction row", async () => {
const dir = mkdtempSync(join(tmpdir(), "omp-compaction-history-"));
const sessionFile = join(dir, "session.jsonl");
writeFileSync(
sessionFile,
[
{ type: "session", id: "root", parentId: null },
{
type: "message",
id: "answer-1",
parentId: "root",
message: {
role: "assistant",
content: [{ type: "text", text: "Before." }],
responseId: "resp-1",
},
},
{
type: "compaction",
id: "compaction-1",
parentId: "answer-1",
timestamp: "2026-10-05T04:31:19.537Z",
summary: "## Goal\nSummary",
shortSummary: "Summary",
firstKeptEntryId: "answer-1",
tokensBefore: 77611,
tokensAfter: 37526,
method: "snapcompact",
details: { readFiles: [], modifiedFiles: [] },
fromExtension: false,
},
{
type: "message",
id: "answer-2",
parentId: "compaction-1",
message: {
role: "assistant",
content: [{ type: "text", text: "After." }],
responseId: "resp-2",
},
},
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
const events: AgentStreamEvent[] = [];
for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) {
events.push(event);
}
expect(events.map((event) => event.item)).toEqual([
{ type: "assistant_message", text: "Before.", messageId: "resp-1" },
{ type: "compaction", status: "completed", preTokens: 77611 },
{ type: "assistant_message", text: "After.", messageId: "resp-2" },
]);
expect(events[1]).toMatchObject({ type: "timeline", timestamp: "2026-10-05T04:31:19.537Z" });
});
test("maps omp 18.1 custom_message entries like live custom messages", async () => {
const dir = mkdtempSync(join(tmpdir(), "omp-custom-message-history-"));
const sessionFile = join(dir, "session.jsonl");
@@ -20,6 +20,10 @@ interface OmpSessionEntry {
[key: string]: unknown;
}
type OmpReplayEntry =
| { entry: OmpSessionEntry; message: OmpAgentMessage }
| { entry: OmpSessionEntry; item: AgentTimelineItem };
export async function readOmpHistoryTodoState(
sessionFile: string,
): Promise<AgentTimelineItem | null> {
@@ -90,13 +94,17 @@ export async function* streamOmpHistory(input: {
throw error;
}
const messages: OmpAgentMessage[] = [];
const messageEntries: OmpSessionEntry[] = [];
const replayEntries: OmpReplayEntry[] = [];
const userEntries: OmpCapturedUserMessageEntry[] = [];
for (const entry of entries) {
if (entry.type === "compaction") {
replayEntries.push({ entry, item: mapCompactionEntry(entry) });
continue;
}
const mapped = mapEntryMessage(entry);
if (!mapped) continue;
messages.push(mapped);
messageEntries.push(entry);
replayEntries.push({ entry, message: mapped });
if (mapped.role === "user" && entry.id) {
userEntries.push({ id: entry.id, text: textOf(mapped.content) });
}
@@ -107,9 +115,13 @@ export async function* streamOmpHistory(input: {
OMP_HISTORY_MAPPER_HOOKS,
input.bridgedTools,
);
for (let index = 0; index < messages.length; index += 1) {
const timestamp = normalizeProviderReplayTimestamp(messageEntries[index]?.timestamp);
for (const event of mapper.mapMessages([messages[index]!])) {
for (const replayEntry of replayEntries) {
const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp);
const events: AgentStreamEvent[] =
"item" in replayEntry
? [{ type: "timeline", provider: input.provider, item: replayEntry.item }]
: mapper.mapMessages([replayEntry.message]);
for (const event of events) {
yield timestamp && event.type === "timeline" ? { ...event, timestamp } : event;
}
}
@@ -393,6 +405,16 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null {
return visibleFallback(entry.type, entry);
}
// OMP persists a compaction as a top-level entry; the live timeline shows it as a compaction row.
// The entry does not record whether a manual /compact or auto-compaction produced it.
function mapCompactionEntry(entry: OmpSessionEntry): AgentTimelineItem {
return {
type: "compaction",
status: "completed",
...(typeof entry.tokensBefore === "number" ? { preTokens: entry.tokensBefore } : {}),
};
}
// omp 18.1+ persists injected rows (skill prompts, hub messages, job notices) as top-level
// custom_message entries without a message object; replay them as live custom messages
function mapCustomMessageEntry(entry: OmpSessionEntry): OmpAgentMessage | null {
@@ -131,6 +131,15 @@ async function timeline(client: DaemonClient, agentId: string): Promise<AgentTim
return result.entries.map((entry) => entry.item);
}
function compactionFiller(): string {
const teas = ["assam", "darjeeling", "sencha", "oolong"];
return Array.from(
{ length: 1200 },
(_, index) =>
`Record ${String(index).padStart(5, "0")}: tea=${teas[index % teas.length]} origin=region-${index % 37} notes=lorem ipsum dolor sit amet`,
).join("\n");
}
function latestTools(items: AgentTimelineItem[]) {
const latest = new Map<string, Extract<AgentTimelineItem, { type: "tool_call" }>>();
for (const item of items) if (item.type === "tool_call") latest.set(item.callId, item);
@@ -510,6 +519,56 @@ describe("daemon E2E (real OMP)", () => {
TIMEOUT_MS,
);
test(
"a compaction shows as a compaction row after a daemon restart",
async () => {
const harness = await createHarness();
let restarted: TestPaseoDaemon | null = null;
let restartedClient: DaemonClient | null = null;
try {
const agent = await createAgent(harness, "compact-restart");
await promptAndFinish(
harness,
agent.id,
`${compactionFiller()}\n\nReply exactly OMP_COMPACT_FIRST`,
);
await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_SECOND");
await harness.client.sendMessage(agent.id, "/compact");
await harness.client.waitForFinish(agent.id, TIMEOUT_MS);
await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_THIRD");
await harness.client.close();
await harness.daemon.close();
restarted = await createTestPaseoDaemon({
agentClients: createRealProviderClients(["omp"], pino({ level: "silent" })),
providerOverrides: { omp: { enabled: true } },
logger: pino({ level: "silent" }),
paseoHomeRoot: harness.paseoHomeRoot,
staticDir: harness.staticDir,
cleanup: false,
});
restartedClient = new DaemonClient({ url: `ws://127.0.0.1:${restarted.port}/ws` });
await restartedClient.connect();
await restartedClient.fetchAgents({ subscribe: {} });
const replayed = await timeline(restartedClient, agent.id);
expect(
latestTools(replayed)
.map(toolResult)
.filter((text) => text.includes("Unsupported history record")),
).toEqual([]);
expect(replayed.filter((item) => item.type === "compaction")).toEqual([
expect.objectContaining({ type: "compaction", status: "completed" }),
]);
} finally {
await restartedClient?.close().catch(() => undefined);
await restarted?.close().catch(() => undefined);
await closeHarness(harness);
}
},
TIMEOUT_MS,
);
test(
"native branch rewinds conversation history and keeps the session usable",
async () => {