Files
openclaw/src/sessions/user-turn-transcript.ts
Peter Steinberger 367ff3cea1 feat(sqlite): migrate persisted media to canonical facts and stop legacy writes (#113695)
* feat(sqlite): migrate persisted media to canonical facts and stop legacy writes

PR 3 of the media legacy retirement program — the operator-approved
canonical cutover.

- openclaw doctor --fix owns one idempotent migration: active
  transcript_events rows canonicalize to __openclaw.media (facts-first
  gap-fill, bare legacy kinds to fact.kind, transcribed indexes and
  workspace dirs onto per-fact fields) via the transcript replacement
  owner; cold plain/.zst archives rewrite through temp-file + codec
  readback + event/id verification + atomic replace; trajectory
  runtime snapshots canonicalize IN PLACE (telemetry preserved, never
  row deletion). Invalid JSON, genuinely ambiguous legacy-only sparse
  alignment, or a changed source aborts that owner without partial work;
  reruns are no-ops.
- Per-agent schema advances to v16 as a pure downgrade guard (main
  independently took v15 for board/session-sharing tables; no
  columns/tables/indexes change here, shared-state DB untouched). v15
  databases repair canonical indexes before the version assertion so
  repairable installations never strand.
- The user-turn builder stops writing top-level legacy Media* fields;
  shouldPersistStructuredMediaEntries and the aligned projection mode
  are deleted; the generic transcript append boundary canonicalizes
  every message role so SDK/mirror writers cannot mint new legacy rows.
- Internal persisted-reader legacy fallbacks are removed; the public
  SDK projection stays until retirement PR 4's window expires.

Hardening from three adversarial review rounds, each with fixture
regressions: in-place trajectory canonicalization instead of row
deletion; repair-before-assert on the v15 path; all-roles append
canonicalization; duplicate-preserving exact row rewrites; v0-v15
reopen guards; complete canonical facts bypass compact legacy
projections (PR-1 dual-write rows migrate cleanly); SQLite LIKE
underscore escaped so populated foreign databases are never claimed.

* fix(sqlite): align schema-support metadata and gates with the v16 cutover

package.json agent schema support advances to 16; verifier and board
parity fixtures run doctor migration before steady-state access (the
production guards were correct); two test-only exports removed; the
migration module registered in the doctor raw-SQLite allowlist.
2026-07-25 07:15:44 -07:00

695 lines
25 KiB
TypeScript

// User turn transcript helpers extract user-turn text from session transcripts.
import { mimeTypeFromFilePath } from "@openclaw/media-core/mime";
import type { AgentMessage } from "../../packages/agent-core/src/types.js";
import {
persistSessionTranscriptTurn,
type SessionTranscriptTurnPersistOptions,
} from "../config/sessions/session-accessor.js";
import { readPersistedMediaFacts, type MediaFact } from "../media/media-facts.js";
import { applyInputProvenanceToUserMessage, normalizeInputProvenance } from "./input-provenance.js";
import {
normalizeStructuredMediaEntryForTranscript,
resolveTranscriptMediaPath,
} from "./user-turn-transcript.media-normalize.js";
import type {
CreateUserTurnTranscriptRecorderParams,
PersistUserTurnTranscriptParams,
PersistedUserTurnMediaInput,
PersistedUserTurnMessage,
UserTurnMessagePersistenceParams,
UserTurnInput,
UserTurnTranscriptPersistResult,
UserTurnTranscriptRecorder,
UserTurnTranscriptTarget,
UserTurnTranscriptTargetResolver,
UserTurnTranscriptUpdateMode,
} from "./user-turn-transcript.types.js";
export type {
PersistedUserTurnMessage,
UserTurnInput,
UserTurnTranscriptRecorder,
} from "./user-turn-transcript.types.js";
export function buildRunUserTurnIdempotencyKey(runId: string): string {
return `${runId}:user`;
}
function normalizeOptionalText(value: string | null | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
function normalizeTranscriptText(value: string | null | undefined): string {
return value ?? "";
}
// Select normalized text for persisted user turns.
export function resolvePersistedUserTurnText(value: string | null | undefined): string | undefined {
const normalized = normalizeOptionalText(value);
if (!normalized) {
return undefined;
}
return normalized;
}
function resolveTranscriptMediaType(params: {
explicitType: string | undefined;
mediaPath: string | undefined;
mediaUrl: string | undefined;
}): string | undefined {
return params.explicitType ?? mimeTypeFromFilePath(params.mediaPath ?? params.mediaUrl);
}
export function buildPersistedUserTurnMediaInputsFromFields(
fields: PersistedUserTurnMessage | null | undefined,
): PersistedUserTurnMediaInput[] {
if (!fields) {
return [];
}
const facts = readPersistedMediaFacts(fields) ?? [];
const normalizedMedia = facts.map((fact) => {
const rawPath = normalizeOptionalText(fact.path);
const mediaPath = rawPath
? resolveTranscriptMediaPath(rawPath, normalizeOptionalText(fact.workspaceDir))
: undefined;
const url = normalizeOptionalText(fact.url);
if (!mediaPath && !url) {
return {};
}
const contentType = resolveTranscriptMediaType({
explicitType: normalizeOptionalText(fact.contentType),
mediaPath,
mediaUrl: url,
});
const media: PersistedUserTurnMediaInput = { contentType };
if (mediaPath) {
media.path = mediaPath;
}
if (url) {
media.url = url;
}
if (!contentType && fact.kind) {
media.kind = fact.kind;
}
return media;
});
return normalizedMedia.some((entry) => entry.path || entry.url) ? normalizedMedia : [];
}
export function buildLateMediaAttachedProjection(message: AgentMessage): {
text?: string;
media: MediaFact[];
} {
const isLateMedia = readOpenClawMessageMeta(message)?.lateMedia === true;
const media = isLateMedia ? (readPersistedMediaFacts(message) ?? []) : [];
const text = media
.flatMap((fact) => {
const mediaRef = fact.path ?? fact.url;
return mediaRef ? [`[media attached: ${mediaRef}]`] : [];
})
.join("\n");
return { ...(text ? { text } : {}), media };
}
function buildUserTurnSenderMeta(
sender: UserTurnInput["sender"],
): Record<string, string> | undefined {
const senderId = normalizeOptionalText(sender?.id);
const senderName = normalizeOptionalText(sender?.name);
const senderUsername = normalizeOptionalText(sender?.username);
if (!senderId && !senderName && !senderUsername) {
return undefined;
}
return {
...(senderId ? { senderId } : {}),
...(senderName ? { senderName } : {}),
...(senderUsername ? { senderUsername } : {}),
};
}
function readOpenClawMessageMeta(message: AgentMessage): Record<string, unknown> | undefined {
const meta = (message as unknown as Record<string, unknown>)["__openclaw"];
return meta && typeof meta === "object" && !Array.isArray(meta)
? (meta as Record<string, unknown>)
: undefined;
}
export function buildPersistedUserTurnMessage(params: UserTurnInput): PersistedUserTurnMessage {
const normalizedMedia = (params.media ?? []).map(normalizeStructuredMediaEntryForTranscript);
const text = normalizeTranscriptText(params.text);
// Storage is BARE (no timestamp prefix). The per-message timestamp is added
// at the single LLM-boundary stamping site (normalizeMessagesForLlmBoundary),
// derived from each message's own `timestamp` field, so the current turn and
// every historical turn serialize identically on the wire. Persisting a stamp
// here would NOT match the bare-current arrival (the gateway no longer stamps
// the live turn) — see https://github.com/openclaw/openclaw/issues/3658.
const senderMeta = buildUserTurnSenderMeta(params.sender);
const openClawMeta = {
...(params.senderIsOwner === undefined ? {} : { senderIsOwner: params.senderIsOwner }),
...senderMeta,
...(params.transport ? { transport: params.transport } : {}),
...(normalizedMedia.length > 0 ? { media: normalizedMedia } : {}),
...(params.mediaImageLayout
? {
mediaImageLayout: {
slots: params.mediaImageLayout.slots.map((slot) => ({ ...slot })),
...(params.mediaImageLayout.suppressedFactIndexes?.length
? {
suppressedFactIndexes: [...params.mediaImageLayout.suppressedFactIndexes],
}
: {}),
},
}
: {}),
};
const message = {
role: "user",
content: text,
timestamp: params.timestamp ?? Date.now(),
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
...(Object.keys(openClawMeta).length > 0 ? { __openclaw: openClawMeta } : {}),
} as PersistedUserTurnMessage;
return applyInputProvenanceToUserMessage(message, params.provenance) as PersistedUserTurnMessage;
}
function resolvePersistedUserTurnMessage(
params: Pick<UserTurnMessagePersistenceParams, "input" | "message">,
): PersistedUserTurnMessage | undefined {
if (params.message) {
return params.message;
}
if (!params.input) {
return undefined;
}
return buildPersistedUserTurnMessage(params.input);
}
function isUserMessage(message: AgentMessage): message is PersistedUserTurnMessage {
return (message as { role?: unknown }).role === "user";
}
function buildLateResolvedMediaMessage(params: {
admittedMessage?: PersistedUserTurnMessage;
resolvedMessage: PersistedUserTurnMessage;
}): PersistedUserTurnMessage | undefined {
const admittedMedia = buildPersistedUserTurnMediaInputsFromFields(params.admittedMessage);
const resolvedMedia = buildPersistedUserTurnMediaInputsFromFields(params.resolvedMessage);
if (
resolvedMedia.length === 0 ||
JSON.stringify(resolvedMedia) === JSON.stringify(admittedMedia)
) {
return undefined;
}
const resolved = params.resolvedMessage as unknown as Record<string, unknown>;
const admittedContent = params.admittedMessage?.content;
const resolvedContent = params.resolvedMessage.content;
let content = resolvedContent;
if (resolvedContent === admittedContent) {
content = "";
} else if (Array.isArray(resolvedContent) && typeof admittedContent === "string") {
content = resolvedContent.filter((block) => {
const textBlock = block as { type?: unknown; text?: unknown } | null;
return textBlock?.type !== "text" || textBlock.text !== admittedContent;
});
}
const idempotencyKey =
typeof resolved.idempotencyKey === "string" && resolved.idempotencyKey.length > 0
? `${resolved.idempotencyKey}:late-media`
: `late-media:${typeof resolved.timestamp === "number" ? resolved.timestamp : Date.now()}`;
// Like #111204, mark late-media scaffolding as wire-only so UIs never render it.
return {
...resolved,
content,
idempotencyKey,
__openclaw: { ...readOpenClawMessageMeta(params.resolvedMessage), lateMedia: true },
} as unknown as PersistedUserTurnMessage;
}
function isBeforeAgentRunBlockedMessage(message: AgentMessage): boolean {
const marker = (message as { __openclaw?: { beforeAgentRunBlocked?: unknown } })["__openclaw"]
?.beforeAgentRunBlocked;
return marker !== undefined;
}
function userMessageHasImageContent(message: AgentMessage): boolean {
return (
isUserMessage(message) &&
Array.isArray(message.content) &&
message.content.some(
(block) =>
typeof block === "object" &&
block !== null &&
(block as { type?: unknown }).type === "image",
)
);
}
// Runtime messages may lack transcript metadata because channel adapters prepare
// display text separately. Merge only safe user messages, never block markers.
export function mergePreparedUserTurnMessageForRuntime(params: {
runtimeMessage: AgentMessage;
preparedMessage?: PersistedUserTurnMessage;
}): AgentMessage {
if (
!params.preparedMessage ||
!isUserMessage(params.runtimeMessage) ||
isBeforeAgentRunBlockedMessage(params.runtimeMessage)
) {
return params.runtimeMessage;
}
const runtimeMessage = params.runtimeMessage as unknown as Record<string, unknown>;
const preparedMessage = params.preparedMessage as unknown as Record<string, unknown>;
const runtimeMeta = readOpenClawMessageMeta(params.runtimeMessage);
const preparedMeta = readOpenClawMessageMeta(params.preparedMessage);
return {
...runtimeMessage,
...preparedMessage,
...(preparedMeta ? { __openclaw: { ...runtimeMeta, ...preparedMeta } } : {}),
...(userMessageHasImageContent(params.runtimeMessage)
? { content: params.runtimeMessage.content }
: {}),
} as unknown as AgentMessage;
}
/** Restores only auth state that write hooks must not be able to forge or erase. */
export function restorePreparedUserTurnOperationalMetaForRuntime(params: {
runtimeMessage: AgentMessage;
preparedMessage?: PersistedUserTurnMessage;
}): AgentMessage {
if (!params.preparedMessage || !isUserMessage(params.runtimeMessage)) {
return params.runtimeMessage;
}
const preparedMeta = readOpenClawMessageMeta(params.preparedMessage);
const senderIsOwner = preparedMeta?.senderIsOwner;
if (typeof senderIsOwner !== "boolean") {
return params.runtimeMessage;
}
return {
...(params.runtimeMessage as unknown as Record<string, unknown>),
__openclaw: { ...readOpenClawMessageMeta(params.runtimeMessage), senderIsOwner },
} as unknown as AgentMessage;
}
/** Applies before-message hooks while preserving user-turn transcript metadata. */
export function preparePersistedUserTurnMessageForTranscriptWrite(
message: PersistedUserTurnMessage,
params: Pick<UserTurnMessagePersistenceParams, "agentId" | "sessionKey" | "beforeMessageWrite">,
): PersistedUserTurnMessage | undefined {
if (!params.beforeMessageWrite) {
return message;
}
const originalMessage = message as unknown as { idempotencyKey?: unknown };
const idempotencyKey =
typeof originalMessage.idempotencyKey === "string" ? originalMessage.idempotencyKey : undefined;
const provenance = normalizeInputProvenance(
(message as unknown as { provenance?: unknown }).provenance,
);
const senderIsOwner = readOpenClawMessageMeta(message)?.senderIsOwner;
const originalTransport = readOpenClawMessageMeta(message)?.transport;
const lateMedia = readOpenClawMessageMeta(message)?.lateMedia === true;
const originalMedia = readOpenClawMessageMeta(message)?.media;
const media = Array.isArray(originalMedia) ? structuredClone(originalMedia) : undefined;
const originalMediaImageLayout = readOpenClawMessageMeta(message)?.mediaImageLayout;
const mediaImageLayout =
originalMediaImageLayout === undefined ? undefined : structuredClone(originalMediaImageLayout);
// Hooks receive the original message object and may mutate nested metadata in
// place. Snapshot transport correlation before handing them that reference.
const transport =
originalTransport && typeof originalTransport === "object" && !Array.isArray(originalTransport)
? { ...originalTransport }
: undefined;
const nextMessage = params.beforeMessageWrite({
message,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
});
if (nextMessage?.role !== "user") {
return undefined;
}
const nextUserMessage = provenance
? (applyInputProvenanceToUserMessage(nextMessage, provenance) as PersistedUserTurnMessage)
: nextMessage;
if (
!idempotencyKey &&
typeof senderIsOwner !== "boolean" &&
!transport &&
!lateMedia &&
media === undefined &&
mediaImageLayout === undefined
) {
return nextUserMessage;
}
const protectedMeta = {
...readOpenClawMessageMeta(nextUserMessage),
...(typeof senderIsOwner === "boolean" ? { senderIsOwner } : {}),
...(transport ? { transport } : {}),
...(lateMedia ? { lateMedia: true } : {}),
...(media === undefined ? {} : { media }),
...(mediaImageLayout === undefined ? {} : { mediaImageLayout }),
};
return {
...(nextUserMessage as unknown as Record<string, unknown>),
...(idempotencyKey ? { idempotencyKey } : {}),
...(Object.keys(protectedMeta).length > 0 ? { __openclaw: protectedMeta } : {}),
} as unknown as PersistedUserTurnMessage;
}
// Store-backed persistence resolves the current session transcript file lazily
// so callers can pass a session entry/store without knowing the final path.
async function persistUserTurnTranscript(
params: PersistUserTurnTranscriptParams,
): Promise<UserTurnTranscriptPersistResult | undefined> {
const message = resolvePersistedUserTurnMessage(params);
if (!message) {
return undefined;
}
const turn = await persistSessionTranscriptTurn(
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
...(params.sessionStore ? { sessionStore: params.sessionStore } : {}),
...(params.storePath ? { storePath: params.storePath } : {}),
agentId: params.agentId,
...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
},
{
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.config
? { config: params.config as SessionTranscriptTurnPersistOptions["config"] }
: {}),
...(params.expectedSessionId ? { expectedSessionId: params.expectedSessionId } : {}),
...(params.expectedSessionState ? { expectedSessionState: params.expectedSessionState } : {}),
...(params.sessionLifecyclePatch
? { sessionLifecyclePatch: params.sessionLifecyclePatch }
: {}),
updateMode: params.updateMode ?? "inline",
messages: [
{
message,
idempotencyLookup: "scan",
prepareMessageAfterIdempotencyCheck: (candidate) =>
preparePersistedUserTurnMessageForTranscriptWrite(
candidate as PersistedUserTurnMessage,
params,
),
},
],
},
);
const appended = turn.messages[0] as
| {
appended: boolean;
messageId: string;
message: PersistedUserTurnMessage;
}
| undefined;
if (!appended) {
return undefined;
}
return {
...appended,
sessionEntry: turn.sessionEntry,
sessionFile: turn.sessionFile,
};
}
async function resolveUserTurnTranscriptTarget(
target: UserTurnTranscriptTargetResolver,
): Promise<UserTurnTranscriptTarget | undefined> {
return typeof target === "function" ? await target() : target;
}
export function createUserTurnTranscriptRecorder(
params: CreateUserTurnTranscriptRecorderParams,
): UserTurnTranscriptRecorder {
const message = resolvePersistedUserTurnMessage(params);
let blocked = false;
let persisted = false;
let runtimePersisted = false;
let persistedResult: UserTurnTranscriptPersistResult | undefined;
let runtimePersistencePromise: Promise<void> | undefined;
let selfPersistencePromise: Promise<UserTurnTranscriptPersistResult | undefined> | undefined;
let resolvedMessagePromise: Promise<PersistedUserTurnMessage | undefined> | undefined;
let persistedMessageNotified = false;
let runtimePersistedMessage: PersistedUserTurnMessage | undefined;
let sentToProvider = false;
let resolvedBeforeProvider = false;
const handlePersistenceError = (error: unknown) => {
if (params.onPersistenceError) {
params.onPersistenceError(error);
return;
}
void import("../globals.js")
.then(({ logVerbose }) => {
logVerbose(
`failed to persist ${params.errorContext ?? "user turn transcript"}: ${String(error)}`,
);
})
.catch(() => undefined);
};
const resolveMessageForPersistence = async (): Promise<PersistedUserTurnMessage | undefined> => {
if (params.message) {
return params.message;
}
if (!params.resolveInput) {
return message;
}
if (!resolvedMessagePromise) {
resolvedMessagePromise = (async () => {
try {
const resolvedInput = await params.resolveInput?.();
const resolvedMessage =
resolvePersistedUserTurnMessage({
message: params.message,
input: resolvedInput ?? params.input,
}) ?? message;
resolvedBeforeProvider = !sentToProvider;
return resolvedMessage;
} catch (error) {
handlePersistenceError(error);
return message;
}
})();
}
return await resolvedMessagePromise;
};
const notifyMessagePersisted = (persistedMessage?: PersistedUserTurnMessage) => {
const notificationMessage = persistedMessage ?? persistedResult?.message ?? message;
if (!notificationMessage || persistedMessageNotified || !params.onMessagePersisted) {
return;
}
persistedMessageNotified = true;
try {
void Promise.resolve(params.onMessagePersisted(notificationMessage)).catch(
handlePersistenceError,
);
} catch (error) {
handlePersistenceError(error);
}
};
const waitForRuntimePersistence = async () => {
if (!runtimePersistencePromise) {
return;
}
try {
await runtimePersistencePromise;
} catch (error) {
handlePersistenceError(error);
}
};
const persistPrepared = async (options: {
waitForRuntime: boolean;
skipWhenBlocked: boolean;
message?: PersistedUserTurnMessage;
target?: UserTurnTranscriptTargetResolver;
updateMode?: UserTurnTranscriptUpdateMode;
cwd?: string;
expectedSessionId?: string;
expectedSessionState?: SessionTranscriptTurnPersistOptions["expectedSessionState"];
sessionLifecyclePatch?: SessionTranscriptTurnPersistOptions["sessionLifecyclePatch"];
retryIfUnpersisted?: boolean;
}): Promise<UserTurnTranscriptPersistResult | undefined> => {
if (options.skipWhenBlocked && blocked) {
return undefined;
}
if (!options.message && !message && !params.resolveInput) {
return undefined;
}
if (options.waitForRuntime) {
await waitForRuntimePersistence();
}
if (selfPersistencePromise) {
const existingPromise = selfPersistencePromise;
const existingResult = await existingPromise;
if (existingResult || !options.retryIfUnpersisted) {
return existingResult;
}
// A guarded store write can lose a session-generation race without appending.
// Explicit retry callers may re-resolve the target, but concurrent ownership stays shared.
if (selfPersistencePromise !== existingPromise) {
return await selfPersistencePromise;
}
selfPersistencePromise = undefined;
}
const persistencePromise = (async () => {
const resolvedMessage = options.message ?? (await resolveMessageForPersistence());
if (!resolvedMessage) {
return undefined;
}
const target = await resolveUserTurnTranscriptTarget(options.target ?? params.target);
if (!target) {
return undefined;
}
const resolvedTarget = options.cwd ? { ...target, cwd: options.cwd } : target;
const updateMode = options.updateMode ?? params.updateMode ?? "inline";
const persistMessage = async (
candidate: PersistedUserTurnMessage,
candidateUpdateMode: UserTurnTranscriptUpdateMode,
) =>
await persistUserTurnTranscript({
...resolvedTarget,
message: candidate,
...(options.expectedSessionId ? { expectedSessionId: options.expectedSessionId } : {}),
...((options.sessionLifecyclePatch ?? params.sessionLifecyclePatch)
? {
sessionLifecyclePatch:
options.sessionLifecyclePatch ?? params.sessionLifecyclePatch,
}
: {}),
...((options.expectedSessionState ?? params.expectedSessionState)
? {
expectedSessionState: options.expectedSessionState ?? params.expectedSessionState,
}
: {}),
updateMode: candidateUpdateMode,
...(params.beforeMessageWrite ? { beforeMessageWrite: params.beforeMessageWrite } : {}),
});
const lateMediaMessage =
sentToProvider && !resolvedBeforeProvider
? buildLateResolvedMediaMessage({
admittedMessage: runtimePersistedMessage ?? message,
resolvedMessage,
})
: undefined;
if (lateMediaMessage) {
// The admitted bytes already crossed the LLM boundary. Persisting media as a
// second turn preserves that prefix; inline replacement would thrash cache tail (#99495).
if (!runtimePersisted && !persisted && message) {
const admittedResult = await persistMessage(message, updateMode);
if (admittedResult) {
persisted = true;
persistedResult = admittedResult;
notifyMessagePersisted(admittedResult.message);
}
}
const appendedMedia = await persistMessage(lateMediaMessage, "none");
if (appendedMedia) {
persisted = true;
persistedResult = appendedMedia;
}
return appendedMedia;
}
if (runtimePersisted) {
return undefined;
}
if (persisted) {
return persistedResult;
}
const result = await persistMessage(resolvedMessage, updateMode);
if (result) {
persisted = true;
persistedResult = result;
notifyMessagePersisted(result.message);
}
return result;
})();
selfPersistencePromise = persistencePromise;
try {
const result = await persistencePromise;
if (!result && options.retryIfUnpersisted && selfPersistencePromise === persistencePromise) {
selfPersistencePromise = undefined;
}
return result;
} catch (error) {
handlePersistenceError(error);
throw error;
}
};
return {
message,
resolveMessage: resolveMessageForPersistence,
getPersistedMessage: () => runtimePersistedMessage ?? persistedResult?.message,
markSentToProvider: () => {
sentToProvider = true;
},
markRuntimePersistencePending: (pending) => {
runtimePersistencePromise = pending;
},
markRuntimePersisted: (persistedMessage) => {
runtimePersistedMessage = persistedMessage;
runtimePersisted = true;
if (persistedMessage && persistedResult) {
persistedResult = {
...persistedResult,
message: persistedMessage,
};
}
notifyMessagePersisted(persistedMessage);
},
markBlocked: () => {
blocked = true;
},
hasPersisted: () => persisted || runtimePersisted,
isBlocked: () => blocked,
hasRuntimePersistencePending: () => runtimePersistencePromise !== undefined,
waitForRuntimePersistence,
persistApproved: async (options) =>
await persistPrepared({
waitForRuntime: false,
skipWhenBlocked: true,
target: options?.target,
updateMode: options?.updateMode,
cwd: options?.cwd,
expectedSessionId: options?.expectedSessionId,
expectedSessionState: options?.expectedSessionState,
sessionLifecyclePatch: options?.sessionLifecyclePatch,
retryIfUnpersisted: options?.retryIfUnpersisted,
}),
persistBlocked: async (blockedMessage, options) => {
blocked = true;
return await persistPrepared({
waitForRuntime: false,
skipWhenBlocked: false,
message: blockedMessage,
target: options?.target,
updateMode: options?.updateMode,
cwd: options?.cwd,
});
},
persistFallback: async (options) =>
await persistPrepared({
waitForRuntime: true,
skipWhenBlocked: true,
target: options?.target,
updateMode: options?.updateMode,
cwd: options?.cwd,
}),
};
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.userTurnTranscriptTestApi")] = {
persistUserTurnTranscript,
};
}