mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-17 08:02:12 -06:00
0a8e3604ba
* refactor(sessions): migrate runtime storage to sqlite * test(sessions): fix sqlite CI regressions * test(sessions): align remaining sqlite fixtures * fix(codex): require sqlite trajectory recorder * test(sessions): align orphan recovery sqlite fixture * test(sessions): align sqlite rebase fixtures * fix(sessions): finish current-main integration of the sqlite flip Resolve the whole-store SDK removal across its owner boundary: drop the loadSessionStore re-export and the registry whole-store wrappers, wire hasTrackedActiveSessionRun into gateway chat, complete the preserveLockedHarnessIds cleanup contract, flip the codex thread-history import to storePath targets, and port remaining main-side tests from file-store helpers to session accessor reads. * chore: drop committed pebbles log, revert plugin-inspector bump, refresh generated docs Remove the 1.8k-line .pebbles/events.jsonl work log from the branch, restore the plugin-inspector advisory lane to main's pinned 0.3.10 so the supply-chain bump gets its own review, and regenerate docs_map, the plugin SDK API baseline, and the export-surface ratchet for the merged tree. * feat(sessions): keep archived transcripts by default with zstd cold storage Codex-style retention: deleting or resetting a session archives its transcript as a zstd-compressed JSONL artifact (plain when the runtime lacks node:zlib zstd) and keeps it until the disk budget evicts oldest first. resetArchiveRetention now governs both deleted and reset archives and defaults to keep; maxDiskBytes defaults to 2gb so retention stays bounded, with archives evicted before live sessions. The cron reaper follows the same knob instead of deleting archives on its own timer. * fix(state): converge agent DB migration lineages and bound database growth Merge coherence: run both structure-gated legacy memory-schema repairs (flip-lineage drop, main-lineage identity rebuild) before the flip migration so pre-flip v1/v2 and pre-merge flip v1/v4 databases all converge, and hoist foreign_keys=OFF outside the schema transaction where the pragma was silently ignored and the v1 sessions rebuild cascade-deleted session_entries. Growth guards: fresh agent DBs enable auto_vacuum=INCREMENTAL, WAL maintenance releases freed pages in bounded passes (never a blocking full VACUUM), and doctor reports state/agent DB bloat from freelist stats. * fix(codex): resolve the store path for thread-history import via the SDK The supervision catalog passed the legacy sessionFile locator to the storePath-targeted transcript mirror; resolve the agent store path with the session-store SDK helper instead of a runtime-object seam so test fakes and headless callers need no extra surface. Drop the obsolete missing-session-id preprocessing case: sessions rows are NOT NULL on session_id and upsert repairs id-less patches at write time. * fix(sessions): fail safe on malformed disk-budget config and doctor stat errors A malformed explicit maxDiskBytes disables the budget instead of falling back to the destructive 2gb default the user never chose, and the doctor bloat check skips databases whose paths stat-fail instead of aborting doctor. * fix(sessions): complete sqlite conflict translations * test(sqlite): align hardening checks with maintenance * test(sessions): inspect compressed transcript archives * fix(tests): await session seeds and drop unused helpers flagged by CI lint The five unawaited writeSessionStoreSeed calls raced their SQLite seeds against the assertions, failing compact shards; the bloat probe drops a useless initializer and the merged tests drop now-unused helpers. * test(sessions): type legacy proof events directly * test(sessions): align hardening contracts * perf(sessions): read usage transcript sizes from SQL aggregates Usage/cost scans walked every session and materialized every transcript event just to re-stringify it for a byte estimate — the #86718 stall class reborn on the DB. readTranscriptStatsSync sums stored JSON bytes in SQLite without loading a single row. * fix(sessions): re-root foreign-root transcript paths onto the current sessions dir Restored backups, moved OPENCLAW_STATE_DIR, and rehearsal copies carry absolute sessionFile paths from the old root; the containment fallback kept those foreign paths, so migration read (and would archive) files in the original root and reported local copies missing. Re-root the canonical agents/<id>/sessions suffix onto the current dir when the file exists there; genuine cross-root layouts still fall through unchanged. * test(agents): seed harness admission through sqlite * fix(sqlite): close agent db on pragma setup failure * fix(doctor): compact and retrofit incremental auto-vacuum after session import The migration is the sanctioned offline window: post-import compact reclaims import churn and applies auto_vacuum=INCREMENTAL to databases created before the fresh-DB pragma existed, so runtime maintenance can release pages in bounded passes on every install. --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1197 lines
39 KiB
TypeScript
1197 lines
39 KiB
TypeScript
// Codex tests cover transcript mirror plugin behavior.
|
|
import { Buffer } from "node:buffer";
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
import {
|
|
initializeGlobalHookRunner,
|
|
resetGlobalHookRunner,
|
|
} from "openclaw/plugin-sdk/hook-runtime";
|
|
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
|
import { readSessionTranscriptEvents } from "openclaw/plugin-sdk/session-transcript-runtime";
|
|
import {
|
|
castAgentMessage,
|
|
makeAgentAssistantMessage,
|
|
makeAgentUserMessage,
|
|
} from "openclaw/plugin-sdk/test-fixtures";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { CodexThread } from "./protocol.js";
|
|
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
|
|
import {
|
|
attachCodexMirrorIdentity,
|
|
buildCodexUserPromptMessage,
|
|
importCodexThreadHistoryToTranscript,
|
|
mirrorCodexAppServerTranscript,
|
|
mirrorTranscriptBestEffort,
|
|
projectBoundedCodexThreadHistory,
|
|
} from "./transcript-mirror.js";
|
|
|
|
const publishSessionTranscriptUpdateByIdentityMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>();
|
|
return {
|
|
...actual,
|
|
publishSessionTranscriptUpdateByIdentity: publishSessionTranscriptUpdateByIdentityMock,
|
|
};
|
|
});
|
|
|
|
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
|
|
|
|
// Mirrors transcript-mirror.ts's content fingerprint exactly so test
|
|
// expectations stay in sync without exposing the helper publicly.
|
|
function expectedFingerprint(message: MirroredAgentMessage): string {
|
|
const payload = JSON.stringify({ role: message.role, content: message.content });
|
|
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
function messageContent(message: AgentMessage | undefined) {
|
|
if (!message || !("content" in message)) {
|
|
throw new Error("expected transcript message content");
|
|
}
|
|
return message.content;
|
|
}
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
resetGlobalHookRunner();
|
|
publishSessionTranscriptUpdateByIdentityMock.mockReset();
|
|
for (const dir of tempDirs.splice(0)) {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function makeRoot(prefix: string): Promise<string> {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
tempDirs.push(root);
|
|
return root;
|
|
}
|
|
|
|
describe("buildCodexUserPromptMessage", () => {
|
|
it("uses the prepared user transcript message for app-server prompt mirrors", () => {
|
|
const message = buildCodexUserPromptMessage({
|
|
prompt: "[Mon 2026-05-25 19:14 GMT+1] What is in this image?",
|
|
messageChannel: "webchat",
|
|
userTurnTranscriptRecorder: {
|
|
message: {
|
|
role: "user",
|
|
content: "What is in this image?",
|
|
timestamp: 1779732875151,
|
|
MediaPath: "/tmp/image.png",
|
|
MediaPaths: ["/tmp/image.png"],
|
|
MediaType: "image/png",
|
|
MediaTypes: ["image/png"],
|
|
},
|
|
},
|
|
} as unknown as Parameters<typeof buildCodexUserPromptMessage>[0]);
|
|
|
|
expect(message).toMatchObject({
|
|
role: "user",
|
|
content: "What is in this image?",
|
|
timestamp: 1779732875151,
|
|
sourceChannel: "webchat",
|
|
MediaPath: "/tmp/image.png",
|
|
MediaPaths: ["/tmp/image.png"],
|
|
MediaType: "image/png",
|
|
MediaTypes: ["image/png"],
|
|
});
|
|
});
|
|
});
|
|
|
|
function readEventMessages(events: unknown[]): Array<{ role?: string; text?: string }> {
|
|
return events
|
|
.map((event) =>
|
|
event && typeof event === "object" ? (event as { message?: unknown }).message : undefined,
|
|
)
|
|
.filter((message): message is { role?: string; content?: unknown } =>
|
|
Boolean(message && typeof message === "object"),
|
|
)
|
|
.map((message) => {
|
|
const content = Array.isArray(message.content)
|
|
? message.content.find((part): part is { text: string } =>
|
|
Boolean(part && typeof part === "object" && typeof part.text === "string"),
|
|
)?.text
|
|
: typeof message.content === "string"
|
|
? message.content
|
|
: undefined;
|
|
return { role: message.role, text: content };
|
|
});
|
|
}
|
|
|
|
async function createSqliteMirrorTarget(prefix: string, options: { sessionId?: string } = {}) {
|
|
const root = await makeRoot(prefix);
|
|
const agentId = "main";
|
|
const sessionId = options.sessionId ?? "session-1";
|
|
const sessionKey = `agent:${agentId}:${sessionId}`;
|
|
const storePath = path.join(root, "openclaw-agent.sqlite");
|
|
await upsertSessionEntry({
|
|
agentId,
|
|
sessionKey,
|
|
storePath,
|
|
entry: {
|
|
sessionFile: `sqlite:${agentId}:${sessionId}:${storePath}`,
|
|
sessionId,
|
|
updatedAt: 1,
|
|
},
|
|
});
|
|
return {
|
|
agentId,
|
|
sessionId,
|
|
sessionKey,
|
|
storePath,
|
|
bogusSessionFile: path.join(root, "should-not-be-created.jsonl"),
|
|
};
|
|
}
|
|
|
|
async function readMirrorEvents(target: {
|
|
agentId: string;
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
storePath: string;
|
|
}): Promise<unknown[]> {
|
|
return await readSessionTranscriptEvents(target);
|
|
}
|
|
|
|
async function readMirrorRaw(target: {
|
|
agentId: string;
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
storePath: string;
|
|
}): Promise<string> {
|
|
return (await readMirrorEvents(target)).map((event) => JSON.stringify(event)).join("\n");
|
|
}
|
|
|
|
async function readMirrorMessages(target: {
|
|
agentId: string;
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
storePath: string;
|
|
}): Promise<Array<{ role?: string; text?: string }>> {
|
|
return readEventMessages(await readMirrorEvents(target));
|
|
}
|
|
|
|
describe("importCodexThreadHistoryToTranscript", () => {
|
|
it("imports only bounded user-visible conversation items with stable identities", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-history-", {
|
|
sessionId: "session-history",
|
|
});
|
|
const sessionFile = `sqlite:${target.agentId}:${target.sessionId}:${target.storePath}`;
|
|
const thread = {
|
|
id: "thread-history",
|
|
cwd: "/workspace/project",
|
|
turns: [
|
|
{
|
|
id: "turn-1",
|
|
status: "completed",
|
|
startedAt: 1_700_000_000,
|
|
completedAt: 1_700_000_001,
|
|
items: [
|
|
{
|
|
id: "user-1",
|
|
type: "userMessage",
|
|
content: [
|
|
{ type: "text", text: "Review this image" },
|
|
{ type: "image", url: "data:image/png;base64,private" },
|
|
],
|
|
},
|
|
{
|
|
id: "reasoning-1",
|
|
type: "reasoning",
|
|
summary: ["private reasoning"],
|
|
content: ["private chain of thought"],
|
|
},
|
|
{
|
|
id: "command-1",
|
|
type: "commandExecution",
|
|
command: "print-secret",
|
|
aggregatedOutput: "private tool output",
|
|
},
|
|
{
|
|
id: "assistant-1",
|
|
type: "agentMessage",
|
|
text: "The visible answer",
|
|
phase: "final_answer",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as CodexThread;
|
|
|
|
const rawProjection = projectBoundedCodexThreadHistory({
|
|
thread,
|
|
throughTurnId: "turn-1",
|
|
importedAt: 1_800_000_000_000,
|
|
});
|
|
expect(rawProjection.responseItems).toEqual([
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "input_text", text: "Review this image\n[Image attachment]" }],
|
|
},
|
|
{
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text: "The visible answer" }],
|
|
phase: "final_answer",
|
|
},
|
|
]);
|
|
expect(JSON.stringify(rawProjection.responseItems)).not.toContain("private");
|
|
expect(JSON.stringify(rawProjection.responseItems)).not.toContain("data:image");
|
|
|
|
await expect(
|
|
importCodexThreadHistoryToTranscript({
|
|
thread,
|
|
throughTurnId: "turn-1",
|
|
storePath: target.storePath,
|
|
sessionId: "session-history",
|
|
sessionKey: target.sessionKey,
|
|
agentId: target.agentId,
|
|
}),
|
|
).resolves.toEqual({ importedMessages: 2, omittedMessages: 0 });
|
|
|
|
const events = await readMirrorEvents(target);
|
|
const raw = events.map((event) => JSON.stringify(event)).join("\n");
|
|
const messages = (events as Array<{ message?: AgentMessage; type?: string }>)
|
|
.filter((event) => event.type === "message")
|
|
.map((event) => event.message);
|
|
expect(messages).toMatchObject([
|
|
{
|
|
role: "user",
|
|
content: "Review this image\n[Image attachment]",
|
|
timestamp: 1_700_000_000_000,
|
|
idempotencyKey: "codex-app-server:thread-history:history:turn-1:user-1",
|
|
},
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "The visible answer" }],
|
|
api: "openai-chatgpt-responses",
|
|
provider: "openai",
|
|
model: "native-history",
|
|
stopReason: "stop",
|
|
timestamp: 1_700_000_001_003,
|
|
idempotencyKey: "codex-app-server:thread-history:history:turn-1:assistant-1",
|
|
},
|
|
]);
|
|
expect(raw).not.toContain("private reasoning");
|
|
expect(raw).not.toContain("private chain of thought");
|
|
expect(raw).not.toContain("private tool output");
|
|
expect(raw).not.toContain("data:image");
|
|
await expect(
|
|
readCodexMirroredSessionHistoryMessages({
|
|
sessionFile,
|
|
sessionId: "session-history",
|
|
sessionKey: target.sessionKey,
|
|
agentId: target.agentId,
|
|
}),
|
|
).resolves.toMatchObject([
|
|
{ role: "user", content: "Review this image\n[Image attachment]" },
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "The visible answer" }],
|
|
api: "openai-chatgpt-responses",
|
|
provider: "openai",
|
|
model: "native-history",
|
|
stopReason: "stop",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("keeps the newest 200 visible messages and deduplicates a retried import", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-bounded-history-", {
|
|
sessionId: "session-bounded-history",
|
|
});
|
|
const thread = {
|
|
id: "thread-bounded-history",
|
|
turns: Array.from({ length: 205 }, (_, index) => ({
|
|
id: `turn-${index}`,
|
|
status: "completed",
|
|
startedAt: 1_700_000_000 + index,
|
|
completedAt: 1_700_000_000 + index,
|
|
items: [
|
|
{
|
|
id: `user-${index}`,
|
|
type: "userMessage",
|
|
content: [{ type: "text", text: `message-${index}` }],
|
|
},
|
|
],
|
|
})),
|
|
} as unknown as CodexThread;
|
|
const importParams = {
|
|
thread,
|
|
throughTurnId: "turn-204",
|
|
storePath: target.storePath,
|
|
sessionId: "session-bounded-history",
|
|
sessionKey: target.sessionKey,
|
|
agentId: target.agentId,
|
|
};
|
|
|
|
await expect(importCodexThreadHistoryToTranscript(importParams)).resolves.toEqual({
|
|
importedMessages: 200,
|
|
omittedMessages: 5,
|
|
});
|
|
await expect(importCodexThreadHistoryToTranscript(importParams)).resolves.toEqual({
|
|
importedMessages: 200,
|
|
omittedMessages: 5,
|
|
});
|
|
|
|
const events = await readMirrorEvents(target);
|
|
const messages = (events as Array<{ message?: AgentMessage; type?: string }>)
|
|
.filter((event) => event.type === "message")
|
|
.map((event) => event.message);
|
|
expect(messages).toHaveLength(200);
|
|
expect(messages[0]).toMatchObject({ content: "message-5" });
|
|
expect(messages.at(-1)).toMatchObject({ content: "message-204" });
|
|
});
|
|
|
|
it("assigns canonical assistant attribution and numeric fallback timestamps", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-fallback-history-", {
|
|
sessionId: "session-fallback-history",
|
|
});
|
|
const sessionFile = `sqlite:${target.agentId}:${target.sessionId}:${target.storePath}`;
|
|
const thread = {
|
|
id: "thread-fallback-history",
|
|
modelProvider: "source-provider",
|
|
turns: [
|
|
{
|
|
id: "turn-without-time",
|
|
status: "completed",
|
|
items: [
|
|
{
|
|
id: "user-without-time",
|
|
type: "userMessage",
|
|
content: [{ type: "text", text: "Earlier prompt" }],
|
|
},
|
|
{
|
|
id: "assistant-without-time",
|
|
type: "agentMessage",
|
|
text: "Earlier answer",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as CodexThread;
|
|
|
|
await importCodexThreadHistoryToTranscript({
|
|
thread,
|
|
throughTurnId: "turn-without-time",
|
|
storePath: target.storePath,
|
|
sessionId: "session-fallback-history",
|
|
sessionKey: target.sessionKey,
|
|
agentId: target.agentId,
|
|
});
|
|
|
|
const history = await readCodexMirroredSessionHistoryMessages({
|
|
sessionFile,
|
|
sessionId: "session-fallback-history",
|
|
sessionKey: target.sessionKey,
|
|
agentId: target.agentId,
|
|
});
|
|
expect(history).toMatchObject([
|
|
{ role: "user", content: "Earlier prompt", timestamp: expect.any(Number) },
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Earlier answer" }],
|
|
api: "openai-chatgpt-responses",
|
|
provider: "source-provider",
|
|
model: "native-history",
|
|
usage: { totalTokens: 0 },
|
|
stopReason: "stop",
|
|
timestamp: expect.any(Number),
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("projectBoundedCodexThreadHistory", () => {
|
|
const thread = {
|
|
id: "thread-prefix",
|
|
createdAt: 1_700_000_000,
|
|
turns: [
|
|
{
|
|
id: "turn-a",
|
|
status: "completed",
|
|
startedAt: 1_700_000_001,
|
|
completedAt: 1_700_000_002,
|
|
items: [
|
|
{
|
|
id: "user-a",
|
|
type: "userMessage",
|
|
content: [{ type: "text", text: "First question" }],
|
|
},
|
|
{
|
|
id: "assistant-a",
|
|
type: "agentMessage",
|
|
text: "First answer",
|
|
phase: "commentary",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "turn-b",
|
|
status: "completed",
|
|
startedAt: 1_700_000_003,
|
|
completedAt: 1_700_000_004,
|
|
items: [
|
|
{
|
|
id: "user-b",
|
|
type: "userMessage",
|
|
content: [{ type: "text", text: "Second question" }],
|
|
},
|
|
{
|
|
id: "assistant-b",
|
|
type: "agentMessage",
|
|
text: "Second answer",
|
|
phase: "final_answer",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "turn-active",
|
|
status: "inProgress",
|
|
items: [
|
|
{
|
|
id: "active-secret",
|
|
type: "agentMessage",
|
|
text: "Do not import the active tail",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "turn-failed",
|
|
status: "failed",
|
|
items: [
|
|
{
|
|
id: "failed-secret",
|
|
type: "agentMessage",
|
|
text: "Do not import the failed tail",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as CodexThread;
|
|
|
|
it("uses one inclusive completed-turn prefix for transcript and Responses API projection", () => {
|
|
const projection = projectBoundedCodexThreadHistory({
|
|
thread,
|
|
throughTurnId: "turn-b",
|
|
importedAt: 1_800_000_000_000,
|
|
modelProvider: "native-provider",
|
|
});
|
|
|
|
expect(projection).toMatchObject({ importedMessages: 4, omittedMessages: 0 });
|
|
expect(projection.transcriptMessages.map(messageContent)).toEqual([
|
|
"First question",
|
|
[{ type: "text", text: "First answer" }],
|
|
"Second question",
|
|
[{ type: "text", text: "Second answer" }],
|
|
]);
|
|
expect(projection.transcriptMessages[1]).toMatchObject({
|
|
role: "assistant",
|
|
api: "openai-chatgpt-responses",
|
|
provider: "native-provider",
|
|
model: "native-history",
|
|
});
|
|
expect(projection.responseItems).toEqual([
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "input_text", text: "First question" }],
|
|
},
|
|
{
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text: "First answer" }],
|
|
phase: "commentary",
|
|
},
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "input_text", text: "Second question" }],
|
|
},
|
|
{
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text: "Second answer" }],
|
|
phase: "final_answer",
|
|
},
|
|
]);
|
|
expect(JSON.stringify(projection)).not.toContain("active tail");
|
|
expect(JSON.stringify(projection)).not.toContain("failed tail");
|
|
});
|
|
|
|
it("accepts terminal boundaries", () => {
|
|
for (const status of ["completed", "interrupted", "failed"]) {
|
|
const terminalThread = {
|
|
...thread,
|
|
turns: [
|
|
...(thread.turns?.slice(0, 2) ?? []),
|
|
{
|
|
id: `turn-${status}`,
|
|
status,
|
|
items: [
|
|
{
|
|
id: `assistant-${status}`,
|
|
type: "agentMessage",
|
|
text: `${status} answer`,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
} as unknown as CodexThread;
|
|
const projection = projectBoundedCodexThreadHistory({
|
|
thread: terminalThread,
|
|
throughTurnId: `turn-${status}`,
|
|
importedAt: 1_800_000_000_000,
|
|
});
|
|
expect(messageContent(projection.transcriptMessages.at(-1))).toEqual([
|
|
{ type: "text", text: `${status} answer` },
|
|
]);
|
|
}
|
|
});
|
|
|
|
it("enforces UTF-8 byte limits without splitting multibyte text", () => {
|
|
const oversizedText = `prefix-${"🙂".repeat(20_000)}-suffix`;
|
|
const oversizedThread = {
|
|
id: "thread-byte-bounds",
|
|
turns: Array.from({ length: 9 }, (_, index) => ({
|
|
id: `turn-${index}`,
|
|
status: "completed",
|
|
items: [
|
|
{
|
|
id: `user-${index}`,
|
|
type: "userMessage",
|
|
content: [{ type: "text", text: `${index}:${oversizedText}` }],
|
|
},
|
|
],
|
|
})),
|
|
} as unknown as CodexThread;
|
|
|
|
const projection = projectBoundedCodexThreadHistory({
|
|
thread: oversizedThread,
|
|
throughTurnId: "turn-8",
|
|
importedAt: 1_800_000_000_000,
|
|
});
|
|
const texts = projection.transcriptMessages.map((message) => {
|
|
const content = messageContent(message);
|
|
return typeof content === "string" ? content : "";
|
|
});
|
|
|
|
expect(projection).toMatchObject({ importedMessages: 8, omittedMessages: 1 });
|
|
expect(texts[0]).toMatch(/^1:prefix-/u);
|
|
expect(texts.every((text) => Buffer.byteLength(text, "utf8") <= 64 * 1024)).toBe(true);
|
|
expect(
|
|
texts.reduce((bytes, text) => bytes + Buffer.byteLength(text, "utf8"), 0),
|
|
).toBeLessThanOrEqual(512 * 1024);
|
|
expect(texts.every((text) => !text.includes("�"))).toBe(true);
|
|
expect(
|
|
texts.every((text) => text.endsWith("[Message truncated during Codex history import.]")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects a non-terminal or missing boundary and projects no history without one", () => {
|
|
expect(() =>
|
|
projectBoundedCodexThreadHistory({
|
|
thread,
|
|
throughTurnId: "turn-active",
|
|
importedAt: 1_800_000_000_000,
|
|
}),
|
|
).toThrow("Codex history boundary turn is not terminal: turn-active");
|
|
expect(() =>
|
|
projectBoundedCodexThreadHistory({
|
|
thread,
|
|
throughTurnId: "turn-missing",
|
|
importedAt: 1_800_000_000_000,
|
|
}),
|
|
).toThrow("Codex history boundary turn not found: turn-missing");
|
|
expect(
|
|
projectBoundedCodexThreadHistory({
|
|
thread,
|
|
throughTurnId: null,
|
|
importedAt: 1_800_000_000_000,
|
|
}),
|
|
).toEqual({
|
|
importedMessages: 0,
|
|
omittedMessages: 0,
|
|
responseItems: [],
|
|
transcriptMessages: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("mirrorCodexAppServerTranscript", () => {
|
|
it("mirrors user, assistant, and tool result messages by SQLite identity", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-basic-");
|
|
const userMessage = makeAgentUserMessage({
|
|
content: [{ type: "text", text: "hello" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
const assistantMessage = makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "hi there" }],
|
|
timestamp: Date.now() + 1,
|
|
});
|
|
const toolResultMessage = castAgentMessage({
|
|
role: "toolResult",
|
|
toolCallId: "call-1",
|
|
toolName: "read",
|
|
content: [{ type: "toolResult", toolCallId: "call-1", content: "read output" }],
|
|
timestamp: Date.now() + 2,
|
|
}) as MirroredAgentMessage;
|
|
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userMessage, assistantMessage, toolResultMessage],
|
|
idempotencyScope: "scope-1",
|
|
});
|
|
|
|
const raw = await readMirrorRaw(target);
|
|
expect(raw).toContain('"role":"user"');
|
|
expect(raw).toContain('"content":[{"type":"text","text":"hello"}]');
|
|
expect(raw).toContain('"role":"assistant"');
|
|
expect(raw).toContain('"content":[{"type":"text","text":"hi there"}]');
|
|
expect(raw).toContain('"role":"toolResult"');
|
|
expect(raw).toContain('"toolCallId":"call-1"');
|
|
expect(raw).toContain('"content":"read output"');
|
|
expect(raw).toContain(`"idempotencyKey":"scope-1:user:${expectedFingerprint(userMessage)}"`);
|
|
expect(raw).toContain(
|
|
`"idempotencyKey":"scope-1:assistant:${expectedFingerprint(assistantMessage)}"`,
|
|
);
|
|
expect(raw).toContain(
|
|
`"idempotencyKey":"scope-1:toolResult:${expectedFingerprint(toolResultMessage)}"`,
|
|
);
|
|
await expect(fs.readFile(target.bogusSessionFile, "utf8")).rejects.toHaveProperty(
|
|
"code",
|
|
"ENOENT",
|
|
);
|
|
});
|
|
|
|
it("preserves gateway user-turn identity across Codex transcript mirroring", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-user-identity-");
|
|
const userMessage = castAgentMessage({
|
|
...makeAgentUserMessage({
|
|
content: [{ type: "text", text: "client prompt" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
idempotencyKey: "client-run:user",
|
|
}) as MirroredAgentMessage;
|
|
|
|
const first = await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userMessage],
|
|
idempotencyScope: "codex-app-server:thread-1",
|
|
});
|
|
const second = await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userMessage],
|
|
idempotencyScope: "codex-app-server:thread-1",
|
|
});
|
|
|
|
const raw = await readMirrorRaw(target);
|
|
expect(raw).toContain('"idempotencyKey":"client-run:user"');
|
|
expect(raw).toContain('"mirrorOrigin":"codex-app-server"');
|
|
expect(raw).not.toContain('"idempotencyKey":"codex-app-server:thread-1:');
|
|
expect(first.userMessagesPresent).toHaveLength(1);
|
|
expect(second.userMessagesPresent).toHaveLength(1);
|
|
expect(
|
|
(await readMirrorMessages(target)).filter((message) => message.role === "user"),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("emits message-bearing updates for newly appended mirrored messages only", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-live-updates-");
|
|
const userMessage = attachCodexMirrorIdentity(
|
|
makeAgentUserMessage({
|
|
content: [{ type: "text", text: "show me live" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
"turn-1:prompt",
|
|
);
|
|
|
|
const firstMirror = await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userMessage],
|
|
idempotencyScope: "codex-app-server:thread-1",
|
|
});
|
|
const secondMirror = await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userMessage],
|
|
idempotencyScope: "codex-app-server:thread-1",
|
|
});
|
|
|
|
const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map(
|
|
([update]) => update as Record<string, unknown> & { update?: Record<string, unknown> },
|
|
);
|
|
expect(updates).toHaveLength(1);
|
|
expect(updates[0]?.sessionKey).toBe(target.sessionKey);
|
|
expect(updates[0]?.storePath).toBe(target.storePath);
|
|
expect(updates[0]?.update?.messageId).toEqual(expect.any(String));
|
|
expect(updates[0]?.update?.message).toMatchObject({
|
|
role: "user",
|
|
content: [{ type: "text", text: "show me live" }],
|
|
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
|
|
});
|
|
expect(updates[0]?.update?.messageSeq).toBe(1);
|
|
expect(firstMirror.userMessagesPresent).toHaveLength(1);
|
|
expect(firstMirror.userMessagesPresent[0]).toMatchObject({
|
|
role: "user",
|
|
content: [{ type: "text", text: "show me live" }],
|
|
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
|
|
});
|
|
expect(secondMirror.userMessagesPresent).toHaveLength(1);
|
|
expect(secondMirror.userMessagesPresent[0]).toMatchObject({
|
|
role: "user",
|
|
content: [{ type: "text", text: "show me live" }],
|
|
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
|
|
});
|
|
});
|
|
|
|
it("emits stable sequence numbers for multi-message mirror batches", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-seq-");
|
|
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [
|
|
attachCodexMirrorIdentity(
|
|
makeAgentUserMessage({
|
|
content: [{ type: "text", text: "first" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
"turn-1:prompt",
|
|
),
|
|
attachCodexMirrorIdentity(
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "second" }],
|
|
timestamp: Date.now() + 1,
|
|
}),
|
|
"turn-1:assistant",
|
|
),
|
|
],
|
|
idempotencyScope: "codex-app-server:thread-1",
|
|
});
|
|
|
|
const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map(
|
|
([update]) => update as Record<string, unknown> & { update?: Record<string, unknown> },
|
|
);
|
|
expect(updates.map((update) => update.update?.messageSeq)).toEqual([1, 2]);
|
|
expect(
|
|
updates.map((update) => {
|
|
const message = update.update?.message as { role?: string } | undefined;
|
|
return message?.role;
|
|
}),
|
|
).toEqual(["user", "assistant"]);
|
|
});
|
|
|
|
it("keeps assistant ownership when live update publication fails", async () => {
|
|
publishSessionTranscriptUpdateByIdentityMock.mockRejectedValueOnce(new Error("publish failed"));
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-publish-failure-");
|
|
const assistantMessage = attachCodexMirrorIdentity(
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "durably persisted" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
"turn-1:assistant",
|
|
);
|
|
|
|
const result = await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [assistantMessage],
|
|
idempotencyScope: "codex-app-server:thread-1",
|
|
});
|
|
|
|
expect(result.assistantMirrorIdentitiesOwned).toEqual(["turn-1:assistant"]);
|
|
expect(await readMirrorRaw(target)).toContain('"role":"assistant"');
|
|
});
|
|
|
|
it("rejects mirror writes without a runtime session identity", async () => {
|
|
await expect(
|
|
mirrorCodexAppServerTranscript({
|
|
sessionId: "session-1",
|
|
messages: [
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "no identity" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
],
|
|
}),
|
|
).rejects.toThrow("runtime session identity");
|
|
});
|
|
|
|
it("deduplicates app-server turn mirrors by idempotency scope", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-dedupe-");
|
|
const messages = [
|
|
makeAgentUserMessage({
|
|
content: [{ type: "text", text: "hello" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "hi there" }],
|
|
timestamp: Date.now() + 1,
|
|
}),
|
|
] as const;
|
|
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [...messages],
|
|
idempotencyScope: "scope-1",
|
|
});
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [...messages],
|
|
idempotencyScope: "scope-1",
|
|
});
|
|
|
|
expect((await readMirrorMessages(target)).filter((message) => message.role)).toHaveLength(2);
|
|
});
|
|
|
|
it("reports final assistant ownership for new and idempotent mirrors", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-assistant-owned-");
|
|
const assistantMessage = attachCodexMirrorIdentity(
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "owned once" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
"turn-1:assistant",
|
|
);
|
|
|
|
const firstMirror = await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [assistantMessage],
|
|
idempotencyScope: "codex-app-server:thread-1",
|
|
});
|
|
const secondMirror = await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [assistantMessage],
|
|
idempotencyScope: "codex-app-server:thread-1",
|
|
});
|
|
|
|
expect(firstMirror.assistantMirrorIdentitiesOwned).toEqual(["turn-1:assistant"]);
|
|
expect(secondMirror.assistantMirrorIdentitiesOwned).toEqual(["turn-1:assistant"]);
|
|
expect(
|
|
(await readMirrorMessages(target)).filter((message) => message.role === "assistant"),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("runs before_message_write before appending mirrored transcript messages", async () => {
|
|
initializeGlobalHookRunner(
|
|
createMockPluginRegistry([
|
|
{
|
|
hookName: "before_message_write",
|
|
handler: (event) => ({
|
|
message: castAgentMessage({
|
|
...((event as { message: unknown }).message as Record<string, unknown>),
|
|
content: [{ type: "text", text: "hello [hooked]" }],
|
|
}),
|
|
}),
|
|
},
|
|
]),
|
|
);
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-hook-");
|
|
const sourceMessage = makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "hello" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [sourceMessage],
|
|
idempotencyScope: "scope-1",
|
|
});
|
|
|
|
const raw = await readMirrorRaw(target);
|
|
expect(raw).toContain('"content":[{"type":"text","text":"hello [hooked]"}]');
|
|
expect(raw).toContain(
|
|
`"idempotencyKey":"scope-1:assistant:${expectedFingerprint(sourceMessage)}"`,
|
|
);
|
|
});
|
|
|
|
it("returns the persisted user message for duplicate mirror hits", async () => {
|
|
initializeGlobalHookRunner(
|
|
createMockPluginRegistry([
|
|
{
|
|
hookName: "before_message_write",
|
|
handler: (event) => ({
|
|
message: castAgentMessage({
|
|
...((event as { message: unknown }).message as Record<string, unknown>),
|
|
content: [{ type: "text", text: "[redacted by hook]" }],
|
|
}),
|
|
}),
|
|
},
|
|
]),
|
|
);
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-duplicates-");
|
|
const sourceMessage = makeAgentUserMessage({
|
|
content: [{ type: "text", text: "secret prompt" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
const first = await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [sourceMessage],
|
|
idempotencyScope: "scope-1",
|
|
});
|
|
const second = await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [sourceMessage],
|
|
idempotencyScope: "scope-1",
|
|
});
|
|
|
|
expect(first.userMessagesPresent[0]?.content).toEqual([
|
|
{ type: "text", text: "[redacted by hook]" },
|
|
]);
|
|
expect(second.userMessagesPresent[0]?.content).toEqual([
|
|
{ type: "text", text: "[redacted by hook]" },
|
|
]);
|
|
expect(JSON.stringify(second.userMessagesPresent)).not.toContain("secret prompt");
|
|
expect(
|
|
(await readMirrorMessages(target)).filter((message) => message.role === "user"),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("preserves the computed idempotency key when hooks rewrite message keys", async () => {
|
|
initializeGlobalHookRunner(
|
|
createMockPluginRegistry([
|
|
{
|
|
hookName: "before_message_write",
|
|
handler: (event) => ({
|
|
message: castAgentMessage({
|
|
...((event as { message: unknown }).message as Record<string, unknown>),
|
|
idempotencyKey: "hook-rewritten-key",
|
|
}),
|
|
}),
|
|
},
|
|
]),
|
|
);
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-key-hook-");
|
|
const sourceMessage = makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "hello" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [sourceMessage],
|
|
idempotencyScope: "scope-1",
|
|
});
|
|
|
|
const raw = await readMirrorRaw(target);
|
|
expect(raw).toContain(
|
|
`"idempotencyKey":"scope-1:assistant:${expectedFingerprint(sourceMessage)}"`,
|
|
);
|
|
expect(raw).not.toContain("hook-rewritten-key");
|
|
});
|
|
|
|
it("respects before_message_write blocking decisions", async () => {
|
|
initializeGlobalHookRunner(
|
|
createMockPluginRegistry([
|
|
{ hookName: "before_message_write", handler: () => ({ block: true }) },
|
|
]),
|
|
);
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-blocked-");
|
|
|
|
const result = await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [
|
|
attachCodexMirrorIdentity(
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "should not persist" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
"turn-1:assistant",
|
|
),
|
|
],
|
|
idempotencyScope: "scope-1",
|
|
});
|
|
|
|
expect(result.assistantMirrorIdentitiesOwned).toEqual(["turn-1:assistant"]);
|
|
expect(await readMirrorMessages(target)).toEqual([]);
|
|
});
|
|
|
|
it("leaves the assistant unowned when transcript persistence fails", async () => {
|
|
const root = await makeRoot("openclaw-codex-transcript-failure-");
|
|
const assistantMessage = attachCodexMirrorIdentity(
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "needs fallback persistence" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
"turn-1:assistant",
|
|
);
|
|
|
|
const assistantTranscriptOwned = await mirrorTranscriptBestEffort({
|
|
params: {
|
|
sessionId: "session-1",
|
|
suppressNextUserMessagePersistence: true,
|
|
} as Parameters<typeof mirrorTranscriptBestEffort>[0]["params"],
|
|
result: {
|
|
messagesSnapshot: [assistantMessage],
|
|
} as Parameters<typeof mirrorTranscriptBestEffort>[0]["result"],
|
|
notifyUserMessagePersisted: () => undefined,
|
|
cwd: root,
|
|
threadId: "thread-1",
|
|
turnId: "turn-1",
|
|
});
|
|
|
|
expect(assistantTranscriptOwned).toBe(false);
|
|
});
|
|
|
|
it("dedupes mirrored messages despite snapshot positional shifts", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-shift-");
|
|
const userMessage = attachCodexMirrorIdentity(
|
|
makeAgentUserMessage({
|
|
content: [{ type: "text", text: "hello" }],
|
|
timestamp: Date.now(),
|
|
}),
|
|
"turn-1:prompt",
|
|
);
|
|
const assistantMessage = attachCodexMirrorIdentity(
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "hi there" }],
|
|
timestamp: Date.now() + 1,
|
|
}),
|
|
"turn-1:assistant",
|
|
);
|
|
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userMessage, assistantMessage],
|
|
idempotencyScope: "codex-app-server:thread-X",
|
|
});
|
|
const reasoningMessage = attachCodexMirrorIdentity(
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "[Codex reasoning] thinking" }],
|
|
timestamp: Date.now() + 2,
|
|
}),
|
|
"turn-1:reasoning",
|
|
);
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userMessage, reasoningMessage, assistantMessage],
|
|
idempotencyScope: "codex-app-server:thread-X",
|
|
});
|
|
|
|
expect((await readMirrorMessages(target)).map((m) => m.text)).toEqual([
|
|
"hello",
|
|
"hi there",
|
|
"[Codex reasoning] thinking",
|
|
]);
|
|
});
|
|
|
|
it("keeps repeated same-content turns distinct", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-repeat-");
|
|
const userTurn1 = attachCodexMirrorIdentity(
|
|
makeAgentUserMessage({ content: [{ type: "text", text: "yes" }], timestamp: Date.now() }),
|
|
"turn-1:prompt",
|
|
);
|
|
const assistantTurn1 = attachCodexMirrorIdentity(
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "ok 1" }],
|
|
timestamp: Date.now() + 1,
|
|
}),
|
|
"turn-1:assistant",
|
|
);
|
|
const userTurn2 = attachCodexMirrorIdentity(
|
|
makeAgentUserMessage({ content: [{ type: "text", text: "yes" }], timestamp: Date.now() + 2 }),
|
|
"turn-2:prompt",
|
|
);
|
|
const assistantTurn2 = attachCodexMirrorIdentity(
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "ok 2" }],
|
|
timestamp: Date.now() + 3,
|
|
}),
|
|
"turn-2:assistant",
|
|
);
|
|
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userTurn1, assistantTurn1],
|
|
idempotencyScope: "codex-app-server:thread-X",
|
|
});
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userTurn2, assistantTurn2],
|
|
idempotencyScope: "codex-app-server:thread-X",
|
|
});
|
|
|
|
expect(await readMirrorMessages(target)).toEqual([
|
|
{ role: "user", text: "yes" },
|
|
{ role: "assistant", text: "ok 1" },
|
|
{ role: "user", text: "yes" },
|
|
{ role: "assistant", text: "ok 2" },
|
|
]);
|
|
});
|
|
|
|
it("dedupes prior-turn entries re-emitted into a later turn's snapshot", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-reemit-");
|
|
const userTurn1 = attachCodexMirrorIdentity(
|
|
makeAgentUserMessage({ content: [{ type: "text", text: "msg1" }], timestamp: Date.now() }),
|
|
"turn-1:prompt",
|
|
);
|
|
const assistantTurn1 = attachCodexMirrorIdentity(
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "reply1" }],
|
|
timestamp: Date.now() + 1,
|
|
}),
|
|
"turn-1:assistant",
|
|
);
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userTurn1, assistantTurn1],
|
|
idempotencyScope: "codex-app-server:thread-X",
|
|
});
|
|
|
|
const userTurn2 = attachCodexMirrorIdentity(
|
|
makeAgentUserMessage({
|
|
content: [{ type: "text", text: "msg2" }],
|
|
timestamp: Date.now() + 2,
|
|
}),
|
|
"turn-2:prompt",
|
|
);
|
|
const assistantTurn2 = attachCodexMirrorIdentity(
|
|
makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "reply2" }],
|
|
timestamp: Date.now() + 3,
|
|
}),
|
|
"turn-2:assistant",
|
|
);
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userTurn1, assistantTurn1, userTurn2, assistantTurn2],
|
|
idempotencyScope: "codex-app-server:thread-X",
|
|
});
|
|
|
|
expect(await readMirrorMessages(target)).toEqual([
|
|
{ role: "user", text: "msg1" },
|
|
{ role: "assistant", text: "reply1" },
|
|
{ role: "user", text: "msg2" },
|
|
{ role: "assistant", text: "reply2" },
|
|
]);
|
|
});
|
|
|
|
it("uses the role+content fingerprint when no identity is attached", async () => {
|
|
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-fingerprint-");
|
|
const userMessage = makeAgentUserMessage({
|
|
content: [{ type: "text", text: "hello" }],
|
|
timestamp: Date.now(),
|
|
});
|
|
const assistantMessage = makeAgentAssistantMessage({
|
|
content: [{ type: "text", text: "hi there" }],
|
|
timestamp: Date.now() + 1,
|
|
});
|
|
|
|
await mirrorCodexAppServerTranscript({
|
|
...target,
|
|
messages: [userMessage, assistantMessage],
|
|
idempotencyScope: "scope-1",
|
|
});
|
|
|
|
const raw = await readMirrorRaw(target);
|
|
expect(raw).toContain(`"idempotencyKey":"scope-1:user:${expectedFingerprint(userMessage)}"`);
|
|
expect(raw).toContain(
|
|
`"idempotencyKey":"scope-1:assistant:${expectedFingerprint(assistantMessage)}"`,
|
|
);
|
|
});
|
|
});
|