From f5c55dfc551df42474d45f245aaa6ce3f88169b5 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:25:59 +0200 Subject: [PATCH] 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> --- .../providers/omp/history-mapper.test.ts | 58 ++++++++++++++++++ .../src/server/agent/providers/omp/history.ts | 32 ++++++++-- .../server/daemon-e2e/omp.real.e2e.test.ts | 59 +++++++++++++++++++ 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index 61e6d0e73d..e64a93fd64 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -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"); diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index 2e86b9670c..2c818dfc5a 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -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 { @@ -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 { diff --git a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts index 510e8b384d..7149b16864 100644 --- a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts @@ -131,6 +131,15 @@ async function timeline(client: DaemonClient, agentId: string): Promise 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>(); 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 () => {