mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user