Files
openclaw/src/sessions/user-turn-transcript.ts
Peter Steinberger bb46b79d3c refactor: internalize OpenClaw agent runtime (#85341)
* refactor: extract agent core package

Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts.

* refactor: extract shared llm runtime

Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout.

* refactor: remove pi runtime internals

Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code.

* refactor: tighten agent session runtime

Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts.

* refactor: remove static model and pi auth paths

Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities.

* refactor: remove legacy provider compat paths

* docs: remove agent parity notes

* fix: skip provider wildcard metadata parsing

* refactor: share session extension sdk loading

* refactor: inline acpx proxy error formatter

* refactor: fold edit recovery into edit tool

* fix: accept extension batch separator

* test: align startup provider plugin expectations

* fix: restore provider-scoped release discovery

* test: align static asset packaging expectations

* fix: run static provider catalogs during scoped discovery

* fix: add provider entry catalogs for scoped live discovery

* fix: load lightweight provider catalog entries

* fix: refresh provider-scoped plugin metadata

* fix: keep provider catalog entries on release live path

* fix: keep static manifest models in release live checks

* fix: harden release model discovery

* fix: reduce OpenAI live cache probe reasoning

* fix: disable OpenAI cache probe reasoning

* ci: extend OpenAI gateway live timeout

* fix: extend live gateway model budget

* fix: stabilize release validation regressions

* fix: honor provider aliases in model rows

* fix: stabilize release validation lanes

* fix: stabilize release memory qa

* ci: stabilize release validation lanes

* ci: prefer ipv4 for live docker node calls

* fix: restore shared tool-call stream wrapper

* ci: remove legacy pi test shard alias

* fix: clean up embedded agent test drift

* fix: stabilize runtime alias status

* fix: clean up embedded agent ci drift

* fix: restore release ci invariants

* fix: clean up post-rebase runtime drift

* fix: restore release ci checks

* fix: restore release ci after rebase

* fix: remove stale pi runtime path

* test: align compaction runtime expectations

* test: update plugin prerelease expectations

* fix: handle claude live tool approvals

* fix: stabilize release validation gates

* fix: finish agent runtime import

* test: finish post-rebase agent runtime mocks

* fix: keep codex compaction native

* fix: stabilize codex app-server hook tests

* test: isolate codex diagnostic active run

* test: remove codex diagnostic completion race

# Conflicts:
#	extensions/codex/src/app-server/run-attempt.test.ts

* ci: fix full release manifest performance run id

* refactor: narrow llm plugin sdk boundary

* chore: drop generated google boundary stamps

* fix: repair rebase fallout

* fix: clean up rebased runtime references

* fix: decode codex jwt payloads as base64url

* fix: preserve shipped pi runtime alias

* fix: add scoped sdk virtual modules

* fix: decode llm codex oauth jwt as base64url

* fix: avoid stale vertex adc negative cache

* fix: harden tool arg decoding and codeql path

* fix: keep vertex adc negative checks live

* refactor: consolidate codex jwt and edit helpers

* fix: await codex oauth node runtime imports

* fix: preserve sdk tool and notice contracts

* fix: preserve shipped compat config boundaries

* fix: align codex oauth callback host

* fix: terminate agent-core loop streams on failure

* fix: keep codex oauth callback alive during fallback

* ci: include session tools in critical codeql scans

* fix: keep Cloudflare Anthropic provider auth header

* docs: redirect legacy pi runtime pages

* fix: honor bundled web provider compat discovery

* fix: protect session output spill files

* fix: keep legacy agent dir env blocked

* fix: contain auto-discovered skill symlinks

* fix: harden agent core sdk proxy surfaces

* fix: restore approval reaction sdk compat

* fix: keep live docker runs bounded

* fix: keep codex oauth redirect host aligned

* fix: resolve post-rebase agent runtime drift

* fix: redact anthropic oauth parse failures

* fix: preserve responses strict tool shaping

* fix: repair agent runtime rebase cleanup

* docs: redirect retired parity pages

* fix: bound auto-discovered resources to roots

* fix: repair post-rebase agent test drift

* fix: preserve bundled provider allowlist migration

* fix: preserve manifest-owned provider aliases

* fix: declare photon image dependency

* fix: keep provider headers out of proxy body

* fix: preserve shipped env aliases

* fix: refresh control ui i18n generated state

* fix: quote read fallback paths

* fix: preview edits through configured backend

* test: satisfy core test typecheck

* fix: preserve ZAI usage auth fallback

* test: repair codex diagnostic test

* fix: repair agent runtime rebase drift

* test: finish embedded runner import rename

* fix: repair agent runtime rebase integrations

* test: align compaction oauth fallback expectations

* fix: allow sdk-auth session models

* fix: update doctor tool schema import

* fix: preserve bedrock plugin region

* fix: stream harmony-like prose immediately

* ci: include session runtime in codeql shards

* fix: repair latest rebase integrations

* fix: honor explicit codex websocket transport

* fix: keep openai-compatible credentials provider-scoped

* fix: refresh sdk api baseline after rebase

* fix: route cli runtime aliases through openclaw harness

* test: rename stale harness mock expectation

* test: rename embedded agent overflow calls

* test: clean embedded auth test wording

* test: use openclaw stream types in deepinfra cache test

* fix: refresh sdk api baseline on latest main

* fix: honor bundled discovery compat allowlists

* fix: refresh sdk api baseline after latest rebase

* fix: remove stale rebase imports

* test: rename stale model catalog mock

* test: mock renamed doctor runtime modules

* fix: map canonical kimi env auth

* fix: use internal model registry in bench script

* fix: migrate deepinfra provider catalog entry

* fix: enforce builtin tool suppression

* fix: route compaction auth and proxy payloads safely

* refactor: prune unused llm registry leftovers

* test: update codex hooks session import

* test: fix model picker ci coverage

* test: align model picker auth mock types
2026-05-27 19:24:04 +01:00

649 lines
20 KiB
TypeScript

import path from "node:path";
import type { AgentMessage } from "../agents/runtime/index.js";
import { appendSessionTranscriptMessage } from "../config/sessions/transcript-append.js";
import { mimeTypeFromFilePath } from "../media/mime.js";
import {
applyInputProvenanceToUserMessage,
type InputProvenance,
normalizeInputProvenance,
} from "./input-provenance.js";
import { emitSessionTranscriptUpdate } from "./transcript-events.js";
type TranscriptAppendConfig = Parameters<typeof appendSessionTranscriptMessage>[0]["config"];
type UserTurnSessionEntry = {
sessionId: string;
updatedAt: number;
sessionFile?: string;
threadId?: string | number;
} & Record<string, unknown>;
type PersistedUserTurnMediaInput = {
path?: string | null;
url?: string | null;
contentType?: string | null;
kind?: string | null;
};
type PersistedUserTurnMediaFields = {
MediaPath?: string;
MediaPaths?: string[];
MediaType?: string;
MediaTypes?: string[];
};
export type PersistedUserTurnMessage = Extract<AgentMessage, { role: "user" }>;
export type UserTurnInput = {
text?: string | null;
media?: readonly PersistedUserTurnMediaInput[] | null;
timestamp?: number;
idempotencyKey?: string;
provenance?: InputProvenance;
mediaOnlyText?: string;
};
type UserTurnTranscriptUpdateMode = "inline" | "none";
export type UserTurnBeforeMessageWrite = (params: {
message: PersistedUserTurnMessage;
agentId?: string;
sessionKey?: string;
}) => AgentMessage | null;
type AppendUserTurnTranscriptMessageParams = {
transcriptPath: string;
input?: UserTurnInput;
message?: PersistedUserTurnMessage;
sessionId?: string;
agentId?: string;
sessionKey?: string;
cwd?: string;
config?: TranscriptAppendConfig;
updateMode?: UserTurnTranscriptUpdateMode;
beforeMessageWrite?: UserTurnBeforeMessageWrite;
};
type PersistUserTurnTranscriptParams = {
input?: UserTurnInput;
message?: PersistedUserTurnMessage;
sessionId: string;
sessionKey: string;
sessionEntry: UserTurnSessionEntry | undefined;
sessionStore?: Record<string, UserTurnSessionEntry>;
storePath?: string;
agentId: string;
threadId?: string | number;
cwd?: string;
config?: TranscriptAppendConfig;
updateMode?: UserTurnTranscriptUpdateMode;
beforeMessageWrite?: UserTurnBeforeMessageWrite;
};
type UserTurnTranscriptPersistenceTarget = Omit<
PersistUserTurnTranscriptParams,
"input" | "message" | "updateMode"
>;
type UserTurnTranscriptFileTarget = {
transcriptPath: string;
sessionId?: string;
agentId?: string;
sessionKey?: string;
cwd?: string;
config?: TranscriptAppendConfig;
};
type UserTurnTranscriptTarget = UserTurnTranscriptPersistenceTarget | UserTurnTranscriptFileTarget;
type UserTurnTranscriptPersistResult = {
sessionFile: string;
sessionEntry: UserTurnSessionEntry | undefined;
messageId: string;
message: PersistedUserTurnMessage;
};
type UserTurnTranscriptTargetResolver =
| UserTurnTranscriptTarget
| (() => UserTurnTranscriptTarget | undefined | Promise<UserTurnTranscriptTarget | undefined>);
type UserTurnInputResolver = () => UserTurnInput | undefined | Promise<UserTurnInput | undefined>;
export type UserTurnTranscriptRecorder = {
readonly message: PersistedUserTurnMessage | undefined;
resolveMessage: () => Promise<PersistedUserTurnMessage | undefined>;
markRuntimePersistencePending: (pending: Promise<void>) => void;
markRuntimePersisted: (message?: PersistedUserTurnMessage) => void;
markBlocked: () => void;
hasPersisted: () => boolean;
isBlocked: () => boolean;
hasRuntimePersistencePending: () => boolean;
waitForRuntimePersistence: () => Promise<void>;
persistApproved: (params?: {
target?: UserTurnTranscriptTargetResolver;
updateMode?: UserTurnTranscriptUpdateMode;
}) => Promise<UserTurnTranscriptPersistResult | undefined>;
persistFallback: (params?: {
target?: UserTurnTranscriptTargetResolver;
updateMode?: UserTurnTranscriptUpdateMode;
}) => Promise<UserTurnTranscriptPersistResult | undefined>;
};
type CreateUserTurnTranscriptRecorderParams = {
input?: UserTurnInput;
message?: PersistedUserTurnMessage;
resolveInput?: UserTurnInputResolver;
target: UserTurnTranscriptTargetResolver;
updateMode?: UserTurnTranscriptUpdateMode;
beforeMessageWrite?: UserTurnBeforeMessageWrite;
errorContext?: string;
onPersistenceError?: (error: unknown) => void;
};
type ResolvePersistedUserTurnTextOptions = {
hasMedia?: boolean;
};
type PersistedUserTurnMediaFieldSource = {
MediaPath?: string | null;
MediaPaths?: readonly (string | null | undefined)[] | null;
MediaUrl?: string | null;
MediaUrls?: readonly (string | null | undefined)[] | null;
MediaType?: string | null;
MediaTypes?: readonly (string | null | undefined)[] | null;
MediaWorkspaceDir?: string | null;
};
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 ?? "";
}
const CHANNEL_MEDIA_PLACEHOLDER_PATTERN = /^<media:[a-z0-9_-]+>(?:\s+\([^)]*\))?$/i;
export function resolvePersistedUserTurnText(
value: string | null | undefined,
options: ResolvePersistedUserTurnTextOptions = {},
): string | undefined {
const normalized = normalizeOptionalText(value);
if (!normalized) {
return undefined;
}
if (options.hasMedia === true && CHANNEL_MEDIA_PLACEHOLDER_PATTERN.test(normalized)) {
return undefined;
}
return normalized;
}
function mediaTypeForTranscript(media: PersistedUserTurnMediaInput): string {
return (
normalizeOptionalText(media.contentType) ??
normalizeOptionalText(media.kind) ??
"application/octet-stream"
);
}
function normalizeMediaEntryForTranscript(media: PersistedUserTurnMediaInput):
| {
path: string;
type: string;
}
| undefined {
const path = normalizeOptionalText(media.path) ?? normalizeOptionalText(media.url);
if (!path) {
return undefined;
}
return {
path,
type: mediaTypeForTranscript(media),
};
}
function normalizeOptionalTextArray(
values: readonly (string | null | undefined)[] | null | undefined,
): string[] {
return (
values?.map(normalizeOptionalText).filter((value): value is string => Boolean(value)) ?? []
);
}
const URL_LIKE_MEDIA_PATH_PATTERN = /^[a-z][a-z0-9+.-]*:/i;
function resolveTranscriptMediaPath(pathValue: string, workspaceDir: string | undefined): string {
if (!workspaceDir || path.isAbsolute(pathValue) || URL_LIKE_MEDIA_PATH_PATTERN.test(pathValue)) {
return pathValue;
}
return path.join(workspaceDir, pathValue);
}
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: PersistedUserTurnMediaFieldSource | null | undefined,
): PersistedUserTurnMediaInput[] {
if (!fields) {
return [];
}
const paths = normalizeOptionalTextArray(fields.MediaPaths);
const urls = normalizeOptionalTextArray(fields.MediaUrls);
const types = normalizeOptionalTextArray(fields.MediaTypes);
const singlePath = normalizeOptionalText(fields.MediaPath);
const singleUrl = normalizeOptionalText(fields.MediaUrl);
const singleType = normalizeOptionalText(fields.MediaType);
const workspaceDir = normalizeOptionalText(fields.MediaWorkspaceDir);
const mediaCount = Math.max(paths.length, urls.length, singlePath || singleUrl ? 1 : 0);
const media: PersistedUserTurnMediaInput[] = [];
for (let index = 0; index < mediaCount; index += 1) {
const rawPath = paths[index] ?? (index === 0 ? singlePath : undefined);
const mediaPath = rawPath ? resolveTranscriptMediaPath(rawPath, workspaceDir) : undefined;
const url = urls[index] ?? (index === 0 ? singleUrl : undefined);
if (!mediaPath && !url) {
continue;
}
media.push({
...(mediaPath ? { path: mediaPath } : {}),
...(url ? { url } : {}),
contentType: resolveTranscriptMediaType({
explicitType: types[index] ?? (index === 0 ? singleType : undefined),
mediaPath,
mediaUrl: url,
}),
});
}
return media;
}
function buildPersistedUserTurnMediaFields(
media: readonly PersistedUserTurnMediaInput[] | null | undefined,
): PersistedUserTurnMediaFields {
const entries = Array.isArray(media) ? media : [];
const normalized = entries
.map(normalizeMediaEntryForTranscript)
.filter((entry): entry is { path: string; type: string } => entry !== undefined);
const paths = normalized.map((entry) => entry.path);
if (paths.length === 0) {
return {};
}
const types = normalized.map((entry) => entry.type);
return {
MediaPath: paths[0],
MediaPaths: paths,
MediaType: types[0],
MediaTypes: types,
};
}
function buildPersistedUserTurnMessage(params: UserTurnInput): PersistedUserTurnMessage {
const mediaFields = buildPersistedUserTurnMediaFields(params.media);
const hasMedia = Boolean(mediaFields.MediaPath);
const text = normalizeTranscriptText(params.text);
const content = text || (hasMedia ? (params.mediaOnlyText ?? "") : "");
const message = {
role: "user",
content,
timestamp: params.timestamp ?? Date.now(),
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
...mediaFields,
} as PersistedUserTurnMessage;
return applyInputProvenanceToUserMessage(message, params.provenance) as PersistedUserTurnMessage;
}
function resolvePersistedUserTurnMessage(
params: Pick<AppendUserTurnTranscriptMessageParams, "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 isBeforeAgentRunBlockedMessage(message: AgentMessage): boolean {
const marker = (message as { __openclaw?: { beforeAgentRunBlocked?: unknown } })["__openclaw"]
?.beforeAgentRunBlocked;
return marker !== undefined;
}
export function mergePreparedUserTurnMessageForRuntime(params: {
runtimeMessage: AgentMessage;
preparedMessage?: PersistedUserTurnMessage;
}): AgentMessage {
if (
!params.preparedMessage ||
!isUserMessage(params.runtimeMessage) ||
isBeforeAgentRunBlockedMessage(params.runtimeMessage)
) {
return params.runtimeMessage;
}
return {
...(params.runtimeMessage as unknown as Record<string, unknown>),
...(params.preparedMessage as unknown as Record<string, unknown>),
} as unknown as AgentMessage;
}
function applyBeforeMessageWriteToUserTurn(
message: PersistedUserTurnMessage,
params: Pick<
AppendUserTurnTranscriptMessageParams,
"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 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;
return idempotencyKey
? ({
...(nextUserMessage as unknown as Record<string, unknown>),
idempotencyKey,
} as unknown as PersistedUserTurnMessage)
: nextUserMessage;
}
export async function appendUserTurnTranscriptMessage(
params: AppendUserTurnTranscriptMessageParams,
): Promise<
| {
sessionFile: string;
messageId: string;
message: PersistedUserTurnMessage;
}
| undefined
> {
const resolvedMessage = resolvePersistedUserTurnMessage(params);
if (!resolvedMessage) {
return undefined;
}
const appended = await appendSessionTranscriptMessage({
transcriptPath: params.transcriptPath,
...(params.sessionId ? { sessionId: params.sessionId } : {}),
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.config ? { config: params.config } : {}),
message: resolvedMessage,
idempotencyLookup: "scan",
prepareMessageAfterIdempotencyCheck: (message) =>
applyBeforeMessageWriteToUserTurn(message, params),
});
if (!appended) {
return undefined;
}
switch (params.updateMode ?? "inline") {
case "inline":
if (appended.appended) {
emitSessionTranscriptUpdate({
sessionFile: params.transcriptPath,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
message: appended.message,
messageId: appended.messageId,
});
}
break;
case "none":
break;
}
return {
sessionFile: params.transcriptPath,
messageId: appended.messageId,
message: appended.message,
};
}
export async function persistUserTurnTranscript(
params: PersistUserTurnTranscriptParams,
): Promise<UserTurnTranscriptPersistResult | undefined> {
const message = resolvePersistedUserTurnMessage(params);
if (!message) {
return undefined;
}
const { resolveSessionTranscriptFile } = await import("../config/sessions/transcript.js");
const { sessionFile, sessionEntry } = await resolveSessionTranscriptFile({
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 } : {}),
});
const appended = await appendUserTurnTranscriptMessage({
transcriptPath: sessionFile,
message,
sessionId: params.sessionId,
agentId: params.agentId,
sessionKey: params.sessionKey,
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.config ? { config: params.config } : {}),
...(params.updateMode ? { updateMode: params.updateMode } : {}),
...(params.beforeMessageWrite ? { beforeMessageWrite: params.beforeMessageWrite } : {}),
});
if (!appended) {
return undefined;
}
return {
...appended,
sessionEntry,
};
}
async function resolveUserTurnTranscriptTarget(
target: UserTurnTranscriptTargetResolver,
): Promise<UserTurnTranscriptTarget | undefined> {
return typeof target === "function" ? await target() : target;
}
function isUserTurnTranscriptFileTarget(
target: UserTurnTranscriptTarget,
): target is UserTurnTranscriptFileTarget {
return "transcriptPath" in target;
}
export function createUserTurnTranscriptRecorder(
params: CreateUserTurnTranscriptRecorderParams,
): UserTurnTranscriptRecorder {
const message = resolvePersistedUserTurnMessage(params);
let blocked = false;
let persisted = false;
let persistedResult: UserTurnTranscriptPersistResult | undefined;
let runtimePersistencePromise: Promise<void> | undefined;
let selfPersistencePromise: Promise<UserTurnTranscriptPersistResult | undefined> | undefined;
let resolvedMessagePromise: Promise<PersistedUserTurnMessage | undefined> | undefined;
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?.();
return (
resolvePersistedUserTurnMessage({
message: params.message,
input: resolvedInput ?? params.input,
}) ?? message
);
} catch (error) {
handlePersistenceError(error);
return message;
}
})();
}
return await resolvedMessagePromise;
};
const waitForRuntimePersistence = async () => {
if (!runtimePersistencePromise) {
return;
}
try {
await runtimePersistencePromise;
} catch (error) {
handlePersistenceError(error);
}
};
const persistPrepared = async (options: {
waitForRuntime: boolean;
skipWhenBlocked: boolean;
target?: UserTurnTranscriptTargetResolver;
updateMode?: UserTurnTranscriptUpdateMode;
}): Promise<UserTurnTranscriptPersistResult | undefined> => {
if (persisted) {
return persistedResult;
}
if (options.skipWhenBlocked && blocked) {
return undefined;
}
if (!message && !params.resolveInput) {
return undefined;
}
if (options.waitForRuntime) {
await waitForRuntimePersistence();
if (persisted) {
return persistedResult;
}
}
if (selfPersistencePromise) {
return await selfPersistencePromise;
}
selfPersistencePromise = (async () => {
const resolvedMessage = await resolveMessageForPersistence();
if (!resolvedMessage) {
return undefined;
}
const target = await resolveUserTurnTranscriptTarget(options.target ?? params.target);
if (!target) {
return undefined;
}
const updateMode = options.updateMode ?? params.updateMode ?? "inline";
const result = isUserTurnTranscriptFileTarget(target)
? await appendUserTurnTranscriptMessage({
...target,
message: resolvedMessage,
updateMode,
...(params.beforeMessageWrite ? { beforeMessageWrite: params.beforeMessageWrite } : {}),
}).then((appended) =>
appended
? {
...appended,
sessionEntry: undefined,
}
: undefined,
)
: await persistUserTurnTranscript({
...target,
message: resolvedMessage,
updateMode,
...(params.beforeMessageWrite ? { beforeMessageWrite: params.beforeMessageWrite } : {}),
});
if (result) {
persisted = true;
persistedResult = result;
}
return result;
})();
try {
return await selfPersistencePromise;
} catch (error) {
handlePersistenceError(error);
throw error;
}
};
return {
message,
resolveMessage: resolveMessageForPersistence,
markRuntimePersistencePending: (pending) => {
runtimePersistencePromise = pending;
},
markRuntimePersisted: (persistedMessage) => {
persisted = true;
if (persistedMessage && persistedResult) {
persistedResult = {
...persistedResult,
message: persistedMessage,
};
}
},
markBlocked: () => {
blocked = true;
},
hasPersisted: () => persisted,
isBlocked: () => blocked,
hasRuntimePersistencePending: () => runtimePersistencePromise !== undefined,
waitForRuntimePersistence,
persistApproved: async (options) =>
await persistPrepared({
waitForRuntime: false,
skipWhenBlocked: true,
target: options?.target,
updateMode: options?.updateMode,
}),
persistFallback: async (options) =>
await persistPrepared({
waitForRuntime: true,
skipWhenBlocked: true,
target: options?.target,
updateMode: options?.updateMode,
}),
};
}