mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 02:15:26 -06:00
9dfb2a1318
* fix(plugins): import adopted catalog history into the OpenClaw transcript Import native Pi and OpenCode history when a catalog session is adopted so the OpenClaw transcript reflects the full conversation immediately. Mark imported user rows with mirrorOrigin because they are transcript mirrors, not new external turns. This preserves watcher deduplication and prevents adopted history from being reported as fresh upstream input. * refactor(plugins): split catalog history importer Keep the session catalog contract module leaf-like by moving transcript mutation into a dedicated runtime module. This breaks the registry-to-transcript import cycle while preserving the Plugin SDK entrypoint and importer behavior.
222 lines
7.3 KiB
TypeScript
222 lines
7.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { importSessionCatalogHistory } from "./session-catalog-history-import.js";
|
|
|
|
const transcript = vi.hoisted(() => ({
|
|
messages: [] as Array<Record<string, unknown>>,
|
|
lockCalls: 0,
|
|
}));
|
|
|
|
vi.mock("../plugin-sdk/session-transcript-runtime.js", () => ({
|
|
withSessionTranscriptWriteLock: async (
|
|
_params: unknown,
|
|
run: (context: {
|
|
appendMessage: (params: {
|
|
message: Record<string, unknown>;
|
|
idempotencyLookup?: string;
|
|
}) => Promise<void>;
|
|
}) => Promise<void>,
|
|
) => {
|
|
transcript.lockCalls += 1;
|
|
await run({
|
|
appendMessage: async ({ message, idempotencyLookup }) => {
|
|
const key = message.idempotencyKey;
|
|
if (
|
|
idempotencyLookup === "scan" &&
|
|
typeof key === "string" &&
|
|
transcript.messages.some((candidate) => candidate.idempotencyKey === key)
|
|
) {
|
|
return;
|
|
}
|
|
transcript.messages.push(message);
|
|
},
|
|
});
|
|
},
|
|
}));
|
|
|
|
type CatalogItem = Parameters<Parameters<typeof importSessionCatalogHistory>[0]["read"]>[0];
|
|
type TranscriptItem = Awaited<
|
|
ReturnType<Parameters<typeof importSessionCatalogHistory>[0]["read"]>
|
|
>["items"][number];
|
|
|
|
function catalogReader(items: TranscriptItem[], maxPageSize = Number.POSITIVE_INFINITY) {
|
|
return vi.fn(async ({ cursor, limit }: CatalogItem) => {
|
|
const offset = cursor ? Number(cursor) : 0;
|
|
const pageLimit = Math.min(limit, maxPageSize);
|
|
const end = Math.max(0, items.length - offset);
|
|
const start = Math.max(0, end - pageLimit);
|
|
const page = items.slice(start, end);
|
|
const consumed = offset + page.length;
|
|
return {
|
|
hostId: "gateway",
|
|
threadId: "thread-1",
|
|
items: page,
|
|
...(consumed < items.length ? { nextCursor: String(consumed) } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
function importHistory(
|
|
items: TranscriptItem[],
|
|
options: { maxPageSize?: number; read?: ReturnType<typeof catalogReader> } = {},
|
|
) {
|
|
const read = options.read ?? catalogReader(items, options.maxPageSize);
|
|
return {
|
|
read,
|
|
result: importSessionCatalogHistory({
|
|
catalogId: "pi",
|
|
threadId: "thread-1",
|
|
read,
|
|
sessionId: "session-1",
|
|
sessionKey: "agent:main:catalog-adopt",
|
|
agentId: "main",
|
|
config: {} as OpenClawConfig,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function messageText(message: Record<string, unknown>): string | undefined {
|
|
if (typeof message.content === "string") {
|
|
return message.content;
|
|
}
|
|
const content = Array.isArray(message.content) ? message.content[0] : undefined;
|
|
return content && typeof content === "object" && "text" in content
|
|
? String(content.text)
|
|
: undefined;
|
|
}
|
|
|
|
describe("importSessionCatalogHistory", () => {
|
|
beforeEach(() => {
|
|
transcript.messages.length = 0;
|
|
transcript.lockCalls = 0;
|
|
});
|
|
|
|
it("imports backward pages in chronological order with native provenance", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-07-25T12:00:00.000Z"));
|
|
try {
|
|
const { result } = importHistory(
|
|
[
|
|
{ id: "u-1", type: "userMessage", text: "repeat me", timestamp: "not-a-date" },
|
|
{ id: "r-empty", type: "reasoning" },
|
|
{
|
|
id: "r-1",
|
|
type: "reasoning",
|
|
text: "careful",
|
|
timestamp: "1969-12-31T23:59:59.000Z",
|
|
},
|
|
{ id: "a-1", type: "agentMessage", text: "answer", model: "anthropic/claude" },
|
|
{ id: "t-1", type: "toolCall", text: "bash" },
|
|
{ id: "o-1", type: "other", text: "checkpoint" },
|
|
],
|
|
{ maxPageSize: 2 },
|
|
);
|
|
await result;
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
|
|
expect(transcript.messages.map(messageText)).toEqual([
|
|
"repeat me",
|
|
"Thinking\n\ncareful",
|
|
"answer",
|
|
"Tool call\n\nbash",
|
|
"Other\n\ncheckpoint",
|
|
]);
|
|
expect(transcript.messages[0]?.["__openclaw"]).toEqual({
|
|
mirrorOrigin: "pi-catalog-import",
|
|
});
|
|
expect(transcript.messages[1]?.timestamp).toBe(-1_000);
|
|
expect(transcript.messages[2]?.model).toBe("anthropic/claude");
|
|
expect(transcript.messages.map((message) => message.idempotencyKey)).toEqual([
|
|
"pi-catalog:thread-1:u-1",
|
|
"pi-catalog:thread-1:r-1",
|
|
"pi-catalog:thread-1:a-1",
|
|
"pi-catalog:thread-1:t-1",
|
|
"pi-catalog:thread-1:o-1",
|
|
]);
|
|
});
|
|
|
|
it("deduplicates a recovered import by scanning item idempotency keys", async () => {
|
|
const items: TranscriptItem[] = [
|
|
{ id: "u-1", type: "userMessage", text: "hello" },
|
|
{ id: "a-1", type: "agentMessage", text: "hi" },
|
|
];
|
|
|
|
await importHistory(items).result;
|
|
await importHistory(items).result;
|
|
|
|
expect(transcript.lockCalls).toBe(2);
|
|
expect(transcript.messages).toHaveLength(2);
|
|
});
|
|
|
|
it("keeps only the most recent 200 items and returns them oldest-first", async () => {
|
|
const items: TranscriptItem[] = Array.from({ length: 205 }, (_, index) => ({
|
|
id: `item-${String(index)}`,
|
|
type: "agentMessage",
|
|
text: `message-${String(index)}`,
|
|
}));
|
|
|
|
const { read, result } = importHistory(items);
|
|
await result;
|
|
|
|
expect(read).toHaveBeenCalledTimes(2);
|
|
expect(transcript.messages).toHaveLength(200);
|
|
expect(messageText(transcript.messages[0]!)).toBe("message-5");
|
|
expect(messageText(transcript.messages.at(-1)!)).toBe("message-204");
|
|
});
|
|
|
|
it("keeps the recent suffix within the 512 KiB serialized-item budget", async () => {
|
|
const items: TranscriptItem[] = Array.from({ length: 10 }, (_, index) => ({
|
|
id: `item-${String(index)}`,
|
|
type: "agentMessage",
|
|
text: `${String(index)}${"x".repeat(100 * 1024)}`,
|
|
}));
|
|
|
|
await importHistory(items, { maxPageSize: 2 }).result;
|
|
|
|
expect(transcript.messages).toHaveLength(5);
|
|
expect(messageText(transcript.messages[0]!)?.startsWith("5")).toBe(true);
|
|
expect(messageText(transcript.messages.at(-1)!)?.startsWith("9")).toBe(true);
|
|
});
|
|
|
|
it("truncates a newest item that alone exceeds the byte budget", async () => {
|
|
await importHistory([{ id: "oversized", type: "toolResult", text: "x".repeat(600 * 1024) }])
|
|
.result;
|
|
|
|
const text = messageText(transcript.messages[0]!);
|
|
expect(text).toMatch(/^Tool result\n\n/u);
|
|
expect(text).toMatch(/…$/u);
|
|
expect(Buffer.byteLength(text ?? "", "utf8")).toBeLessThan(512 * 1024);
|
|
});
|
|
|
|
it("does not let an unused raw payload hide visible history", async () => {
|
|
await importHistory([
|
|
{
|
|
id: "raw-heavy",
|
|
type: "agentMessage",
|
|
text: "visible answer",
|
|
raw: "x".repeat(600 * 1024),
|
|
},
|
|
]).result;
|
|
|
|
expect(transcript.messages.map(messageText)).toEqual(["visible answer"]);
|
|
});
|
|
|
|
it("does not open the transcript write lock when a paged read fails", async () => {
|
|
const read = vi
|
|
.fn<Parameters<typeof importSessionCatalogHistory>[0]["read"]>()
|
|
.mockResolvedValueOnce({
|
|
hostId: "gateway",
|
|
threadId: "thread-1",
|
|
items: [{ id: "a-1", type: "agentMessage", text: "latest" }],
|
|
nextCursor: "older",
|
|
})
|
|
.mockRejectedValueOnce(new Error("catalog read failed"));
|
|
|
|
await expect(importHistory([], { read }).result).rejects.toThrow("catalog read failed");
|
|
expect(transcript.lockCalls).toBe(0);
|
|
expect(transcript.messages).toEqual([]);
|
|
});
|
|
});
|