Files
openclaw/src/sessions/user-turn-transcript.test.ts
Peter Steinberger 4273ca9dbd refactor(sessions): remove file-era transcript runtime (#113233)
* refactor(sessions): keep helper transcripts in memory

* refactor(sessions): remove file-era transcript storage

* test(sessions): use SQLite identity in attempt persistence

* test(codex): isolate legacy transcript fixtures

* fix(sessions): preserve SQLite transcript identity

* fix(sessions): harden transcript lifecycle invariants

* fix(sessions): validate transcript identities

* fix(sessions): close identity compatibility gaps

* fix(sessions): preserve leaf and plugin identities

* fix(sessions): retain dispatch transcript targets

* fix(sessions): preserve active transcript context

* fix(sessions): isolate artifact accounting

* fix(sessions): bound SQLite usage accounting

* fix(sessions): retain bounded latest usage

* fix(sessions): align rebased transcript targets

* test(sessions): align accessor scope fixture

* fix(telegram): derive SQLite transcript identity

* refactor(sessions): remove file-era compaction residue

* chore(sessions): lower max-lines baseline

* fix(sessions): preserve structured transcript identity

* test(sessions): align doctor identity assertions

* fix(sessions): isolate default SDK database

* refactor(sessions): remove dead file-era exports

* fix(sessions): reconcile SQLite transcript identity

* fix(sessions): pass checkpoint identity explicitly

* test(sessions): make entry field probe explicit

* test(sessions): satisfy transcript cleanup lint

* test(sessions): align diagnostics identity proof

* fix(sessions): finish transcript runtime teardown

* fix(sessions): preserve transcript identity invariants

* fix(sessions): harden transcript compatibility edges

* fix(sessions): preserve checkpoint transcript anchors

* fix(sessions): preserve SQLite lifecycle invariants

* fix(sessions): retarget compaction successors

* test(sessions): preserve transcript fixture semantics

* feat(plugin-sdk): add command transcript targets

* fix(sessions): serialize transcript rewrites

* fix(sessions): validate legacy successor identity

* fix(sessions): normalize compaction ownership

* fix(sessions): validate successor identity before adoption

* fix(sessions): preserve plugin transcript ownership

* fix(sessions): carry transcript identity through commands

* fix(sessions): import legacy checkpoint artifacts into SQLite

* fix(sessions): preserve successor transcript ownership

* fix(sessions): align transcript consumers with target identity

* fix(sessions): scope transcript token estimates

* fix(sessions): retain agent identity across lifecycle hooks

* fix(sessions): resolve scoped SQLite targets

* fix(sessions): isolate lifecycle transcript targets

* fix(sessions): validate compaction agent ownership

* fix(sessions): preserve reset and cleanup lifecycle

* fix(sessions): serialize prompt cleanup lifecycle

* fix(sessions): remove stale lock import

* fix(sessions): preserve reset target context

* fix(sessions): fence prompt reload takeover

* fix(sessions): unblock abort and default lifecycle reads

* fix(sessions): validate legacy successor scope

* fix(sessions): reject metadata-only runtime rows

* fix(sessions): propagate custom transcript stores

* fix(sessions): preserve adopted retry targets

* fix(sessions): allow unkeyed usage reads

* fix(sessions): harden runtime target boundaries

* fix(sessions): serialize retry transcript writes

* fix(sessions): bound prompt reload disposal

* fix(sessions): complete retry marker identity

* fix(sessions): keep legacy marker identity minimal

* test(sessions): tighten teardown fixture types

* fix(sessions): preserve compatibility target identity

* test(sessions): persist post-checkpoint boundary turn

* test(sessions): align runtime store mock contracts

* style(sessions): simplify persisted identity guard

* fix(sessions): prefer complete typed targets

* fix(sessions): recover legacy marker targets

* test(sessions): align marker lookup fixture scope

* fix(sessions): validate partial transcript targets

* fix(sessions): reconcile partial transcript identities

* fix(sessions): canonicalize compatibility identities

* test(sessions): cover compatibility aliases

* fix(sessions): adopt legacy successor identity

* fix(sessions): preserve usage read identity

* fix(sessions): preserve partial marker compatibility

* fix(sessions): validate legacy successor mappings

* fix(sessions): reconcile marker store mappings

* fix(sessions): preserve legacy fallback identity

* fix(sessions): harden marker alias resolution

* fix(sessions): prefer verified successor aliases

* fix(sessions): resolve preferred marker aliases

* fix(sessions): serialize cleanup admission

* fix(sessions): align marker lookup scopes

* fix(codex): type marker alias summaries

* style(sessions): satisfy changed lint

* test(sessions): align structured target assertions

* fix(sessions): reconcile latest identity contracts

* fix(sessions): validate transcript identity boundaries

* docs(sessions): explain stable registry keys

* fix(sessions): harden compatibility target round trips

* fix(sessions): port usage identity to split modules

* test(sessions): align subagent transcript identity

* fix(sessions): finish transcript identity migration

* fix(agents): route subagent completion capture through transcript targets

* fix(agents): settle SQLite prompt handoff during cleanup

* chore: shrink max-lines baseline after teardown

* fix(sessions): port teardown across split runtime owners

* fix(sessions): carry transcript targets through split owners

* test(agents): use SQLite compaction target in abort coverage

* chore: retain unrelated max-lines suppressions

* chore: shrink max-lines baseline after main splits

* style(agents): const compaction checkpoint locals

* fix(sessions): harden SQLite teardown boundaries

* test(sessions): use typed metadata in predicate isolation fixture

* test(agents): cover malformed settlement rejections lint-safely

* fix(sessions): close remaining SQLite identity races

* fix(agents): fail closed on incomplete successor targets

* fix(sessions): preserve transcript identity fallbacks

* fix(agents): preserve session-key abort admission

* fix(trajectory): validate incomplete export targets

* test(sessions): drop retired pricing cache imports

* fix(sessions): validate partial transcript identities

* fix(sessions): close transcript identity edge cases

* fix(plugins): reserve retired transcript locator slot

* fix(sessions): scope transcript locks by target

* style(sessions): simplify SDK initialization error

* fix(sessions): preserve initialized transcript state

* fix(codex): verify mirrored history session keys

* fix(sessions): reject stale transcript ownership

* fix(sessions): anchor asynchronous transcript ownership

* fix(sessions): measure active transcript state

* fix(sessions): preserve scoped transcript compaction

* fix(sessions): harden transcript identity and lifecycle

* fix(sessions): resolve scoped command transcript stores

* fix(sessions): make transcript appends failure-atomic

* fix(sessions): enforce scoped transcript ownership

* fix(sessions): reject cross-owner transcript handoffs

* fix(sessions): fence cleanup transcript ownership

* fix(sessions): retire stale write ownership contexts

* fix(sessions): preserve pending session migration state

* fix(sessions): validate migrated transcript ownership

* fix(sessions): validate usage transcript targets

* fix(sessions): clear predecessor transcript metadata

* fix(sessions): align durable session event targets

* fix(sessions): fence late prompt handoffs

* fix(sessions): fence lifecycle transcript fallbacks

* fix(sessions): bound zero-length memory capture

* fix(sessions): preserve transcript teardown ownership

* fix(sessions): reject duplicate cleanup ownership

* fix(sessions): serialize runtime writes with sqlite leases

* fix(sessions): close sqlite teardown concurrency gaps

* fix(sessions): preserve nested lifecycle failures

* fix(sessions): canonicalize sqlite transcript ownership

* fix(sessions): settle disposed prompt handoffs

* fix(sessions): resolve canonical attempt lock targets

* test(sessions): align canonical target fixtures

* test(sessions): retire redundant jsonl parser coverage

* refactor(sessions): split active transcript cursors

* test(memory): retire legacy marker fixture

* fix(sessions): preserve canonical transcript access after rebase

* fix(sessions): fence prompt lease and return transcript targets

* fix(sessions): colocate transcript leases with target store

* fix(sessions): canonicalize transcript lease and worker targets

* fix(sessions): preserve plugin and fork identity markers

* fix(sessions): complete sqlite transcript target migration

* fix(sessions): integrate canonical followup identity

* fix(sessions): preserve bounded transcript topology

* fix(sessions): validate transcript identity boundaries

* fix(context): separate caller and successor targets

* test(sessions): split persistence compatibility coverage

* test(sessions): preserve fixture topology efficiently

* chore(sdk): refresh plugin api baseline

* test(agents): align compaction lock target mocks

* test(sessions): seed malformed transcript fixtures directly

* fix(agents): canonicalize transcript compatibility inputs

* fix(agents): type optional tool result ids

* test(ci): stabilize loaded process timing

* test(tui): wait for collect queue admission
2026-07-27 22:33:24 -04:00

878 lines
28 KiB
TypeScript

// User turn transcript tests cover transcript extraction for user turns.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { castAgentMessage } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it } from "vitest";
import { formatSqliteSessionFileMarker } from "../config/sessions/legacy-sqlite-marker.js";
import { loadTranscriptEvents } from "../config/sessions/session-accessor.js";
import {
buildLateMediaAttachedProjection,
createUserTurnTranscriptRecorder,
mergePreparedUserTurnMessageForRuntime,
resolvePersistedUserTurnText,
type UserTurnInput,
} from "./user-turn-transcript.js";
import { persistUserTurnTranscript } from "./user-turn-transcript.test-support.js";
describe("user turn transcript persistence", () => {
const tempDirs: string[] = [];
const unusedRecorderTarget = {
agentId: "main",
sessionEntry: undefined,
sessionId: "unused-session",
sessionKey: "agent:main:unused",
storePath: "/tmp/openclaw-unused-sessions.json",
};
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function createTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function createSqliteTranscriptTarget(params: {
dir: string;
sessionId?: string;
sessionKey?: string;
}) {
const sessionId = params.sessionId ?? "session-1";
const sessionKey = params.sessionKey ?? "agent:main:main";
const storePath = path.join(params.dir, "agents", "main", "sessions", "sessions.json");
fs.mkdirSync(path.dirname(storePath), { recursive: true });
const sqliteMarker = formatSqliteSessionFileMarker({
agentId: "main",
sessionId,
storePath,
});
return {
agentId: "main",
cwd: params.dir,
sessionEntry: undefined,
sessionId,
sessionKey,
storePath,
sqliteMarker,
};
}
async function readTranscriptMessages(params: {
sessionId: string;
sessionKey: string;
storePath: string;
}): Promise<Array<Record<string, unknown>>> {
return (
await loadTranscriptEvents({
agentId: "main",
sessionId: params.sessionId,
sessionKey: params.sessionKey,
storePath: params.storePath,
})
)
.map((entry) => (entry as { message?: unknown }).message)
.filter(
(message): message is Record<string, unknown> =>
typeof message === "object" && message !== null,
);
}
describe("mergePreparedUserTurnMessageForRuntime", () => {
it("adds prepared transcript metadata to runtime user messages", () => {
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "display prompt",
media: [{ path: "/tmp/image.png", contentType: "image/png" }],
timestamp: 123,
},
target: unusedRecorderTarget,
});
expect(
mergePreparedUserTurnMessageForRuntime({
runtimeMessage: castAgentMessage({
role: "user",
content: "runtime prompt",
provenance: { sourceChannel: "telegram" },
}),
preparedMessage: recorder.message,
}),
).toMatchObject({
role: "user",
content: "display prompt",
provenance: { sourceChannel: "telegram" },
timestamp: 123,
__openclaw: {
media: [expect.objectContaining({ path: "/tmp/image.png", contentType: "image/png" })],
},
});
});
it("preserves runtime metadata when adding prepared sender attribution", () => {
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "group prompt",
sender: { id: "user-42", name: "Ada" },
},
target: unusedRecorderTarget,
});
expect(
mergePreparedUserTurnMessageForRuntime({
runtimeMessage: castAgentMessage({
role: "user",
content: "runtime prompt",
__openclaw: { mirrorIdentity: "run-1:prompt" },
}),
preparedMessage: recorder.message,
}),
).toMatchObject({
__openclaw: {
mirrorIdentity: "run-1:prompt",
senderId: "user-42",
senderName: "Ada",
},
});
});
it("does not replace blocked before_agent_run user markers", () => {
const recorder = createUserTurnTranscriptRecorder({
input: { text: "raw prompt" },
target: unusedRecorderTarget,
});
const blocked = castAgentMessage({
role: "user",
content: "[blocked]",
__openclaw: { beforeAgentRunBlocked: true },
});
expect(
mergePreparedUserTurnMessageForRuntime({
runtimeMessage: blocked,
preparedMessage: recorder.message,
}),
).toBe(blocked);
});
it("preserves runtime multimodal content while merging prepared metadata", () => {
const recorder = createUserTurnTranscriptRecorder({
input: { text: "canonical image caption", timestamp: 123 },
target: unusedRecorderTarget,
});
const runtimeContent = [
{ type: "text", text: "canonical image caption" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
];
expect(
mergePreparedUserTurnMessageForRuntime({
runtimeMessage: castAgentMessage({
role: "user",
content: runtimeContent,
}),
preparedMessage: recorder.message,
}),
).toMatchObject({
role: "user",
content: runtimeContent,
timestamp: 123,
});
});
it("does not apply prepared user metadata to assistant messages", () => {
const recorder = createUserTurnTranscriptRecorder({
input: { text: "display prompt" },
target: unusedRecorderTarget,
});
const assistant = castAgentMessage({ role: "assistant", content: "hello" });
expect(
mergePreparedUserTurnMessageForRuntime({
runtimeMessage: assistant,
preparedMessage: recorder.message,
}),
).toBe(assistant);
});
});
describe("resolvePersistedUserTurnText", () => {
it("normalizes the selected clean user-turn transcript text", () => {
expect(resolvePersistedUserTurnText(" What is in this image? ")).toBe(
"What is in this image?",
);
});
it("preserves historical placeholder-like text as ordinary transcript content", () => {
expect(resolvePersistedUserTurnText("<media:image> (2 images)")).toBe(
"<media:image> (2 images)",
);
});
});
describe("persistUserTurnTranscript", () => {
it("resolves the session file and persists the user turn", async () => {
const dir = createTempDir("openclaw-user-turn-persist-");
const target = createSqliteTranscriptTarget({ dir });
const sessionStore = {
[target.sessionKey]: {
sessionId: target.sessionId,
sessionFile: target.sqliteMarker,
updatedAt: 1,
},
};
const persisted = await persistUserTurnTranscript({
sessionId: target.sessionId,
sessionKey: target.sessionKey,
sessionEntry: sessionStore[target.sessionKey],
sessionStore,
storePath: target.storePath,
agentId: target.agentId,
cwd: dir,
input: {
text: "hello",
timestamp: 123,
},
updateMode: "none",
});
expect(persisted?.sessionFile).toBe(target.sessionKey);
await expect(readTranscriptMessages(target)).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "hello",
}),
]);
});
});
describe("createUserTurnTranscriptRecorder", () => {
it("accepts and normalizes provider-defined persisted media kinds", () => {
const input: UserTurnInput = {
text: "inspect this attachment",
media: [{ path: " /tmp/provider-media.bin ", kind: " provider/custom-media " }],
};
const recorder = createUserTurnTranscriptRecorder({
input,
target: unusedRecorderTarget,
});
expect(recorder.message).toMatchObject({
__openclaw: {
media: [
expect.objectContaining({
path: "/tmp/provider-media.bin",
contentType: "provider/custom-media",
}),
],
},
});
});
it("persists fallback user turns only once", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-fallback-");
const target = createSqliteTranscriptTarget({ dir });
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "hello from fallback",
timestamp: 123,
idempotencyKey: "chat-run-1:user",
},
target: {
...target,
},
updateMode: "none",
});
expect(recorder.getPersistedMessage?.()).toBeUndefined();
const [first, second] = await Promise.all([
recorder.persistFallback(),
recorder.persistFallback(),
]);
expect(first?.messageId).toBeTruthy();
expect(second?.messageId).toBe(first?.messageId);
expect(recorder.getPersistedMessage?.()).toEqual(first?.message);
await expect(readTranscriptMessages(target)).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "hello from fallback",
idempotencyKey: "chat-run-1:user",
}),
]);
});
it("notifies once after fallback user-turn persistence", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-notify-");
const target = createSqliteTranscriptTarget({ dir });
const persistedMessages: unknown[] = [];
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "#35676 Keśava: No wtf",
timestamp: 123,
idempotencyKey: "chat-run-ambient:user",
},
target: {
...target,
},
updateMode: "none",
onMessagePersisted: (message) => {
persistedMessages.push(message);
},
});
await recorder.persistFallback();
await recorder.persistFallback();
expect(persistedMessages).toEqual([
expect.objectContaining({
role: "user",
content: "#35676 Keśava: No wtf",
}),
]);
await expect(readTranscriptMessages(target)).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "#35676 Keśava: No wtf",
}),
]);
});
it("resolves media lazily at persistence time", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-lazy-media-");
const target = createSqliteTranscriptTarget({ dir });
let resolverCalled = false;
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "describe this",
timestamp: 123,
idempotencyKey: "chat-run-lazy:user",
},
resolveInput: async () => {
resolverCalled = true;
return {
text: "describe this",
timestamp: 123,
idempotencyKey: "chat-run-lazy:user",
media: [{ path: path.join(dir, "image.png"), contentType: "image/png" }],
};
},
target: {
...target,
},
updateMode: "none",
});
expect(recorder.message).toEqual(
expect.objectContaining({
role: "user",
content: "describe this",
idempotencyKey: "chat-run-lazy:user",
}),
);
expect(recorder.message).not.toHaveProperty("MediaPath");
expect(resolverCalled).toBe(false);
const persisted = await recorder.persistFallback();
expect(resolverCalled).toBe(true);
expect(persisted?.message).toMatchObject({
role: "user",
content: "describe this",
__openclaw: {
media: [
expect.objectContaining({
path: path.join(dir, "image.png"),
contentType: "image/png",
}),
],
},
});
await expect(readTranscriptMessages(target)).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "describe this",
__openclaw: {
media: [expect.objectContaining({ path: path.join(dir, "image.png") })],
},
}),
]);
});
it("appends #99495 media that resolves after the admitted turn reached the provider", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-late-media-");
const target = createSqliteTranscriptTarget({ dir });
const admittedInput = {
text: "describe this",
timestamp: 123,
idempotencyKey: "chat-run-late:user",
};
let resolveMedia!: (input: UserTurnInput) => void;
let markResolverStarted!: () => void;
const resolverStarted = new Promise<void>((resolve) => {
markResolverStarted = resolve;
});
const mediaInput = new Promise<UserTurnInput>((resolve) => {
resolveMedia = resolve;
});
const recorder = createUserTurnTranscriptRecorder({
input: admittedInput,
resolveInput: async () => {
markResolverStarted();
return await mediaInput;
},
beforeMessageWrite: ({ message }) =>
castAgentMessage({
...(message as unknown as Record<string, unknown>),
__openclaw: { hookOwned: true },
}),
target: {
...target,
},
});
const persistence = recorder.persistFallback();
await resolverStarted;
await persistUserTurnTranscript({
...target,
input: admittedInput,
});
recorder.markRuntimePersisted(recorder.message);
recorder.markSentToProvider?.();
resolveMedia({
...admittedInput,
media: [{ path: path.join(dir, "image.png"), contentType: "image/png" }],
});
await persistence;
const messages = await readTranscriptMessages(target);
expect(messages).toEqual([
expect.objectContaining({
content: "describe this",
idempotencyKey: "chat-run-late:user",
}),
expect.objectContaining({
content: "",
idempotencyKey: "chat-run-late:user:late-media",
__openclaw: {
hookOwned: true,
lateMedia: true,
media: [expect.objectContaining({ path: path.join(dir, "image.png") })],
},
}),
]);
const lateProjection = buildLateMediaAttachedProjection(castAgentMessage(messages[1]));
expect(lateProjection.text).toBe(`[media attached: ${path.join(dir, "image.png")}]`);
expect(lateProjection.media).toEqual([
expect.objectContaining({
path: path.join(dir, "image.png"),
contentType: "image/png",
kind: "image",
}),
]);
});
it("preserves distinct text supplied with late-resolved media", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-late-caption-");
const target = createSqliteTranscriptTarget({ dir });
const admittedInput = {
text: "describe this",
timestamp: 123,
idempotencyKey: "chat-run-late-caption:user",
};
let resolveMedia!: (input: UserTurnInput) => void;
let markResolverStarted!: () => void;
const resolverStarted = new Promise<void>((resolve) => {
markResolverStarted = resolve;
});
const recorder = createUserTurnTranscriptRecorder({
input: admittedInput,
resolveInput: async () => {
markResolverStarted();
return await new Promise<UserTurnInput>((resolve) => {
resolveMedia = resolve;
});
},
target,
});
const persistence = recorder.persistFallback();
await resolverStarted;
await persistUserTurnTranscript({ ...target, input: admittedInput });
recorder.markRuntimePersisted(recorder.message);
recorder.markSentToProvider?.();
resolveMedia({
...admittedInput,
text: "resolved subtitle",
media: [{ path: path.join(dir, "image.png"), contentType: "image/png" }],
});
await persistence;
await expect(readTranscriptMessages(target)).resolves.toEqual([
expect.objectContaining({ content: "describe this" }),
expect.objectContaining({
content: "resolved subtitle",
__openclaw: {
lateMedia: true,
media: [{ path: path.join(dir, "image.png"), contentType: "image/png" }],
},
}),
]);
});
it("keeps #99495 media inline when it resolves before first serialization", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-early-media-");
const target = createSqliteTranscriptTarget({ dir });
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "describe this",
timestamp: 123,
idempotencyKey: "chat-run-early:user",
},
resolveInput: async () => ({
text: "describe this",
timestamp: 123,
idempotencyKey: "chat-run-early:user",
media: [{ path: path.join(dir, "image.png"), contentType: "image/png" }],
}),
target: {
...target,
},
});
await recorder.persistFallback();
recorder.markSentToProvider?.();
await expect(readTranscriptMessages(target)).resolves.toEqual([
expect.objectContaining({
content: "describe this",
idempotencyKey: "chat-run-early:user",
__openclaw: {
media: [expect.objectContaining({ path: path.join(dir, "image.png") })],
},
}),
]);
});
it("falls back to the admitted text message when lazy media resolution fails", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-lazy-failed-");
const target = createSqliteTranscriptTarget({ dir });
const errors: unknown[] = [];
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "keep the prompt",
timestamp: 123,
idempotencyKey: "chat-run-lazy-failed:user",
},
resolveInput: async () => {
throw new Error("media staging failed");
},
target: {
...target,
},
updateMode: "none",
onPersistenceError: (error) => errors.push(error),
});
const persisted = await recorder.persistFallback();
expect(errors).toHaveLength(1);
expect(persisted?.message).toMatchObject({
role: "user",
content: "keep the prompt",
idempotencyKey: "chat-run-lazy-failed:user",
});
expect(persisted?.message).not.toHaveProperty("MediaPath");
await expect(readTranscriptMessages(target)).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "keep the prompt",
idempotencyKey: "chat-run-lazy-failed:user",
}),
]);
});
it("does not fallback-persist after runtime persistence is marked", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-runtime-");
const target = createSqliteTranscriptTarget({ dir });
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "runtime-owned turn",
timestamp: 123,
},
target: {
...target,
},
updateMode: "none",
});
recorder.markRuntimePersisted({
role: "user",
content: "runtime-owned turn",
timestamp: 123,
});
await expect(recorder.persistFallback()).resolves.toBeUndefined();
await expect(readTranscriptMessages(target)).resolves.toEqual([]);
});
it("approved persistence skips file targets after runtime persistence is marked", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-runtime-approved-");
const target = createSqliteTranscriptTarget({ dir });
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "runtime-owned turn",
timestamp: 123,
},
target: {
...target,
},
updateMode: "none",
});
recorder.markRuntimePersisted({
role: "user",
content: "runtime-owned turn",
timestamp: 123,
});
await expect(recorder.persistApproved()).resolves.toBeUndefined();
await expect(readTranscriptMessages(target)).resolves.toEqual([]);
});
it("approved persistence does not duplicate runtime-owned SQLite turns", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-runtime-canonical-");
const storePath = path.join(dir, "sessions.json");
const sessionStore = {};
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "runtime-owned turn",
timestamp: 123,
},
target: {
agentId: "main",
sessionEntry: undefined,
sessionId: "session-1",
sessionKey: "agent:main:main",
sessionStore,
storePath,
},
updateMode: "none",
});
recorder.markRuntimePersisted({
role: "user",
content: "runtime-owned turn",
timestamp: 123,
});
await expect(recorder.persistApproved()).resolves.toBeUndefined();
await expect(
readTranscriptMessages({
sessionId: "session-1",
sessionKey: "agent:main:main",
storePath,
}),
).resolves.toEqual([]);
});
it("does not fallback-persist after before_agent_run blocks the turn", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-blocked-");
const target = createSqliteTranscriptTarget({ dir });
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "raw blocked prompt",
timestamp: 123,
},
target: {
...target,
},
updateMode: "none",
});
recorder.markBlocked();
await expect(recorder.persistFallback()).resolves.toBeUndefined();
await expect(readTranscriptMessages(target)).resolves.toEqual([]);
});
it("uses the runtime target supplied at approved persistence time", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-target-");
const staleTarget = createSqliteTranscriptTarget({ dir, sessionId: "stale-session" });
const admittedTarget = createSqliteTranscriptTarget({ dir, sessionId: "admitted-session" });
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "persist me in the admitted session",
timestamp: 123,
},
target: {
...staleTarget,
},
updateMode: "none",
});
const persisted = await recorder.persistApproved({
target: {
...admittedTarget,
},
});
expect(persisted?.sessionFile).toBe(admittedTarget.sessionKey);
await expect(readTranscriptMessages(staleTarget)).resolves.toEqual([]);
await expect(readTranscriptMessages(admittedTarget)).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "persist me in the admitted session",
}),
]);
});
it("re-resolves the target after an explicitly retryable persistence miss", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-retry-");
const admittedTarget = createSqliteTranscriptTarget({ dir, sessionId: "admitted-session" });
let targetResolutionCount = 0;
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "persist me after the target rotates",
timestamp: 123,
},
target: () => {
targetResolutionCount += 1;
return targetResolutionCount === 1 ? undefined : admittedTarget;
},
updateMode: "none",
});
await expect(recorder.persistApproved({ retryIfUnpersisted: true })).resolves.toBeUndefined();
const persisted = await recorder.persistApproved({ retryIfUnpersisted: true });
expect(targetResolutionCount).toBe(2);
expect(persisted?.sessionFile).toBe(admittedTarget.sessionKey);
await expect(readTranscriptMessages(admittedTarget)).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "persist me after the target rotates",
}),
]);
});
it("keeps concurrent persistence retries single-flight", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-concurrent-retry-");
const admittedTarget = createSqliteTranscriptTarget({ dir, sessionId: "admitted-session" });
let targetResolutionCount = 0;
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "persist me once after concurrent retries",
timestamp: 123,
},
target: () => {
targetResolutionCount += 1;
return targetResolutionCount === 1 ? undefined : admittedTarget;
},
updateMode: "none",
});
await expect(recorder.persistApproved({ retryIfUnpersisted: true })).resolves.toBeUndefined();
const [first, second] = await Promise.all([
recorder.persistApproved({ retryIfUnpersisted: true }),
recorder.persistApproved({ retryIfUnpersisted: true }),
]);
expect(targetResolutionCount).toBe(2);
expect(first?.sessionFile).toBe(admittedTarget.sessionKey);
expect(second?.sessionFile).toBe(admittedTarget.sessionKey);
await expect(readTranscriptMessages(admittedTarget)).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "persist me once after concurrent retries",
}),
]);
});
it("waits for runtime persistence before deciding fallback ownership", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-pending-");
const target = createSqliteTranscriptTarget({ dir });
let releaseRuntimePersistence!: () => void;
const runtimePersistenceStarted = new Promise<void>((resolve) => {
releaseRuntimePersistence = resolve;
});
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "pending runtime turn",
timestamp: 123,
},
target: {
...target,
},
updateMode: "none",
});
recorder.markRuntimePersistencePending(
runtimePersistenceStarted.then(() => {
recorder.markRuntimePersisted({
role: "user",
content: "pending runtime turn",
timestamp: 123,
});
}),
);
let fallbackSettled = false;
const fallback = recorder.persistFallback().then((result) => {
fallbackSettled = true;
return result;
});
await Promise.resolve();
expect(fallbackSettled).toBe(false);
releaseRuntimePersistence();
await expect(fallback).resolves.toBeUndefined();
await expect(readTranscriptMessages(target)).resolves.toEqual([]);
});
it("fallback-persists when pending runtime persistence fails", async () => {
const dir = createTempDir("openclaw-user-turn-recorder-pending-failed-");
const target = createSqliteTranscriptTarget({ dir });
const errors: unknown[] = [];
let rejectRuntimePersistence!: (error: unknown) => void;
const runtimePersistence = new Promise<void>((_, reject) => {
rejectRuntimePersistence = reject;
});
const recorder = createUserTurnTranscriptRecorder({
input: {
text: "pending failed turn",
timestamp: 123,
},
target: {
...target,
},
updateMode: "none",
onPersistenceError: (error) => errors.push(error),
});
recorder.markRuntimePersistencePending(runtimePersistence);
const fallback = recorder.persistFallback();
rejectRuntimePersistence(new Error("runtime append failed"));
const persisted = await fallback;
expect(errors).toHaveLength(1);
expect(persisted?.message).toMatchObject({
role: "user",
content: "pending failed turn",
});
await expect(readTranscriptMessages(target)).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "pending failed turn",
}),
]);
});
});
});