Files
openclaw/src/agents/pi-embedded-runner/transcript-rewrite.ts
Val Alexander 05c9492bff fix: reduce WebUI session latency churn (#76277) thanks @BunsDev
Reduce WebUI/Gateway latency churn by avoiding redundant session reloads, carrying session keys through transcript update events, and deferring explicit media provider discovery. Includes changelog attribution and closes the referenced runtime latency issues.
2026-05-02 18:39:06 -05:00

407 lines
12 KiB
TypeScript

import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import type {
TranscriptRewriteReplacement,
TranscriptRewriteRequest,
TranscriptRewriteResult,
} from "../../context-engine/types.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { getRawSessionAppendMessage } from "../session-raw-append-message.js";
import {
acquireSessionWriteLock,
type SessionWriteLockAcquireTimeoutConfig,
resolveSessionWriteLockAcquireTimeoutMs,
} from "../session-write-lock.js";
import { log } from "./logger.js";
import {
persistTranscriptStateMutation,
readTranscriptFileState,
type TranscriptFileState,
} from "./transcript-file-state.js";
type SessionManagerLike = ReturnType<typeof SessionManager.open>;
type SessionBranchEntry = ReturnType<SessionManagerLike["getBranch"]>[number];
function estimateMessageBytes(message: AgentMessage): number {
return Buffer.byteLength(JSON.stringify(message), "utf8");
}
function remapEntryId(
entryId: string | null | undefined,
rewrittenEntryIds: ReadonlyMap<string, string>,
): string | null {
if (!entryId) {
return null;
}
return rewrittenEntryIds.get(entryId) ?? entryId;
}
function appendBranchEntry(params: {
sessionManager: SessionManagerLike;
entry: SessionBranchEntry;
rewrittenEntryIds: ReadonlyMap<string, string>;
appendMessage: SessionManagerLike["appendMessage"];
}): string {
const { sessionManager, entry, rewrittenEntryIds, appendMessage } = params;
if (entry.type === "message") {
return appendMessage(entry.message as Parameters<typeof sessionManager.appendMessage>[0]);
}
if (entry.type === "compaction") {
return sessionManager.appendCompaction(
entry.summary,
remapEntryId(entry.firstKeptEntryId, rewrittenEntryIds) ?? entry.firstKeptEntryId,
entry.tokensBefore,
entry.details,
entry.fromHook,
);
}
if (entry.type === "thinking_level_change") {
return sessionManager.appendThinkingLevelChange(entry.thinkingLevel);
}
if (entry.type === "model_change") {
return sessionManager.appendModelChange(entry.provider, entry.modelId);
}
if (entry.type === "custom") {
return sessionManager.appendCustomEntry(entry.customType, entry.data);
}
if (entry.type === "custom_message") {
return sessionManager.appendCustomMessageEntry(
entry.customType,
entry.content,
entry.display,
entry.details,
);
}
if (entry.type === "session_info") {
if (entry.name) {
return sessionManager.appendSessionInfo(entry.name);
}
return sessionManager.appendSessionInfo("");
}
if (entry.type === "branch_summary") {
return sessionManager.branchWithSummary(
remapEntryId(entry.parentId, rewrittenEntryIds),
entry.summary,
entry.details,
entry.fromHook,
);
}
return sessionManager.appendLabelChange(
remapEntryId(entry.targetId, rewrittenEntryIds) ?? entry.targetId,
entry.label,
);
}
function appendTranscriptStateBranchEntry(params: {
state: TranscriptFileState;
entry: SessionBranchEntry;
rewrittenEntryIds: ReadonlyMap<string, string>;
}): SessionBranchEntry {
const { state, entry, rewrittenEntryIds } = params;
if (entry.type === "message") {
return state.appendMessage(entry.message);
}
if (entry.type === "compaction") {
return state.appendCompaction(
entry.summary,
remapEntryId(entry.firstKeptEntryId, rewrittenEntryIds) ?? entry.firstKeptEntryId,
entry.tokensBefore,
entry.details,
entry.fromHook,
);
}
if (entry.type === "thinking_level_change") {
return state.appendThinkingLevelChange(entry.thinkingLevel);
}
if (entry.type === "model_change") {
return state.appendModelChange(entry.provider, entry.modelId);
}
if (entry.type === "custom") {
return state.appendCustomEntry(entry.customType, entry.data);
}
if (entry.type === "custom_message") {
return state.appendCustomMessageEntry(
entry.customType,
entry.content,
entry.display,
entry.details,
);
}
if (entry.type === "session_info") {
return state.appendSessionInfo(entry.name ?? "");
}
if (entry.type === "branch_summary") {
return state.branchWithSummary(
remapEntryId(entry.parentId, rewrittenEntryIds),
entry.summary,
entry.details,
entry.fromHook,
);
}
return state.appendLabelChange(
remapEntryId(entry.targetId, rewrittenEntryIds) ?? entry.targetId,
entry.label,
);
}
/**
* Safely rewrites transcript message entries on the active branch by branching
* from the first rewritten message's parent and re-appending the suffix.
*/
export function rewriteTranscriptEntriesInSessionManager(params: {
sessionManager: SessionManagerLike;
replacements: TranscriptRewriteReplacement[];
}): TranscriptRewriteResult {
const replacementsById = new Map(
params.replacements
.filter((replacement) => replacement.entryId.trim().length > 0)
.map((replacement) => [replacement.entryId, replacement.message]),
);
if (replacementsById.size === 0) {
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason: "no replacements requested",
};
}
const branch = params.sessionManager.getBranch();
if (branch.length === 0) {
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason: "empty session",
};
}
const matchedIndices: number[] = [];
let bytesFreed = 0;
for (let index = 0; index < branch.length; index++) {
const entry = branch[index];
if (entry.type !== "message") {
continue;
}
const replacement = replacementsById.get(entry.id);
if (!replacement) {
continue;
}
const originalBytes = estimateMessageBytes(entry.message);
const replacementBytes = estimateMessageBytes(replacement);
matchedIndices.push(index);
bytesFreed += Math.max(0, originalBytes - replacementBytes);
}
if (matchedIndices.length === 0) {
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason: "no matching message entries",
};
}
const firstMatchedEntry = branch[matchedIndices[0]] as
| Extract<SessionBranchEntry, { type: "message" }>
| undefined;
// matchedIndices only contains indices of branch "message" entries.
if (!firstMatchedEntry) {
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason: "invalid first rewrite target",
};
}
if (!firstMatchedEntry.parentId) {
params.sessionManager.resetLeaf();
} else {
params.sessionManager.branch(firstMatchedEntry.parentId);
}
// Maintenance rewrites should preserve the exact requested history without
// re-running persistence hooks or size truncation on replayed messages.
const appendMessage = getRawSessionAppendMessage(params.sessionManager);
const rewrittenEntryIds = new Map<string, string>();
for (let index = matchedIndices[0]; index < branch.length; index++) {
const entry = branch[index];
const replacement = entry.type === "message" ? replacementsById.get(entry.id) : undefined;
const newEntryId =
replacement === undefined
? appendBranchEntry({
sessionManager: params.sessionManager,
entry,
rewrittenEntryIds,
appendMessage,
})
: appendMessage(replacement as Parameters<typeof params.sessionManager.appendMessage>[0]);
rewrittenEntryIds.set(entry.id, newEntryId);
}
return {
changed: true,
bytesFreed,
rewrittenEntries: matchedIndices.length,
};
}
export function rewriteTranscriptEntriesInState(params: {
state: TranscriptFileState;
replacements: TranscriptRewriteReplacement[];
}): TranscriptRewriteResult & { appendedEntries: SessionBranchEntry[] } {
const replacementsById = new Map(
params.replacements
.filter((replacement) => replacement.entryId.trim().length > 0)
.map((replacement) => [replacement.entryId, replacement.message]),
);
if (replacementsById.size === 0) {
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason: "no replacements requested",
appendedEntries: [],
};
}
const branch = params.state.getBranch();
if (branch.length === 0) {
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason: "empty session",
appendedEntries: [],
};
}
const matchedIndices: number[] = [];
let bytesFreed = 0;
for (let index = 0; index < branch.length; index++) {
const entry = branch[index];
if (entry.type !== "message") {
continue;
}
const replacement = replacementsById.get(entry.id);
if (!replacement) {
continue;
}
const originalBytes = estimateMessageBytes(entry.message);
const replacementBytes = estimateMessageBytes(replacement);
matchedIndices.push(index);
bytesFreed += Math.max(0, originalBytes - replacementBytes);
}
if (matchedIndices.length === 0) {
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason: "no matching message entries",
appendedEntries: [],
};
}
const firstMatchedEntry = branch[matchedIndices[0]] as
| Extract<SessionBranchEntry, { type: "message" }>
| undefined;
if (!firstMatchedEntry) {
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason: "invalid first rewrite target",
appendedEntries: [],
};
}
if (!firstMatchedEntry.parentId) {
params.state.resetLeaf();
} else {
params.state.branch(firstMatchedEntry.parentId);
}
const appendedEntries: SessionBranchEntry[] = [];
const rewrittenEntryIds = new Map<string, string>();
for (let index = matchedIndices[0]; index < branch.length; index++) {
const entry = branch[index];
const replacement = entry.type === "message" ? replacementsById.get(entry.id) : undefined;
const newEntry =
replacement === undefined
? appendTranscriptStateBranchEntry({
state: params.state,
entry,
rewrittenEntryIds,
})
: params.state.appendMessage(replacement);
rewrittenEntryIds.set(entry.id, newEntry.id);
appendedEntries.push(newEntry);
}
return {
changed: true,
bytesFreed,
rewrittenEntries: matchedIndices.length,
appendedEntries,
};
}
/**
* Open a transcript file, rewrite message entries on the active branch, and
* emit a transcript update when the active branch changed.
*/
export async function rewriteTranscriptEntriesInSessionFile(params: {
sessionFile: string;
sessionId?: string;
sessionKey?: string;
request: TranscriptRewriteRequest;
config?: SessionWriteLockAcquireTimeoutConfig;
}): Promise<TranscriptRewriteResult> {
let sessionLock: Awaited<ReturnType<typeof acquireSessionWriteLock>> | undefined;
try {
sessionLock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
timeoutMs: resolveSessionWriteLockAcquireTimeoutMs(params.config),
});
const state = await readTranscriptFileState(params.sessionFile);
const result = rewriteTranscriptEntriesInState({
state,
replacements: params.request.replacements,
});
if (result.changed) {
await persistTranscriptStateMutation({
sessionFile: params.sessionFile,
state,
appendedEntries: result.appendedEntries,
});
emitSessionTranscriptUpdate({
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
});
log.info(
`[transcript-rewrite] rewrote ${result.rewrittenEntries} entr` +
`${result.rewrittenEntries === 1 ? "y" : "ies"} ` +
`bytesFreed=${result.bytesFreed} ` +
`sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`,
);
}
return result;
} catch (err) {
const reason = formatErrorMessage(err);
log.warn(`[transcript-rewrite] failed: ${reason}`);
return {
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
reason,
};
} finally {
await sessionLock?.release();
}
}