test(ui): distinguish saved and live transcript paint in readiness audits (#189187)

* test(ui): distinguish saved and live transcript paint

* test(ui): preserve deferred transcript response phases
This commit is contained in:
Peter Steinberger 2026-10-31 10:04:24 +01:00 • committed by GitHub
parent cd6e48ff50
commit 1dd4145dae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 779 additions and 106 deletions

View file

@ -0,0 +1,2 @@
# Native saved/live paint provenance and nested mock startup deferral regressions.
239

View file

@ -28,11 +28,14 @@
// --critical-path-intent-ms 150 includes hover/focus prefetch before route activation.
// --settings-core-schema replays the server's core config schema, prepared before sampling.
// Paint/CLS and static-shell geometry are recorded in presentation; --filmstrip-only saves frames.
// --transcript-witness distinguishes native saved/live text paint in warm Chat reloads.
// --transcript-witness-hold also holds startup for saved paint: correctness only, never timing.
import { execFileSync } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { loadavg } from "node:os";
import path from "node:path";
import { parseArgs } from "node:util";
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import { buildControlUiSessionPath } from "@openclaw/session-url-contract";
import { chromium, type Browser, type CDPSession, type Page } from "playwright";
import { buildSandboxHostPath } from "../src/agents/sandbox-host.js";
@ -108,7 +111,7 @@ import { probeColdLoadOverlays } from "./lib/control-ui-overlay-probe.ts";
import { installPaneChromeProbe } from "./lib/control-ui-pane-chrome-probe.ts";
import { createControlUiStreamingReply } from "./lib/control-ui-streaming-fixture.ts";
import { createStreamingReplyProbeInitScript } from "./lib/control-ui-streaming-reply.ts";
import { warmReloadProbe } from "./lib/control-ui-warm-reload-probe.ts";
import { warmReloadProbe, type TranscriptWitnesses } from "./lib/control-ui-warm-reload-probe.ts";
import {
GATEWAY_HEAP_SAMPLE_INTERVAL,
readGatewayHeapProfile,
@ -270,6 +273,8 @@ const { values } = parseArgs({
"no-owner-probes": { type: "boolean", default: false },
allocations: { type: "boolean", default: false },
"warm-reload": { type: "boolean", default: false },
"transcript-witness": { type: "boolean", default: false },
"transcript-witness-hold": { type: "boolean", default: false },
"row-facts": { type: "boolean", default: false },
"late-row-facts": { type: "boolean", default: false },
"pane-chrome-probe": { type: "boolean", default: false },
@ -514,6 +519,15 @@ if (
throw new Error("Expected boot-route chat, new, sessions or settings");
}
const navigationRoutes: CriticalPathRoute[] = [];
if (
(values["transcript-witness"] &&
(!values["warm-reload"] || firstLoad || bootRoute !== "chat" || layoutCells.length)) ||
(values["transcript-witness-hold"] && (!values["transcript-witness"] || values["critical-path"]))
) {
throw new Error(
"transcript-witness requires warm Chat without a layout matrix; hold requires transcript-witness without critical-path navigation",
);
}
// Cold New has no Chat owner to complete registration, so omit the unused WeakMap shim.
const captureOwnerRegistration = !values["no-owner-probes"] && (!firstLoad || bootRoute !== "new");
const ownerProbes =
@ -824,6 +838,80 @@ const fixture = createControlUiTeamScaleFixture({
: undefined,
dashboardPaintMarker: values["dashboard-paint-marker"],
});
const witnessPhaseKey = "openclaw-bench-transcript-witness-phase";
const transcriptWitness = values["transcript-witness"]
? (() => {
const messages = fixture.scenario.historyMessages ?? [];
const index = messages.length - 1;
const message = messages[index];
const metadata = message?.["__openclaw"];
if (
message?.role !== "assistant" ||
typeof metadata?.id !== "string" ||
!Array.isArray(message.content)
) {
throw new Error("Transcript witness requires an existing final assistant text message");
}
const originalText = "The current implementation is ready for verification.";
const witnesses: TranscriptWitnesses = {
saved: {
messageId: `${metadata.id}-cache`,
rowKey: "",
text: "The cached implementation is ready for verification.",
},
live: {
messageId: `${metadata.id}-fresh`,
rowKey: "",
text: "The latest implementation is ready for verification.",
},
};
const variants = { saved: messages, live: messages };
for (const source of ["saved", "live"] as const) {
const witness = witnesses[source];
witness.rowKey = `group:assistant:msg:id:${witness.messageId}:0`;
let replacements = 0;
const content = message.content.map((value) => {
if (
value.type !== "text" ||
typeof value.text !== "string" ||
!value.text.includes(originalText)
) {
return value;
}
replacements++;
return { ...value, text: value.text.replace(originalText, witness.text) };
});
if (replacements !== 1) {
throw new Error("Transcript witness requires exactly one existing fixture paragraph");
}
variants[source] = messages.map((value, position) =>
position === index
? { ...message, __openclaw: { ...metadata, id: witness.messageId }, content }
: value,
);
}
// Preserve the visible reaction across both IDs; one unused lookup alias remains per phase.
const reactions = fixture.scenario.sessionReactions?.[fixture.selectedSessionKey];
const reaction = reactions?.[metadata.id];
if (reactions && reaction) {
reactions[witnesses.saved.messageId] = reaction;
reactions[witnesses.live.messageId] = reaction;
delete reactions[metadata.id];
}
return {
witnesses,
variants,
originalMessageId: metadata.id,
messageCount: messages.length,
// Native persistence retains the last 80 distinct-sequence fixture messages.
snapshotMessageCount: Math.min(messages.length, 80),
preparedAt: new Date().toISOString(),
};
})()
: undefined;
if (transcriptWitness && fixture.liveHistory) {
fixture.liveHistory.messages = transcriptWitness.variants.live;
}
const streamingReply = values["streaming-reply"]
? createControlUiStreamingReply(streamingRate, streamingContent)
: undefined;
@ -1410,8 +1498,38 @@ try {
: "";
const warmProbes =
values["warm-reload"] || firstLoad
? `\n${paneChromeScript}\n(${warmReloadProbe.toString()})(${JSON.stringify({ rowFacts: values["row-facts"], avatars: values["transcript-avatars"], lateRowFacts: values["late-row-facts"] })}, ${values["pane-chrome-probe"] ? "__paneChrome.sample" : "undefined"});`
? `\n${paneChromeScript}\n(${warmReloadProbe.toString()})(${JSON.stringify({ rowFacts: values["row-facts"], avatars: values["transcript-avatars"], lateRowFacts: values["late-row-facts"], witnesses: transcriptWitness?.witnesses })}, ${values["pane-chrome-probe"] ? "__paneChrome.sample" : "undefined"});`
: "";
// The last history case is the fixture's full-window fallback. Witness mode
// measures a changed transcript, so it must not replay the unchanged-cursor delta.
const historyCases = asNullableRecord(fixture.scenario.methodResponses?.["chat.history"])?.cases;
const witnessHistory =
transcriptWitness && Array.isArray(historyCases)
? asNullableRecord(asNullableRecord(historyCases.at(-1))?.response)
: undefined;
if (transcriptWitness && !Array.isArray(witnessHistory?.messages)) {
throw new Error("Transcript witness requires the fixture's complete history response");
}
const gatewayScenario = transcriptWitness
? {
...prepared.scenario,
historyMessages: [],
methodResponses: {
...prepared.scenario.methodResponses,
"chat.history": { ...witnessHistory, messages: [] },
"chat.startup": { ...witnessHistory, messages: [] },
},
}
: prepared.scenario;
const witnessInit = transcriptWitness
? `
const witnessLive = localStorage.getItem(${JSON.stringify(witnessPhaseKey)}) === "live";
const witnessMessages = ${JSON.stringify(transcriptWitness.variants.saved)};
if (witnessLive) witnessMessages[witnessMessages.length - 1] = ${JSON.stringify(transcriptWitness.variants.live.at(-1))};
window.openclawControlUiE2eGateway.setHistoryMessages(witnessMessages);
if (witnessLive && ${values["transcript-witness-hold"]}) window.openclawControlUiE2eGateway.deferNext("chat.startup");
`
: "";
const presentationProbes = fixture.sidebarPresentation
? `\n(${installTeamScaleSidebarPresentation.toString()})(${JSON.stringify(fixture.sidebarPresentation)});${values["sidebar-presentation-probe"] ? `\n(${sidebarPresentationProbe.toString()})();` : ""}`
: "";
@ -1419,7 +1537,7 @@ try {
? `\n(${installRotatingBoardTickets.toString()})(${JSON.stringify(fixture.board)}, window.openclawControlUiE2eGateway);`
: "";
await page.addInitScript({
content: `;(() => { if (window !== window.top || location.protocol === "about:") return; const __name = (fn) => fn; (${installWidgetReadiness.toString()})(); ${createControlUiMockGatewayInitScript(prepared.scenario)}\n${rigProbeScript}\nwindow.__teamScaleSidebarLists = { apply: (mutation) => window.__teamScaleMutate(mutation) }; window.openclawControlUiE2eGateway.setRequestHandler("sessions.list", async ({ params, respond }) => respond(await window.__teamScaleList(params))); window.openclawControlUiE2eGateway.setRequestHandler("cron.list", async ({ params, respond }) => respond(await window.__teamScaleCronList(params)));\n${boardTickets}\n${createColdLoadProbeInitScript({ latencyMs, downloadKbps: values["critical-path"] || latencyProfile === "live-capped" ? downloadKbps : undefined, methodLatencyMs, sidebarQueryLatencyMs, omitHelloSelfPresence: values["omit-hello-self-presence"], connectNotBeforeMs, frameGapMs, events: fixture.events, liveEvents: fixture.liveEvents, liveHistory: fixture.liveHistory, ownerProbes, sidebarCounters })}\n${streamingReply ? createStreamingReplyProbeInitScript() : ""}\n${extraInitScripts.join("\n")}\n${warmProbes}${presentationProbes} })();`,
content: `;(() => { if (window !== window.top || location.protocol === "about:") return; const __name = (fn) => fn; (${installWidgetReadiness.toString()})(); ${createControlUiMockGatewayInitScript(gatewayScenario)}\n${witnessInit}\n${rigProbeScript}\nwindow.__teamScaleSidebarLists = { apply: (mutation) => window.__teamScaleMutate(mutation) }; window.openclawControlUiE2eGateway.setRequestHandler("sessions.list", async ({ params, respond }) => respond(await window.__teamScaleList(params))); window.openclawControlUiE2eGateway.setRequestHandler("cron.list", async ({ params, respond }) => respond(await window.__teamScaleCronList(params)));\n${boardTickets}\n${createColdLoadProbeInitScript({ latencyMs, downloadKbps: values["critical-path"] || latencyProfile === "live-capped" ? downloadKbps : undefined, methodLatencyMs, sidebarQueryLatencyMs, omitHelloSelfPresence: values["omit-hello-self-presence"], connectNotBeforeMs, frameGapMs, events: fixture.events, liveEvents: fixture.liveEvents, liveHistory: fixture.liveHistory, ownerProbes, sidebarCounters })}\n${streamingReply ? createStreamingReplyProbeInitScript() : ""}\n${extraInitScripts.join("\n")}\n${warmProbes}${presentationProbes} })();`,
});
if (values["critical-path"]) {
await page.addInitScript({
@ -1591,6 +1709,7 @@ try {
.evaluate(() => ({
...Reflect.get(window, "__coldLoad")?.read(),
widgetReadiness: Reflect.get(window, "__readWidgetReadiness")?.(),
transcriptWitnesses: Reflect.get(window, "__warmReload")?.witnesses(),
}))
.catch(() => ({ unavailable: "Page navigated while capturing diagnostics" }));
await writeFile(
@ -1940,6 +2059,58 @@ try {
}
}
}
if (transcriptWitness) {
const seeds = warmStates.map((storage, bundleIndex) => {
const snapshotRecord = storage.records.snapshots.find((row) =>
row.sessionKey.endsWith(`\u0000${fixture.selectedSessionKey}`),
);
const messages = snapshotRecord?.snapshot.messages ?? [];
const present = (source: "saved" | "live") =>
messages.filter((message) => {
const messageRecord = asNullableRecord(message);
return (
asNullableRecord(messageRecord?.["__openclaw"])?.id ===
transcriptWitness.witnesses[source].messageId &&
(JSON.stringify(messageRecord?.content) ?? "").includes(
transcriptWitness.witnesses[source].text,
)
);
}).length;
return {
bundleIndex,
messageCount: messages.length,
saved: present("saved"),
live: present("live"),
};
});
await writeFile(
path.join(artifactDir, "transcript-witness-fixture.json"),
JSON.stringify(
{
...transcriptWitness,
variants: undefined,
seededAt: new Date().toISOString(),
seeds,
measurement: values["transcript-witness-hold"] ? "held-correctness" : "natural-timing",
},
null,
2,
),
);
if (
seeds.length !== bundleSlots.length ||
seeds.some(
(seed) =>
seed.messageCount !== transcriptWitness.snapshotMessageCount ||
seed.saved !== 1 ||
seed.live !== 0,
)
) {
throw new Error(
"Native snapshot seed did not preserve exactly one saved witness and no live witness",
);
}
}
let historyValidatorRequests = 0;
if (historyValidatorDelayMs > 0) {
for (const storage of warmStates) {
@ -2025,10 +2196,27 @@ try {
return { ...mapped, summary };
}
const validatorRequestsBeforeReload = historyValidatorRequests;
const witnessReload = transcriptWitness
? {
restoreStartedAt: new Date().toISOString(),
phase: "live",
measurement: values["transcript-witness-hold"] ? "held-correctness" : "natural-timing",
}
: undefined;
let witnessHold: unknown;
const cdp = await context.newCDPSession(page);
if (cold) {
await resetColdState(cdp, bundleIndex);
} else {
if (transcriptWitness) {
reloadStorage = {
...reloadStorage!,
local: [
...reloadStorage!.local.filter(([key]) => key !== witnessPhaseKey),
[witnessPhaseKey, "live"],
],
};
}
await restoreColdLoadWarmState(page, reloadStorage!, layoutCell && reloadStorage);
await selectComparisonBundle(bundleIndex, page);
}
@ -2194,6 +2382,48 @@ try {
} else {
await page.reload({ waitUntil: "domcontentloaded" });
}
if (values["transcript-witness-hold"]) {
await page.waitForFunction(
() => typeof Reflect.get(window, "__warmReload").witnesses()?.saved.paintMs === "number",
undefined,
{ timeout: 10_000 },
);
await page.screenshot({ path: path.join(artifactDir, `run-${run + 1}.saved-witness.png`) });
// Saved paint can precede startup, including its nested subscription response queue.
const released = await page.waitForFunction(
() => {
const beforeRelease = Reflect.get(window, "__warmReload")?.witnesses();
const releasedAtMs = performance.now();
try {
Reflect.get(window, "openclawControlUiE2eGateway").resolveDeferred("chat.startup");
} catch (error) {
if (
error instanceof Error &&
error.message === "No deferred mock Gateway response for chat.startup"
) {
return false;
}
throw error;
}
return { releasedAtMs, beforeRelease };
},
undefined,
{ timeout: 10_000 },
);
witnessHold = await released.jsonValue();
await released.dispose();
await writeFile(
path.join(artifactDir, `run-${run + 1}.witness-hold.json`),
JSON.stringify(
{
...witnessReload,
observation: witnessHold,
},
null,
2,
),
);
}
await settle(runRoute);
if (values["late-row-facts"]) {
// Seeds are qualified above; best-effort live link prefetch can skip a reload.
@ -2361,6 +2591,54 @@ try {
values["warm-reload"] || firstLoad
? await page.evaluate(() => Reflect.get(window, "__warmReload").read())
: undefined;
if (transcriptWitness) {
const observed = warmReload?.transcriptWitnesses;
const cache =
observed?.history.filter((entry: { source: string }) => entry.source === "snapshot-read") ??
[];
const wire =
observed?.history.filter((entry: { source: string }) => entry.source !== "snapshot-read") ??
[];
const valid =
observed &&
cache.some(
(entry: { saved: number; live: number; messageCount: number }) =>
entry.saved === 1 &&
entry.live === 0 &&
entry.messageCount === transcriptWitness.snapshotMessageCount,
) &&
wire.length > 0 &&
wire.every(
(entry: { saved: number; live: number; messageCount: number }) =>
entry.saved === 0 &&
entry.live === 1 &&
entry.messageCount === transcriptWitness.messageCount,
) &&
observed.attached.live &&
!observed.attached.saved &&
observed.live.paintMs !== null;
await writeFile(
path.join(artifactDir, `run-${run + 1}.transcript-witness.json`),
JSON.stringify(
{
...witnessReload,
capturedAt: new Date().toISOString(),
hold: witnessHold,
observed,
valid: Boolean(valid),
savedPaintObserved:
observed?.saved.paintMs !== null && observed?.saved.paintMs !== undefined,
},
null,
2,
),
);
if (!valid) {
throw new Error(
"Saved/live witness provenance or final live presentation was not established; see transcript-witness artifact",
);
}
}
const paneChrome = values["pane-chrome-probe"]
? {
...(await page.evaluate(
@ -3400,6 +3678,7 @@ try {
sessionSwitch,
criticalPath,
warmReload,
transcriptWitnessReload: witnessReload,
paneChrome,
sidebarPresentation,
historyValidatorRequests: historyValidatorRequests - validatorRequestsBeforeReload,
@ -3494,6 +3773,17 @@ try {
? "Production Gateway HTTP handler; synthetic bootstrap, WebSocket RPC and fixture media. Immutable server asset caches stay warm per bundle."
: "Vite production preview HTTP; synthetic bootstrap, WebSocket RPC and fixture media.",
firstLoad,
transcriptWitness: transcriptWitness
? {
witnesses: transcriptWitness.witnesses,
preparedAt: transcriptWitness.preparedAt,
messageCount: transcriptWitness.messageCount,
snapshotMessageCount: transcriptWitness.snapshotMessageCount,
measurement: values["transcript-witness-hold"]
? "held-correctness"
: "natural-timing",
}
: undefined,
gatewayHttp: values["gateway-http"],
bootRoute,
coldHttpCache: values["cold-http-cache"],

View file

@ -1,8 +1,17 @@
type TranscriptWitness = { messageId: string; rowKey: string; text: string };
export type TranscriptWitnesses = { saved: TranscriptWitness; live: TranscriptWitness };
/** Browser-only diagnostic, serialized after the mock socket and wire-delay probe. */
export function warmReloadProbe(
input: { rowFacts?: boolean; avatars?: boolean; lateRowFacts?: boolean } = {},
input: {
rowFacts?: boolean;
avatars?: boolean;
lateRowFacts?: boolean;
witnesses?: TranscriptWitnesses;
} = {},
samplePaneChrome?: (at: number) => void,
) {
const witnesses = input.witnesses;
const milestones: Record<string, number> = {};
const mutations: Array<{ at: number; count: number; region: string }> = [];
const shifts: Array<{ at: number; value: number }> = [];
@ -46,6 +55,61 @@ export function warmReloadProbe(
};
const transcriptPaints: Array<{ renderTime: number; element: string; rowKey: string | null }> =
[];
type WitnessSource = "saved" | "live";
const witnessElements = new WeakMap<Element, { source: WitnessSource; rowKey: string }>();
const witnessPaints: Array<{
source: WitnessSource;
identifier: string;
rowKey: string;
renderTime: number;
}> = [];
const witnessHistory: Array<{
atMs: number;
source: string;
messageCount: number | null;
saved: number;
live: number;
}> = [];
const recordWitnessHistory = (source: string, messages: unknown) => {
if (!witnesses) {
return;
}
const matches = { saved: 0, live: 0 };
for (const message of Array.isArray(messages) ? messages : []) {
if (!message || typeof message !== "object") {
continue;
}
const metadata = Reflect.get(message, "__openclaw");
const id = metadata && typeof metadata === "object" ? Reflect.get(metadata, "id") : null;
for (const witnessSource of ["saved", "live"] as const) {
if (id !== witnesses[witnessSource].messageId) {
continue;
}
const content = Reflect.get(message, "content");
const text =
typeof content === "string"
? content
: Array.isArray(content)
? content
.flatMap((block) =>
block && typeof block === "object" && Reflect.get(block, "type") === "text"
? [Reflect.get(block, "text")]
: [],
)
.join("\n")
: "";
if (text.includes(witnesses[witnessSource].text)) {
matches[witnessSource]++;
}
}
}
witnessHistory.push({
atMs: performance.now(),
source,
messageCount: Array.isArray(messages) ? messages.length : null,
...matches,
});
};
const largestPaints: Array<{
renderTime: number;
loadTime: number;
@ -163,8 +227,10 @@ export function warmReloadProbe(
// Element Timing is implemented by Chromium but absent from lib.dom.
const paint = entry as ElementPaint;
const row = paint.element?.closest(".chat-virtual-row");
const witness = paint.element ? witnessElements.get(paint.element) : undefined;
if (
paint.identifier !== "openclaw-transcript" ||
(paint.identifier !== "openclaw-transcript" &&
(!witness || paint.identifier !== `openclaw-transcript-${witness.source}`)) ||
!row ||
!paint.element ||
paint.renderTime <= 0 ||
@ -179,6 +245,16 @@ export function warmReloadProbe(
element: elementLabel(paint.element),
rowKey: row.getAttribute("data-virtual-row-key"),
});
if (witness && paint.identifier === `openclaw-transcript-${witness.source}`) {
milestones[
witness.source === "saved" ? "firstSavedWitnessPaint" : "firstLiveWitnessPaint"
] ??= paint.renderTime;
witnessPaints.push({
...witness,
identifier: paint.identifier,
renderTime: paint.renderTime,
});
}
}
});
observe("largest-contentful-paint", (entries) => {
@ -245,6 +321,7 @@ export function warmReloadProbe(
});
if (store === "snapshots" && request.result !== undefined) {
mark("cacheRead");
recordWitnessHistory("snapshot-read", request.result?.snapshot?.messages);
}
});
return request;
@ -316,6 +393,19 @@ export function warmReloadProbe(
) {
mark("catchupResponse");
}
if (frame.ok === true) {
if (method === "chat.startup" || method === "chat.history") {
recordWitnessHistory(method, frame.payload?.messages);
} else if (
method === "sessions.messages.subscribe" &&
frame.payload?.initialHistory?.ok === true
) {
recordWitnessHistory(
"subscription-initial-history",
frame.payload.initialHistory.payload?.messages,
);
}
}
}
});
return socket;
@ -324,6 +414,7 @@ export function warmReloadProbe(
const observer = new MutationObserver((records) => {
const regions = new Map<string, number>();
const textRoots = new Set<Element>();
const addedRoots = new Set<Element>();
const textSelector = input.lateRowFacts ? ".chat-text, .chat-notice" : ".chat-text";
for (const record of records) {
const element =
@ -353,6 +444,9 @@ export function warmReloadProbe(
if (!(node instanceof Element)) {
continue;
}
if (witnesses) {
addedRoots.add(node);
}
if (node.matches(textSelector)) {
textRoots.add(node);
}
@ -377,15 +471,34 @@ export function warmReloadProbe(
// Tag direct text owners before paint. A row/container attribute does not
// cover descendant block text, and a DOM/rAF timestamp is not presentation.
for (const root of textRoots) {
if (!root.closest(".chat-virtual-row")) {
const row = root.closest(".chat-virtual-row");
if (!row) {
continue;
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let text: Node | null;
while ((text = walker.nextNode())) {
const parent = text.parentElement;
if (text.textContent?.trim() && parent && !parent.hasAttribute("elementtiming")) {
parent.setAttribute("elementtiming", "openclaw-transcript");
const textContent = text.textContent;
if (textContent?.trim() && parent && !parent.hasAttribute("elementtiming")) {
const rowKey = row.getAttribute("data-virtual-row-key");
const source = witnesses
? (["saved", "live"] as const).find(
(kind) =>
witnesses[kind].rowKey === rowKey &&
textContent.includes(witnesses[kind].text) &&
[...addedRoots].some((added) => added === parent || added.contains(parent)),
)
: undefined;
// Only a fresh text owner may become a witness. Never relabel an already-painted node.
if (source && rowKey) {
witnessElements.set(parent, { source, rowKey });
mark(source === "saved" ? "firstSavedWitnessDom" : "firstLiveWitnessDom");
}
parent.setAttribute(
"elementtiming",
source ? `openclaw-transcript-${source}` : "openclaw-transcript",
);
}
}
}
@ -644,7 +757,35 @@ export function warmReloadProbe(
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
const readWitnesses = () =>
witnesses
? {
history: [...witnessHistory],
paints: [...witnessPaints],
saved: {
domMs: milestones.firstSavedWitnessDom ?? null,
paintMs: milestones.firstSavedWitnessPaint ?? null,
},
live: {
domMs: milestones.firstLiveWitnessDom ?? null,
paintMs: milestones.firstLiveWitnessPaint ?? null,
},
attached: Object.fromEntries(
(["saved", "live"] as const).map((source) => [
source,
[
...document.querySelectorAll(".chat-pane-cache__pane--active .chat-virtual-row"),
].some(
(row) =>
row.getAttribute("data-virtual-row-key") === witnesses[source].rowKey &&
row.querySelector(".chat-text")?.textContent?.includes(witnesses[source].text),
),
]),
),
}
: undefined;
Reflect.set(window, "__warmReload", {
witnesses: readWitnesses,
read() {
stopped = true;
observer.disconnect();
@ -737,6 +878,7 @@ export function warmReloadProbe(
},
measures: performance.getEntriesByType("measure").map((entry) => entry.toJSON()),
transcriptPaints,
transcriptWitnesses: readWitnesses(),
largestPaints,
reads,
historyRequests,

View file

@ -133,6 +133,7 @@ suite.define(() => {
expect(cold.params).toHaveProperty("initialHistory");
expect(cold.params).not.toHaveProperty("initialHistory.cursor");
await gateway.resolveDeferred("sessions.messages.subscribe");
await gateway.resolveDeferred("chat.startup");
await expectTranscript(page, ["Saved question", "Saved answer"]);
expect(await gateway.getRequests("chat.startup")).toEqual([]);
await waitForControlUiInitialRoster(page);
@ -258,10 +259,13 @@ suite.define(() => {
await gateway.waitForRequest("connect", { after: connects });
await gateway.resolveDeferred("connect");
await waitForControlUiGatewayReady(page);
await gateway.waitForRequest("sessions.messages.subscribe", {
match: { key: sessionKey },
after: subscriptions,
});
const replacementSubscription = await gateway.waitForRequest(
"sessions.messages.subscribe",
{
match: { key: sessionKey },
after: subscriptions,
},
);
await gateway.resolveDeferred(
"sessions.messages.subscribe",
{
@ -283,6 +287,13 @@ suite.define(() => {
{ match: warm.params, exactParams: true },
);
await gateway.resolveDeferred("sessions.messages.subscribe");
if (
isRecord(replacementSubscription.params) &&
isRecord(replacementSubscription.params.initialHistory)
) {
await gateway.deferNext("chat.startup");
await gateway.resolveDeferred("chat.startup");
}
const scopedRefill = await gateway.waitForRequest("chat.startup");
expect(scopedRefill.params).not.toHaveProperty("cursor");
for (const text of ["Next question", "Next answer"]) {
@ -302,6 +313,10 @@ suite.define(() => {
await expectTranscript(page, ["Saved question", "Saved answer"]);
await updateSession(finalSession);
await gateway.resolveDeferred("sessions.messages.subscribe");
if (reset) {
await gateway.deferNext("chat.startup");
}
await gateway.resolveDeferred("chat.startup");
if (reset) {
const refill = await gateway.waitForRequest("chat.startup");
expect(refill.params).not.toHaveProperty("cursor");

View file

@ -1,3 +1,12 @@
type TeamScaleAudioAttachment = {
type: "audio";
artifactId: string;
url: string;
fileName: string;
mimeType: string;
durationMs: number;
};
export function createTeamScaleDuplicateReadFixture(sessionKey: string, now: number) {
const mediaId = "00000000-0000-4000-8000-000000000001";
const mediaPath = `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${mediaId}/full`;
@ -12,7 +21,7 @@ export function createTeamScaleDuplicateReadFixture(sessionKey: string, now: num
branch: "fixture/duplicate-reads",
repoRoot: "/synthetic/team-scale-project",
},
attachments: Array.from({ length: 6 }, (_unused, alias) => ({
attachments: Array.from({ length: 6 }, (_unused, alias): TeamScaleAudioAttachment => ({
type: "audio",
artifactId: `artifact_managed_media_${mediaId}`,
url: `${mediaPath}?mediaTicket=historical-${alias}`,

View file

@ -108,3 +108,94 @@ it("keeps roster deferrals separate from child queries", async ({ gatewayPage })
expect(ids(rosterRequests)).toEqual(["roster-held", "roster-next"]);
expect(gateway.deferNext("sessions.list", rosterMatch)).toBe(rosterRequests.length);
});
it.for(["resolve", "reject"] as const)(
"%ss startup held inside the subscription response without a second wire request",
async (outcome, { gatewayPage }) => {
const messages = [{ role: "assistant", content: "Live witness" }];
gatewayPage.execute(
createControlUiMockGatewayInitScript({
deferredMethods: ["chat.startup"],
historyMessages: messages,
}),
);
const { send, frames } = gatewayPage.connect();
const gateway = (gatewayPage.window as MockGatewayWindow).openclawControlUiE2eGateway!;
await flush();
send("subscription", "sessions.messages.subscribe", {
key: "agent:main:main",
initialHistory: {},
});
await flush();
expect(frames.filter((frame) => frame.type === "res")).toEqual([]);
expect(gateway.findRequests("chat.startup")).toEqual([]);
expect(gateway.findRequests("sessions.messages.subscribe")).toHaveLength(1);
if (outcome === "resolve") {
gateway.resolveDeferred("chat.startup");
} else {
gateway.rejectDeferred("chat.startup", { message: "History unavailable" });
}
expectReply(frames, "subscription", {
key: "agent:main:main",
initialHistory:
outcome === "resolve"
? { ok: true, payload: expect.objectContaining({ messages }) }
: { ok: false, error: expect.objectContaining({ message: "History unavailable" }) },
});
},
);
it("preserves explicitly supplied deferred subscription payloads", async ({ gatewayPage }) => {
gatewayPage.execute(
createControlUiMockGatewayInitScript({
heldMethods: ["chat.startup"],
}),
);
const { send, frames } = gatewayPage.connect();
const gateway = (gatewayPage.window as MockGatewayWindow).openclawControlUiE2eGateway!;
await flush();
const key = "agent:main:main";
const payloads = [
{ key },
{ key, initialHistory: { ok: true, payload: { messages: ["Explicit history"] } } },
];
for (const [index, payload] of payloads.entries()) {
const id = `explicit-${index}`;
gateway.deferNext("sessions.messages.subscribe");
send(id, "sessions.messages.subscribe", { key, initialHistory: {} });
await flush();
gateway.resolveDeferred("sessions.messages.subscribe", payload);
expect(frames.at(-1)).toMatchObject({ id, ok: true });
expect(frames.at(-1)?.payload).toEqual(payload);
}
});
it("replaces the static startup and history transcript together", async ({ gatewayPage }) => {
const stale = { messages: [{ role: "assistant", content: "Saved witness" }], hasMore: false };
gatewayPage.execute(
createControlUiMockGatewayInitScript({
methodResponses: { "chat.startup": stale, "chat.history": stale },
}),
);
const { request } = gatewayPage.connect();
const gateway = (gatewayPage.window as MockGatewayWindow).openclawControlUiE2eGateway!;
await flush();
const messages = [{ role: "assistant", content: "Live witness" }];
gateway.setHistoryMessages(messages);
for (const method of ["chat.startup", "chat.history"]) {
expect(await request(method, method, { sessionKey: "agent:main:main" })).toEqual({
messages,
hasMore: false,
});
}
expect(
await request("subscription", "sessions.messages.subscribe", {
key: "agent:main:main",
initialHistory: {},
}),
).toEqual({
key: "agent:main:main",
initialHistory: { ok: true, payload: { messages, hasMore: false } },
});
});

View file

@ -667,11 +667,9 @@ function installControlUiMockGateway(
) {
const NativeWebSocket = window.WebSocket;
type DeferredResponse = {
preferenceScope: ControlUiMockPreferenceScope | null;
id: string;
method: string;
params?: unknown;
socket: MockWebSocket;
respond: (response: unknown, defaultResponse?: boolean) => void;
};
type MockTerminalSession = {
sessionId: string;
@ -1880,13 +1878,11 @@ function installControlUiMockGateway(
case "sessions.subscribe":
return { subscribed: true };
case "sessions.messages.subscribe":
return subscriptionRouting.subscribeResponse(params, (historyParams) =>
commitFixtureResponse(
"chat.startup",
historyParams,
buildResponse("chat.startup", historyParams),
return {
key: subscriptionRouting.canonicalKey(
isRecord(params) && typeof params.key === "string" ? params.key : "",
),
);
};
case "sessions.messages.unsubscribe":
return { ok: true };
case "terminal.open": {
@ -1984,6 +1980,53 @@ function installControlUiMockGateway(
});
}
function prepareResponse(
method: string,
params: unknown,
socket: MockWebSocket,
respond: (response: unknown, deferred?: boolean) => void,
): (() => void) | undefined {
const complete = (response: unknown, deferred = false, defaultResponse = false) => {
if (
defaultResponse &&
method === "sessions.messages.subscribe" &&
!hasOwn(scenario.methodResponses, method) &&
subscriptionRouting.completeInitialHistory(
params,
response,
(historyParams, reply) =>
prepareResponse("chat.startup", historyParams, socket, (history, historyDeferred) =>
reply(commitFixtureResponse("chat.startup", historyParams, history), historyDeferred),
)?.(),
(payload, historyDeferred) => respond(payload, deferred || historyDeferred),
)
) {
return;
}
respond(response, deferred);
};
if (deferredRequests.shouldDefer(method, params)) {
deferredRequests.responses.push({
method,
params,
respond: (response, defaultResponse) => complete(response, true, defaultResponse),
});
return undefined;
}
return () => {
const handler = requestHandlers.get(method);
if (handler) {
handler({
params,
respond: complete,
emit: (event, payload) => emitGatewayEvent(socket, event, payload),
});
} else {
complete(buildResponse(method, params), false, true);
}
};
}
class MockWebSocket extends EventTarget {
static readonly CLOSED = 3;
static readonly CLOSING = 2;
@ -2080,17 +2123,7 @@ function installControlUiMockGateway(
}
requests.push({ id, method, params: frame.params });
const preferenceScope = this.preferenceScope;
if (deferredRequests.shouldDefer(method, frame.params)) {
deferredRequests.responses.push({
id,
method,
params: frame.params,
socket: this,
preferenceScope,
});
return;
}
const respond = (response: unknown) => {
const respond = (response: unknown, deferred = false) => {
const payload = commitFixtureResponse(
method,
frame.params,
@ -2112,6 +2145,10 @@ function installControlUiMockGateway(
emitTerminalOutput(this, method, frame.params, payload);
preferenceFixtures.publish(this, method, frame.params, payload);
}
// A held reply acknowledges the RPC; its test owns later lifecycle events.
if (deferred) {
return;
}
if (!mockError && method === "connect" && this.readyState === MockWebSocket.OPEN) {
this.tickTimer = window.setInterval(() => {
this.deliver({ event: "tick", payload: {}, seq: ++seq, type: "event" });
@ -2140,20 +2177,10 @@ function installControlUiMockGateway(
});
}
};
window.setTimeout(() => {
const handler = requestHandlers.get(method);
if (handler) {
// Delayed fixtures retain this request and socket, even when another
// request for the same method finishes first.
handler({
params: frame.params,
respond,
emit: (event, payload) => emitGatewayEvent(this, event, payload),
});
} else {
respond(buildResponse(method, frame.params));
}
}, 0);
const dispatch = prepareResponse(method, frame.params, this, respond);
if (dispatch) {
window.setTimeout(dispatch, 0);
}
}
suspend(): void {
@ -2198,55 +2225,24 @@ function installControlUiMockGateway(
),
rejectDeferred(method, error, options) {
for (const response of deferredRequests.take(method, options)) {
response.socket.deliver({
error: {
response.respond({
__mockError: {
code: error?.code ?? "INVALID_REQUEST",
message: error?.message ?? "mock Gateway rejected request",
...(error?.details ? { details: error.details } : {}),
...(error?.retryable ? { retryable: true } : {}),
},
id: response.id,
ok: false,
type: "res",
});
}
},
requests,
resolveDeferred(method, payload, options) {
for (const response of deferredRequests.take(method, options)) {
const resolvedPayload = commitFixtureResponse(
response.method,
response.params,
responseFixtures.applyAgentModel(
response.method,
payload ?? buildResponse(response.method, response.params),
),
response.socket,
response.preferenceScope,
const responsePayload = payload ?? buildResponse(response.method, response.params);
response.respond(
responseFixtures.applyAgentModel(response.method, responsePayload),
payload == null && !requestHandlers.has(method),
);
const mockError = isRecord(resolvedPayload) ? resolvedPayload["__mockError"] : undefined;
if (!mockError) {
response.socket.sessionMessageSubscriptions.recordRequest(
response.method,
response.params,
resolvedPayload,
);
}
response.socket.deliver({
id: response.id,
ok: !mockError,
...(mockError ? { error: mockError } : { payload: resolvedPayload }),
type: "res",
});
if (!mockError) {
emitTerminalOutput(response.socket, response.method, response.params, resolvedPayload);
preferenceFixtures.publish(
response.socket,
response.method,
response.params,
resolvedPayload,
);
}
}
},
suspendLatest() {
@ -2341,9 +2337,13 @@ function installControlUiMockGateway(
setHistoryMessages(messages) {
historyMessagesOverridden = true;
scenario.historyMessages = Array.isArray(messages) ? messages : [];
const configuredHistory = scenario.methodResponses["chat.history"];
if (isRecord(configuredHistory) && !responseFixtures.cases(configuredHistory)) {
configuredHistory.messages = scenario.historyMessages;
// Static startup and history responses describe the same overridden transcript.
// Case tables remain intentional query-specific fixtures.
for (const method of ["chat.startup", "chat.history"]) {
const configuredHistory = scenario.methodResponses[method];
if (isRecord(configuredHistory) && !responseFixtures.cases(configuredHistory)) {
configuredHistory.messages = scenario.historyMessages;
}
}
},
socketCount() {

View file

@ -26,27 +26,41 @@ export function createControlUiMockSessionSubscriptions(
};
return {
canonicalKey,
subscribeResponse(params: unknown, readHistory: (params: Record<string, unknown>) => unknown) {
const key = canonicalKey(
isRecord(params) && typeof params.key === "string" ? params.key : "",
);
completeInitialHistory(
params: unknown,
response: unknown,
readHistory: (
historyParams: Record<string, unknown>,
respond: (history: unknown, deferred?: boolean) => void,
) => void,
respond: (response: unknown, deferred?: boolean) => void,
): boolean {
if (
!scenario.subscriptionInitialHistory ||
!isRecord(params) ||
!isRecord(params.initialHistory)
!isRecord(params.initialHistory) ||
!isRecord(response) ||
Object.hasOwn(response, "__mockError")
) {
return { key };
return false;
}
const payload = readHistory({
const historyParams = {
...params.initialHistory,
sessionKey: key,
sessionKey: response.key,
agentId: params.agentId,
});
const error = isRecord(payload) ? payload["__mockError"] : undefined;
return {
key,
initialHistory: isRecord(error) ? { ok: false, error } : { ok: true, payload },
};
// The caller owns dispatch; completion may wait for a held nested startup reply.
readHistory(historyParams, (history, deferred) => {
const error = isRecord(history) ? history["__mockError"] : undefined;
respond(
{
...response,
initialHistory: isRecord(error) ? { ok: false, error } : { ok: true, payload: history },
},
deferred,
);
});
return true;
},
createClient(lifecycle?: {
isOpen: () => boolean;

View file

@ -3,9 +3,9 @@ import type { OpenClawConfig } from "../../../src/config/types.openclaw.js";
import type { ConfigSnapshot } from "../api/types.ts";
import type { ControlUiMockGatewayScenario } from "./control-ui-e2e.ts";
export function withTeamScaleConfig(
scenario: ControlUiMockGatewayScenario,
): ControlUiMockGatewayScenario {
export function withTeamScaleConfig<T extends ControlUiMockGatewayScenario>(
scenario: T,
): Omit<ControlUiMockGatewayScenario, "historyMessages"> & Pick<T, "historyMessages"> {
const { display, snapshot } = createTeamScaleConfig();
return {
...scenario,

View file

@ -6,6 +6,14 @@ import type { createTeamScaleDuplicateReadFixture } from "./control-ui-duplicate
import type { ControlUiMockPresenceUser } from "./control-ui-mock-presence.ts";
import type { ControlUiSessionFixture } from "./control-ui-session-fixtures.ts";
type TeamScaleHistoryContent =
| { type: "text"; text: string }
| ({ type: "image"; mimeType: string; fileName: string } & (
| { artifactId: string }
| { url: string }
))
| ReturnType<typeof createTeamScaleDuplicateReadFixture>["attachments"][number];
export type TeamScaleFixtureAsset = {
body: string | Buffer;
contentType: string;
@ -132,7 +140,7 @@ export function createTeamScaleHistoryFixture(options: {
content: [
...(options.attachments && index === options.messageCount - 1 ? options.attachments : []),
...(lateRowFacts && index === options.messageCount - 1
? [
? ([
{
type: "image",
artifactId: lateRowFacts.artifactId,
@ -145,7 +153,7 @@ export function createTeamScaleHistoryFixture(options: {
mimeType: "image/png",
fileName: "Synthetic cacheable thumbnail.png",
},
]
] satisfies TeamScaleHistoryContent[])
: []),
{
type: "text",
@ -169,7 +177,7 @@ export function createTeamScaleHistoryFixture(options: {
: []),
].join("\n\n"),
},
],
] satisfies TeamScaleHistoryContent[],
}));
if (options.rowFacts) {
const tail = historyMessages.slice(-3);

View file

@ -176,3 +176,105 @@ it.for(["hidden", "clipped", "animated"] as const)(
}
},
);
it("keeps missing, hidden, saved and live transcript witness paints distinct", async () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
try {
const target = iframe.contentWindow;
const doc = iframe.contentDocument;
if (!target || !doc) {
throw new Error("Paint witness realm was not created");
}
const witnesses = {
saved: { messageId: "cached", rowKey: "saved-row", text: "Saved witness text." },
live: { messageId: "latest", rowKey: "live-row", text: "Fresh witness text." },
};
const script = doc.createElement("script");
script.textContent = `
const __name = fn => fn;
(${warmReloadProbe.toString()})({ witnesses: ${JSON.stringify(witnesses)} });
const pane = document.createElement('div');
pane.className = 'chat-pane-cache__pane--active';
document.body.append(pane);
const frames = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const paint = identifier => new Promise((resolve, reject) => {
const timer = setTimeout(() => { observer.disconnect(); reject(new Error('Missing paint: ' + identifier)); }, 3000);
const observer = new PerformanceObserver(list => {
if (!list.getEntries().some(entry => entry.identifier === identifier && entry.renderTime > 0)) return;
clearTimeout(timer);
observer.disconnect();
resolve();
});
observer.observe({ type: 'element', buffered: true });
});
const add = (key, text, hidden = false) => {
const row = document.createElement('div');
row.className = 'chat-virtual-row';
row.dataset.virtualRowKey = key;
row.style.opacity = hidden ? '0' : '1';
const root = document.createElement('div');
root.className = 'chat-text';
const paragraph = document.createElement('p');
paragraph.textContent = text;
root.append(paragraph);
row.append(root);
pane.append(row);
return row;
};
window.paintProof = (async () => {
// Matching text in another row is not provenance.
const unrelated = add('other-row', 'Saved witness text.');
await paint('openclaw-transcript');
const missing = window.__warmReload.witnesses();
unrelated.dataset.virtualRowKey = 'saved-row';
await frames();
const alreadyPainted = window.__warmReload.witnesses();
unrelated.remove();
const saved = add('saved-row', 'Saved witness text.', true);
await frames();
const hiddenSaved = window.__warmReload.witnesses();
const savedShownAt = performance.now();
saved.style.opacity = '1';
await paint('openclaw-transcript-saved');
const savedPaint = window.__warmReload.witnesses();
saved.remove();
const live = add('live-row', 'Fresh witness text.', true);
await frames();
const hiddenLive = window.__warmReload.witnesses();
const liveShownAt = performance.now();
live.style.opacity = '1';
await paint('openclaw-transcript-live');
return { missing, alreadyPainted, hiddenSaved, savedShownAt, savedPaint, hiddenLive, liveShownAt, final: window.__warmReload.read() };
})();
`;
doc.body.append(script);
const proof = await Reflect.get(target, "paintProof");
expect(proof.missing.saved).toEqual({ domMs: null, paintMs: null });
expect(proof.missing.live).toEqual({ domMs: null, paintMs: null });
expect(proof.alreadyPainted.saved.paintMs).toBeNull();
expect(proof.hiddenSaved.saved.domMs).toBeTypeOf("number");
expect(proof.hiddenSaved.saved.paintMs).toBeNull();
expect(proof.savedPaint.saved.paintMs).toBeGreaterThanOrEqual(proof.savedShownAt);
expect(proof.savedPaint.live.paintMs).toBeNull();
expect(proof.hiddenLive.live.domMs).toBeTypeOf("number");
expect(proof.hiddenLive.live.paintMs).toBeNull();
expect(proof.final.transcriptWitnesses.live.paintMs).toBeGreaterThanOrEqual(proof.liveShownAt);
expect(proof.final.transcriptWitnesses.saved.paintMs).toBe(proof.savedPaint.saved.paintMs);
expect(proof.final.transcriptWitnesses.attached).toEqual({ saved: false, live: true });
expect(proof.final.transcriptWitnesses.paints).toContainEqual({
source: "saved",
identifier: "openclaw-transcript-saved",
rowKey: "saved-row",
renderTime: proof.savedPaint.saved.paintMs,
});
expect(proof.final.transcriptWitnesses.paints).toContainEqual({
source: "live",
identifier: "openclaw-transcript-live",
rowKey: "live-row",
renderTime: proof.final.transcriptWitnesses.live.paintMs,
});
} finally {
iframe.remove();
}
});