feat: add web sentinel session invariance checks
This commit is contained in:
@@ -354,6 +354,9 @@ async function processCommand(command) {
|
||||
case "cancel": return withObserverSync(await cancelRunningTurn(), "cancel");
|
||||
case "selectProvider": return withObserverSync(await selectProvider(String(command.provider || command.value || command.text || "")), "selectProvider");
|
||||
case "clickSession": return withObserverSync(await clickSession(String(command.sessionId || command.value || "")), "clickSession");
|
||||
case "refreshCurrentSession": return withObserverSync(await refreshCurrentSession(command), "refreshCurrentSession");
|
||||
case "switchAwayAndBack": return withObserverSync(await switchAwayAndBack(command), "switchAwayAndBack");
|
||||
case "assertSessionInvariant": return withObserverSync(await assertSessionInvariant(command), "assertSessionInvariant");
|
||||
case "gotoProjectMdtodo": return withObserverSync(await gotoProjectMdtodo(), "gotoProjectMdtodo");
|
||||
case "openMdtodoSourceConfig": return openMdtodoSourceConfig(command);
|
||||
case "configureMdtodoHwpodSource": return configureMdtodoHwpodSource(command);
|
||||
@@ -1535,6 +1538,295 @@ async function clickSession(sessionId) {
|
||||
return { beforeUrl, afterUrl: currentPageUrl(), sessionId, pageId };
|
||||
}
|
||||
|
||||
async function refreshCurrentSession(command) {
|
||||
const beforeUrl = currentPageUrl();
|
||||
const before = await workbenchSessionSnapshot();
|
||||
const sessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim();
|
||||
if (!sessionId) throw new Error("refreshCurrentSession requires a current Workbench session");
|
||||
const navigation = await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId));
|
||||
const after = await workbenchSessionSnapshot();
|
||||
return {
|
||||
beforeUrl,
|
||||
afterUrl: currentPageUrl(),
|
||||
type: "refreshCurrentSession",
|
||||
afterRound: integerOrNull(command.afterRound),
|
||||
canarySessionId: sessionId,
|
||||
routeSessionId: after?.routeSessionId ?? null,
|
||||
activeSessionId: after?.activeSessionId ?? null,
|
||||
routeOk: after?.routeSessionId === sessionId,
|
||||
activeOk: after?.activeSessionId === sessionId,
|
||||
composerReady: after?.composerReady === true,
|
||||
navigation,
|
||||
pageId,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function switchAwayAndBack(command) {
|
||||
const beforeUrl = currentPageUrl();
|
||||
const before = await workbenchSessionSnapshot();
|
||||
const canarySessionId = String(command.sessionId || command.canarySessionId || before?.routeSessionId || before?.activeSessionId || "").trim();
|
||||
if (!canarySessionId) throw new Error("switchAwayAndBack requires a current canary Workbench session");
|
||||
const strategy = String(command.alternateSessionStrategy || command.strategy || "existing-or-create");
|
||||
const beforeSessionIds = await visibleWorkbenchSessionIds();
|
||||
let alternateSessionId = beforeSessionIds.find((item) => item && item !== canarySessionId) || null;
|
||||
let alternateSource = alternateSessionId === null ? null : "existing";
|
||||
let createResult = null;
|
||||
if (alternateSessionId === null && strategy === "existing-or-create") {
|
||||
createResult = await createSessionFromUi();
|
||||
alternateSessionId = createResult?.sessionId || createResult?.createdSessionId || null;
|
||||
alternateSource = "created";
|
||||
}
|
||||
if (!alternateSessionId) throw new Error("switchAwayAndBack could not find an alternate session with strategy=" + strategy);
|
||||
const switchAway = await clickSession(alternateSessionId);
|
||||
const away = await workbenchSessionSnapshot();
|
||||
const switchBack = await gotoTarget("/workbench/sessions/" + encodeURIComponent(canarySessionId));
|
||||
const after = await workbenchSessionSnapshot();
|
||||
const routeOk = after?.routeSessionId === canarySessionId;
|
||||
const activeOk = after?.activeSessionId === canarySessionId;
|
||||
if (!routeOk || !activeOk) {
|
||||
const error = new Error("switchAwayAndBack did not return to the canary session");
|
||||
error.details = { canarySessionId, alternateSessionId, routeSessionId: after?.routeSessionId ?? null, activeSessionId: after?.activeSessionId ?? null, pageId, valuesRedacted: true };
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
beforeUrl,
|
||||
afterUrl: currentPageUrl(),
|
||||
type: "switchAwayAndBack",
|
||||
afterRound: integerOrNull(command.afterRound),
|
||||
canarySessionId,
|
||||
alternateSessionId,
|
||||
alternateSessionStrategy: strategy,
|
||||
alternateSource,
|
||||
beforeSessionCount: beforeSessionIds.length,
|
||||
createResult: createResult === null ? null : { sessionId: alternateSessionId, valuesRedacted: true },
|
||||
switchAway,
|
||||
away: {
|
||||
routeSessionId: away?.routeSessionId ?? null,
|
||||
activeSessionId: away?.activeSessionId ?? null,
|
||||
messageCount: away?.messageCount ?? null,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
switchBack,
|
||||
routeSessionId: after?.routeSessionId ?? null,
|
||||
activeSessionId: after?.activeSessionId ?? null,
|
||||
routeOk,
|
||||
activeOk,
|
||||
composerReady: after?.composerReady === true,
|
||||
pageId,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertSessionInvariant(command) {
|
||||
const beforeUrl = currentPageUrl();
|
||||
const snapshot = await workbenchSessionSnapshot();
|
||||
const canarySessionId = String(command.sessionId || command.canarySessionId || snapshot?.routeSessionId || snapshot?.activeSessionId || "").trim();
|
||||
if (!canarySessionId) throw new Error("assertSessionInvariant requires a current canary Workbench session");
|
||||
const routeOk = snapshot?.routeSessionId === canarySessionId;
|
||||
const activeOk = snapshot?.activeSessionId === canarySessionId;
|
||||
const composerReady = snapshot?.composerReady === true;
|
||||
if (!routeOk || !activeOk) {
|
||||
const error = new Error("assertSessionInvariant saw route/active session mismatch");
|
||||
error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, pageId, valuesRedacted: true };
|
||||
throw error;
|
||||
}
|
||||
if (command.requireComposerReady === true && !composerReady) {
|
||||
const error = new Error("assertSessionInvariant requires composer ready for the next round");
|
||||
error.details = { canarySessionId, routeSessionId: snapshot?.routeSessionId ?? null, activeSessionId: snapshot?.activeSessionId ?? null, warning: snapshot?.warning ?? null, pageId, valuesRedacted: true };
|
||||
throw error;
|
||||
}
|
||||
const messageOrder = await visibleMessageOrderSummary();
|
||||
await samplePage("assert-session-invariant", { refreshObserver: false, screenshot: false }).catch((error) => appendJsonl(files.errors, eventRecord("assert-session-invariant-sample-error", { error: errorSummary(error), pageId, valuesRedacted: true })));
|
||||
return {
|
||||
beforeUrl,
|
||||
afterUrl: currentPageUrl(),
|
||||
type: "assertSessionInvariant",
|
||||
afterRound: integerOrNull(command.afterRound),
|
||||
canarySessionId,
|
||||
routeSessionId: snapshot?.routeSessionId ?? null,
|
||||
activeSessionId: snapshot?.activeSessionId ?? null,
|
||||
routeOk,
|
||||
activeOk,
|
||||
composerReady,
|
||||
expectedSentinelRange: command.expectedSentinelRange || null,
|
||||
findingId: command.findingId || "workbench-message-order-user-clustered-after-navigation",
|
||||
severity: command.severity || "amber",
|
||||
blocking: command.blocking === true,
|
||||
messageOrder,
|
||||
sampleSeq,
|
||||
pageRole: "control",
|
||||
pageId,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function visibleWorkbenchSessionIds() {
|
||||
return page.evaluate(() => {
|
||||
const visible = (element) => {
|
||||
if (!element) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||||
};
|
||||
const sessionIdForElement = (element) => {
|
||||
const direct = element.getAttribute("data-session-id");
|
||||
if (direct) return direct;
|
||||
const href = element.getAttribute("href") || element.closest("a[href]")?.getAttribute("href") || "";
|
||||
const match = String(href || "").match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
|
||||
return match ? decodeURIComponent(match[1] || "") : null;
|
||||
};
|
||||
const ids = [];
|
||||
for (const element of Array.from(document.querySelectorAll(".session-tab[data-session-id], [role='tab'][data-session-id], [data-testid*='session' i][data-session-id], a[href*='/workbench/sessions/'], a[href*='/workspace/sessions/']"))) {
|
||||
if (!visible(element)) continue;
|
||||
const id = sessionIdForElement(element);
|
||||
if (id && !ids.includes(id)) ids.push(id);
|
||||
}
|
||||
return ids.slice(0, 50);
|
||||
}).catch(() => []);
|
||||
}
|
||||
|
||||
async function visibleMessageOrderSummary() {
|
||||
const rawMessages = await page.evaluate(() => {
|
||||
const trim = (value, limit = 1600) => String(value || "").replace(/\s+/gu, " ").trim().slice(0, limit);
|
||||
const visible = (element) => {
|
||||
if (!element) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
||||
};
|
||||
const stableMessageText = (element) => {
|
||||
const selectors = [".message-markdown.message-text", ".message-text", "[data-message-body]", "[data-testid='message-body']", "[data-testid*='message-text' i]", "[data-testid*='final-response' i]"];
|
||||
const parts = [];
|
||||
for (const selector of selectors) {
|
||||
for (const candidate of Array.from(element.querySelectorAll(selector))) {
|
||||
if (!visible(candidate)) continue;
|
||||
const text = trim(candidate.textContent || "", 1200);
|
||||
if (text && !parts.includes(text)) parts.push(text);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" ") : trim(element.textContent || "", 1200);
|
||||
};
|
||||
return Array.from(document.querySelectorAll('article.message-card, .message-card[data-message-id], article[data-message-id]')).filter(visible).slice(-80).map((element, index) => ({
|
||||
index,
|
||||
dataRole: element.getAttribute("data-role") || null,
|
||||
role: element.getAttribute("role") || null,
|
||||
testId: element.getAttribute("data-testid") || null,
|
||||
status: element.getAttribute("data-status") || element.getAttribute("aria-busy") || null,
|
||||
sessionId: element.getAttribute("data-session-id") || null,
|
||||
messageId: element.getAttribute("data-message-id") || element.getAttribute("id") || null,
|
||||
traceId: element.getAttribute("data-trace-id") || null,
|
||||
turnId: element.getAttribute("data-turn-id") || null,
|
||||
text: stableMessageText(element),
|
||||
}));
|
||||
}).catch(() => []);
|
||||
const entries = Array.isArray(rawMessages) ? rawMessages.map((item, index) => {
|
||||
const text = String(item?.text || "");
|
||||
const kind = messageKindForOrder(item, text);
|
||||
const markers = sentinelMarkers(text);
|
||||
return {
|
||||
index: Number.isFinite(Number(item?.index)) ? Number(item.index) : index,
|
||||
kind,
|
||||
role: item?.dataRole || item?.role || null,
|
||||
status: item?.status || null,
|
||||
sessionId: item?.sessionId || null,
|
||||
messageId: item?.messageId || null,
|
||||
traceId: item?.traceId || null,
|
||||
turnId: item?.turnId || null,
|
||||
markers,
|
||||
textHash: sha256Text(text),
|
||||
textBytes: Buffer.byteLength(text),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}) : [];
|
||||
const clusters = userClusters(entries);
|
||||
const maxCluster = clusters.slice().sort((a, b) => b.consecutiveUserMessageCount - a.consecutiveUserMessageCount)[0] || null;
|
||||
return {
|
||||
messageCount: entries.length,
|
||||
sequence: entries.map((item) => ({
|
||||
index: item.index,
|
||||
kind: item.kind,
|
||||
marker: item.markers[0] || null,
|
||||
markerCount: item.markers.length,
|
||||
traceId: item.traceId,
|
||||
status: item.status,
|
||||
textHash: item.textHash,
|
||||
textBytes: item.textBytes,
|
||||
valuesRedacted: true,
|
||||
})).slice(-40),
|
||||
userClustered: clusters.length > 0,
|
||||
consecutiveUserMessageCount: maxCluster?.consecutiveUserMessageCount ?? 0,
|
||||
sentinelRange: maxCluster?.sentinelRange ?? null,
|
||||
traceIds: uniqueStrings(entries.map((item) => item.traceId)).slice(0, 12),
|
||||
clusters,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function messageKindForOrder(item, text) {
|
||||
const signal = [item?.dataRole, item?.role, item?.testId].map((value) => String(value || "").toLowerCase()).join(" ");
|
||||
if (/\b(user|human)\b/u.test(signal)) return "user";
|
||||
if (/\b(assistant|agent|code-agent|system)\b/u.test(signal)) return "agent";
|
||||
const body = String(text || "").trim();
|
||||
if (/^Run\s+/iu.test(body) && /\bsentinel-(?:0[1-9]|10)\b/u.test(body)) return "user";
|
||||
if (/\b(?:final response|assistant|code agent)\b/iu.test(body)) return "agent";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function sentinelMarkers(text) {
|
||||
return uniqueStrings(Array.from(String(text || "").matchAll(/\bsentinel-(?:0[1-9]|10)\b/giu)).map((match) => match[0].toLowerCase()));
|
||||
}
|
||||
|
||||
function userClusters(entries) {
|
||||
const clusters = [];
|
||||
let current = [];
|
||||
const flush = () => {
|
||||
if (current.length >= 2) {
|
||||
const markers = current.flatMap((item) => item.markers);
|
||||
clusters.push({
|
||||
startIndex: current[0].index,
|
||||
endIndex: current[current.length - 1].index,
|
||||
consecutiveUserMessageCount: current.length,
|
||||
sentinelRange: sentinelRange(markers),
|
||||
markers: uniqueStrings(markers).slice(0, 12),
|
||||
messageTextHashes: current.map((item) => item.textHash).slice(0, 12),
|
||||
traceIds: uniqueStrings(current.map((item) => item.traceId)).slice(0, 12),
|
||||
valuesRedacted: true,
|
||||
});
|
||||
}
|
||||
current = [];
|
||||
};
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === "user") {
|
||||
current.push(entry);
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return clusters.slice(0, 20);
|
||||
}
|
||||
|
||||
function sentinelRange(markers) {
|
||||
const unique = uniqueStrings(markers).sort((a, b) => sentinelMarkerNumber(a) - sentinelMarkerNumber(b));
|
||||
if (unique.length === 0) return null;
|
||||
return unique.length === 1 ? unique[0] : unique[0] + ".." + unique[unique.length - 1];
|
||||
}
|
||||
|
||||
function sentinelMarkerNumber(marker) {
|
||||
const match = String(marker || "").match(/sentinel-(\d+)/u);
|
||||
return match ? Number(match[1]) : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function integerOrNull(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function ensureProjectManagementCommand(type) {
|
||||
if (projectManagement.enabled !== true) throw new Error(type + " requires config/hwlab-node-lanes.yaml webProbe.projectManagement.enabled=true for the selected node/lane");
|
||||
if (!projectManagement.commandAllowlist.includes(type)) throw new Error(type + " is not in webProbe.projectManagement.commandAllowlist for the selected node/lane");
|
||||
@@ -2810,6 +3102,13 @@ function commandInputSummary(command) {
|
||||
url: command.url ? safeUrl(command.url) : null,
|
||||
sessionId: command.sessionId || command.value || null,
|
||||
provider: command.provider || null,
|
||||
afterRound: Number.isInteger(Number(command.afterRound)) ? Number(command.afterRound) : null,
|
||||
severity: command.severity || null,
|
||||
alternateSessionStrategy: command.alternateSessionStrategy || null,
|
||||
expectedSentinelRange: command.expectedSentinelRange || null,
|
||||
requireComposerReady: command.requireComposerReady === true,
|
||||
findingId: command.findingId || null,
|
||||
blocking: command.blocking === true ? true : command.blocking === false ? false : null,
|
||||
sourceId: opaque(command.sourceId),
|
||||
fileRef: opaque(command.fileRef),
|
||||
taskRef: opaque(command.taskRef),
|
||||
|
||||
Reference in New Issue
Block a user