fix(web-probe): alert session title fallback majority (#705)

Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
Lyon
2026-06-23 00:23:39 +08:00
committed by GitHub
parent 1f31dbcbe1
commit eb5e79795c
3 changed files with 301 additions and 4 deletions
+2
View File
@@ -205,6 +205,8 @@ UniDesk 主前端交付门禁仍由 `bun scripts/cli.ts e2e run` 承担;本技
UniDesk/HWLAB Web 工作不再把裸 Playwright 当作默认操作面。需要截图、DOM 断言、API matrix、route/intercept、SSE 降级、长程 session 采样、性能页面复测或 Workbench 多轮任务时,统一选择 `hwlab nodes web-probe run|script|observe`
`web-probe observe analyze` 必须把 Workbench session 列表标题纳入默认采样与报告:可见列表中 `Session ses_...` fallback 标题超过一半时输出红灯 finding。修复方向必须让上游 session list projection 直接携带名称;点击详情后的下游补名、reload repair 或多来源仲裁不能作为修复。
`trans <route> playwright``playwright-cli` 和临时 Node Playwright 脚本只允许作为非 HWLAB 外站短生命周期截图/PDF、或 web-probe 尚未覆盖且一次性不可复用的诊断例外。例外使用前必须写明为什么 `web-probe script``web-probe observe` 不适用;同类动作出现第二次就应补进 web-probe CLI 或 repo-owned probe。
需要把截图回传到本机时,优先用 `web-probe script``screenshot(name)`,或长程观测中使用 `observe command --type screenshot --label <label>`,并在 issue 中引用截图 SHA、report SHA、observer id 和 stateDir。不要把本地 Playwright artifact 当作 HWLAB node/lane 原入口验收替代品。
+41 -1
View File
@@ -8100,7 +8100,14 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
" loadingLongestContinuousSeconds: compact.sampleMetrics.loadingLongestContinuousSeconds ?? null,",
" loadingCurrentContinuousSeconds: compact.sampleMetrics.loadingCurrentContinuousSeconds ?? null,",
" loadingOverFiveSecondSegmentCount: compact.sampleMetrics.loadingOverFiveSecondSegmentCount ?? null,",
" sessionRailSampleCount: compact.sampleMetrics.sessionRailSampleCount ?? null,",
" sessionRailVisibleSampleCount: compact.sampleMetrics.sessionRailVisibleSampleCount ?? null,",
" sessionRailFallbackMajoritySampleCount: compact.sampleMetrics.sessionRailFallbackMajoritySampleCount ?? null,",
" sessionRailFallbackMaxRatio: compact.sampleMetrics.sessionRailFallbackMaxRatio ?? null,",
" sessionRailFallbackMaxVisibleCount: compact.sampleMetrics.sessionRailFallbackMaxVisibleCount ?? null,",
" sessionRailFallbackMaxCount: compact.sampleMetrics.sessionRailFallbackMaxCount ?? null,",
" loading: compact.sampleMetrics.loading ?? null,",
" sessionRailTitles: compact.sampleMetrics.sessionRailTitles ? { summary: compact.sampleMetrics.sessionRailTitles.summary ?? null, samples: Array.isArray(compact.sampleMetrics.sessionRailTitles.samples) ? compact.sampleMetrics.sessionRailTitles.samples.slice(0, 4) : [], examples: Array.isArray(compact.sampleMetrics.sessionRailTitles.examples) ? compact.sampleMetrics.sessionRailTitles.examples.slice(0, 4) : [] } : null,",
" turnTimingRows: compact.sampleMetrics.turnTimingRows ?? null,",
" turnTimingNonMonotonicCount: compact.sampleMetrics.turnTimingNonMonotonicCount ?? null,",
" turnTimingTotalElapsedDecreaseCount: compact.sampleMetrics.turnTimingTotalElapsedDecreaseCount ?? null,",
@@ -8151,6 +8158,10 @@ function runNodeWebProbeObserveAnalyze(options: NodeWebProbeObserveOptions, spec
" loadingCurrentContinuousSeconds: compact.sampleMetrics.loadingCurrentContinuousSeconds ?? null,",
" loadingOverFiveSecondSegmentCount: compact.sampleMetrics.loadingOverFiveSecondSegmentCount ?? null,",
" loading: compact.sampleMetrics.loading ? { summary: compact.sampleMetrics.loading.summary ?? null, longestSegments: Array.isArray(compact.sampleMetrics.loading.longestSegments) ? compact.sampleMetrics.loading.longestSegments.slice(0, 4) : [], owners: Array.isArray(compact.sampleMetrics.loading.owners) ? compact.sampleMetrics.loading.owners.slice(0, 4) : [] } : null,",
" sessionRailFallbackMajoritySampleCount: compact.sampleMetrics.sessionRailFallbackMajoritySampleCount ?? null,",
" sessionRailFallbackMaxRatio: compact.sampleMetrics.sessionRailFallbackMaxRatio ?? null,",
" sessionRailFallbackMaxCount: compact.sampleMetrics.sessionRailFallbackMaxCount ?? null,",
" sessionRailTitles: compact.sampleMetrics.sessionRailTitles ? { summary: compact.sampleMetrics.sessionRailTitles.summary ?? null, samples: Array.isArray(compact.sampleMetrics.sessionRailTitles.samples) ? compact.sampleMetrics.sessionRailTitles.samples.slice(0, 4) : [], examples: Array.isArray(compact.sampleMetrics.sessionRailTitles.examples) ? compact.sampleMetrics.sessionRailTitles.examples.slice(0, 4) : [] } : null,",
" turnTimingRows: compact.sampleMetrics.turnTimingRows ?? null,",
" turnTimingNonMonotonicCount: compact.sampleMetrics.turnTimingNonMonotonicCount ?? null,",
" turnTimingTotalElapsedDecreaseCount: compact.sampleMetrics.turnTimingTotalElapsedDecreaseCount ?? null,",
@@ -8224,6 +8235,10 @@ function renderWebObserveAnalyzeTable(value: Record<string, unknown>): string {
const loadingSummary = record(loading?.summary);
const loadingSegments = Array.isArray(loading?.longestSegments) ? loading.longestSegments.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 8) : [];
const loadingOwners = Array.isArray(loading?.owners) ? loading.owners.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 8) : [];
const sessionRailTitles = record(sampleMetrics?.sessionRailTitles);
const sessionRailTitleSummary = record(sessionRailTitles?.summary);
const sessionRailTitleSamples = Array.isArray(sessionRailTitles?.samples) ? sessionRailTitles.samples.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 8) : [];
const sessionRailTitleExamples = Array.isArray(sessionRailTitles?.examples) ? sessionRailTitles.examples.map(record).filter((item): item is Record<string, unknown> => item !== null).slice(0, 8) : [];
const rounds = webObserveArray(sampleMetrics?.roundItems ?? sampleMetrics?.rounds).map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-8);
const turnColumns = webObserveArray(sampleMetrics?.turnColumns).map(record).filter((item): item is Record<string, unknown> => item !== null).slice(-12);
const roundCount = Array.isArray(sampleMetrics?.rounds) ? sampleMetrics.rounds.length : sampleMetrics?.rounds;
@@ -8428,6 +8443,31 @@ function renderWebObserveAnalyzeTable(value: Record<string, unknown>): string {
webObserveText(loadingSummary?.overFiveSecondSegmentCount ?? sampleMetrics?.loadingOverFiveSecondSegmentCount),
]]),
"",
"Session rail titles:",
webObserveTable(["SAMPLES", "VISIBLE", "MAJORITY", "MAX_RATIO", "MAX_VISIBLE", "MAX_FALLBACK"], [[
webObserveText(sessionRailTitleSummary?.sampleCount ?? sampleMetrics?.sessionRailSampleCount),
webObserveText(sessionRailTitleSummary?.visibleSampleCount ?? sampleMetrics?.sessionRailVisibleSampleCount),
webObserveText(sessionRailTitleSummary?.majorityFallbackSampleCount ?? sampleMetrics?.sessionRailFallbackMajoritySampleCount),
webObserveText(sessionRailTitleSummary?.maxFallbackRatio ?? sampleMetrics?.sessionRailFallbackMaxRatio),
webObserveText(sessionRailTitleSummary?.maxVisibleCount ?? sampleMetrics?.sessionRailFallbackMaxVisibleCount),
webObserveText(sessionRailTitleSummary?.maxFallbackTitleCount ?? sampleMetrics?.sessionRailFallbackMaxCount),
]]),
sessionRailTitleSamples.length === 0 ? "Session rail fallback samples:\n-" : webObserveTable(["SEQ", "ROLE", "VISIBLE", "FALLBACK", "RATIO", "EXAMPLE"], sessionRailTitleSamples.map((item) => [
webObserveText(item.seq),
webObserveText(item.pageRole),
webObserveText(item.visibleCount),
webObserveText(item.fallbackTitleCount),
webObserveText(item.fallbackTitleRatio),
webObserveShort(webObserveText((Array.isArray(item.examples) ? record(item.examples[0])?.titlePreview : null) ?? ""), 48),
])),
sessionRailTitleExamples.length === 0 ? "Session rail fallback examples:\n-" : webObserveTable(["SEQ", "ROLE", "ACTIVE", "SESSION", "TITLE"], sessionRailTitleExamples.map((item) => [
webObserveText(item.firstSeq),
webObserveText(item.pageRole),
webObserveText(item.active),
webObserveText(item.sessionIdPrefix),
webObserveShort(webObserveText(item.titlePreview ?? item.titleHash), 64),
])),
"",
"Loading owners:",
webObserveTable(["SAMPLES", "OCCUR", "MAX", "LONGEST_S", "KIND", "OWNER"], loadingOwners.length > 0 ? loadingOwners.map((item) => [
webObserveText(item.sampleCount),
@@ -9237,7 +9277,7 @@ const compactHeartbeat=(item)=>item?{ok:item.ok,jobId:item.jobId,pid:item.pid,st
const retryLabel=(detail)=>detail&&detail.auth?detail.auth.lastRetryLabel||'':detail&&detail.result?detail.result.lastRetryLabel||'':detail&&detail.error&&detail.error.auth?detail.error.auth.lastRetryLabel||'':'';
const detailText=(detail)=>detail&&detail.error?short((detail.error.message||'')+(detail.error.auth&&detail.error.auth.lastError?' '+detail.error.auth.lastError:'')):detail&&detail.result?short([detail.result.statusText,detail.result.retryExhausted?'retry-exhausted':''].filter(Boolean).join(' ')):'';
const compactControl=(item)=>({ts:item.ts,seq:item.seq,phase:item.phase,type:item.type,commandId:item.commandId,durationMs:item.detail&&item.detail.durationMs||null,retry:retryLabel(item.detail),detail:detailText(item.detail)});
const compactSample=(item)=>({seq:item.seq,ts:item.ts,path:item.path,routeSessionId:item.routeSessionId||null,activeSessionId:item.activeSessionId||null,messageCount:Array.isArray(item.messages)?item.messages.length:0,traceRowCount:Array.isArray(item.traceRows)?item.traceRows.length:0,loadingCount:Array.isArray(item.loadings)?item.loadings.length:0,loadingOwners:Array.isArray(item.loadings)?Array.from(new Set(item.loadings.map((loading)=>loading.ownerLabel||loading.ownerKind||loading.ownerKey||'loading'))).slice(0,4):[]});
const compactSample=(item)=>({seq:item.seq,ts:item.ts,path:item.path,routeSessionId:item.routeSessionId||null,activeSessionId:item.activeSessionId||null,messageCount:Array.isArray(item.messages)?item.messages.length:0,traceRowCount:Array.isArray(item.traceRows)?item.traceRows.length:0,loadingCount:Array.isArray(item.loadings)?item.loadings.length:0,loadingOwners:Array.isArray(item.loadings)?Array.from(new Set(item.loadings.map((loading)=>loading.ownerLabel||loading.ownerKind||loading.ownerKey||'loading'))).slice(0,4):[],sessionRail:item.sessionRail?{visibleCount:item.sessionRail.visibleCount??null,fallbackTitleCount:item.sessionRail.fallbackTitleCount??null,fallbackTitleRatio:item.sessionRail.fallbackTitleRatio??null,fallbackItems:Array.isArray(item.sessionRail.fallbackItems)?item.sessionRail.fallbackItems.slice(0,4).map((fallback)=>({sessionIdPrefix:fallback.sessionIdPrefix??null,titlePreview:fallback.titlePreview??null,active:fallback.active===true})):[]}:null});
const compactNetwork=(item)=>({ts:item.ts,type:item.type,method:item.method,url:short(item.url),status:item.status||null,failure:item.failure?short(item.failure):null});
const pidText=fs.existsSync(path.join(dir,'pid'))?fs.readFileSync(path.join(dir,'pid'),'utf8').trim():null;
let alive=false; if(pidText){try{process.kill(Number(pidText),0); alive=true}catch{}}
@@ -1338,6 +1338,78 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
};
});
};
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") || "";
if (!href) return null;
try {
const parsed = new URL(href, location.href);
const match = parsed.pathname.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
return match ? decodeURIComponent(match[1] || "") : null;
} catch {
const match = href.match(/\/(?:workbench|workspace)\/sessions\/([^/?#]+)/u);
return match ? decodeURIComponent(match[1] || "") : null;
}
};
const sessionTitleTextForElement = (element) => {
const titleNode = element.querySelector("[data-testid*='session-title' i], [data-testid*='session-name' i], [class*='session-title' i], [class*='session-name' i], [data-testid*='title' i], [class*='title' i]");
return trim(
(titleNode ? titleNode.textContent || "" : "")
|| element.getAttribute("aria-label")
|| element.getAttribute("title")
|| element.textContent
|| "",
240
);
};
const sessionTitleFallbackPattern = /^(?:Session\s+)?ses_[A-Za-z0-9_.-]+/iu;
const looksLikeSessionTitleFallback = (title, sessionId) => {
const text = trim(title, 240);
const id = String(sessionId || "").trim();
if (!text) return true;
if (sessionTitleFallbackPattern.test(text)) return true;
if (!id) return false;
return text === id || text.startsWith(id) || text.startsWith("Session " + id);
};
const collectSessionRailTitles = () => {
const candidates = 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/']"))
.filter(visible);
const seen = new Set();
const items = [];
for (const element of candidates) {
const sessionId = sessionIdForElement(element);
const titleText = sessionTitleTextForElement(element);
const key = (sessionId || "") + "|" + titleText;
if (seen.has(key)) continue;
seen.add(key);
const rect = element.getBoundingClientRect();
const fallbackTitle = looksLikeSessionTitleFallback(titleText, sessionId);
items.push({
index: items.length,
tag: element.tagName.toLowerCase(),
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true",
sessionId,
sessionIdPrefix: sessionId ? String(sessionId).slice(0, 12) : null,
titleText,
fallbackTitle,
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
});
}
const visibleItems = items.slice(0, 120);
const fallbackItems = visibleItems.filter((item) => item.fallbackTitle);
const visibleCount = visibleItems.length;
const fallbackTitleCount = fallbackItems.length;
return {
visibleCount,
fallbackTitleCount,
fallbackTitleRatio: visibleCount > 0 ? Number((fallbackTitleCount / visibleCount).toFixed(4)) : 0,
items: visibleItems.slice(0, 60),
fallbackItems: fallbackItems.slice(0, 12),
};
};
const diagnosticSummaryText = (element) => {
const summarySelectors = [
".api-error-diagnostic-summary-text p",
@@ -1424,6 +1496,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
const messages = summarize(messageSelector, 20);
const traceRows = summarize(traceSelector, 30);
const loadings = collectLoadingNodes();
const sessionRail = collectSessionRailTitles();
const diagnostics = Array.from(document.querySelectorAll(diagnosticSelector)).filter(visible).slice(-40).map((element, index) => {
const rect = element.getBoundingClientRect();
const text = diagnosticSummaryText(element);
@@ -1488,6 +1561,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
messages,
traceRows,
loadings,
sessionRail,
diagnostics,
turns,
pageProvenance: {
@@ -1543,11 +1617,44 @@ function digestDom(dom, pageRole = "control") {
const messages = Array.isArray(dom.messages) ? dom.messages.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : [];
const traceRows = Array.isArray(dom.traceRows) ? dom.traceRows.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || "") })) : [];
const loadings = Array.isArray(dom.loadings) ? dom.loadings.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), ownerTextHash: sha256Text(item.ownerText || ""), ownerTextPreview: truncate(item.ownerText || "", 160) })) : [];
const sessionRail = digestSessionRail(dom.sessionRail);
const diagnostics = Array.isArray(dom.diagnostics) ? dom.diagnostics.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 260), textBytes: Buffer.byteLength(item.text || "") })) : [];
const turns = Array.isArray(dom.turns) ? dom.turns.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 200), textBytes: Buffer.byteLength(item.text || "") })) : [];
const pageProvenance = normalizePageProvenance(dom.pageProvenance, { reason: "sample", pageLoadSeq: currentPageProvenance?.pageLoadSeq ?? pageLoadSeq });
if (pageRole === "control") currentPageProvenance = pageProvenance;
return { ...dom, messages, traceRows, loadings, diagnostics, turns, pageProvenance: compactPageProvenance(pageProvenance) };
return { ...dom, messages, traceRows, loadings, sessionRail, diagnostics, turns, pageProvenance: compactPageProvenance(pageProvenance) };
}
function digestSessionRail(value) {
if (!value || typeof value !== "object") return null;
const items = Array.isArray(value.items) ? value.items.map((item) => {
const titleText = String(item?.titleText || item?.titlePreview || "");
return {
index: item?.index ?? null,
tag: item?.tag ?? null,
testId: item?.testId ?? null,
role: item?.role ?? null,
active: item?.active === true,
sessionId: item?.sessionId ?? null,
sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null),
fallbackTitle: item?.fallbackTitle === true,
titleHash: sha256Text(titleText),
titlePreview: truncate(titleText, 160),
titleBytes: Buffer.byteLength(titleText),
rect: item?.rect ?? null,
};
}) : [];
const fallbackItems = items.filter((item) => item.fallbackTitle).slice(0, 12);
const visibleCount = Number(value.visibleCount ?? items.length);
const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length);
return {
visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length,
fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length,
fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0),
items,
fallbackItems,
valuesRedacted: true,
};
}
async function captureScreenshot(reason, imageType = "png") {
@@ -1839,6 +1946,7 @@ console.log(JSON.stringify({
rounds: recentWindow.sampleMetrics.rounds.slice(-8).map((item) => ({ promptIndex: item.promptIndex, promptTextHash: item.promptTextHash, sampleCount: item.sampleCount, firstSeq: item.firstSeq, lastSeq: item.lastSeq, lastTotalElapsedSeconds: item.lastTotalElapsedSeconds, lastRecentUpdateSeconds: item.lastRecentUpdateSeconds, loadingSamples: item.loadingSamples, maxLoadingCount: item.maxLoadingCount, loadingOwnerCount: item.loadingOwnerCount, diagnosticSamples: item.diagnosticSamples, terminalSamples: item.terminalSamples, finalTextSamples: item.finalTextSamples, turnTimingTotalElapsedZeroResetCount: item.turnTimingTotalElapsedZeroResetCount, turnTimingTotalElapsedForwardJumpCount: item.turnTimingTotalElapsedForwardJumpCount, turnTimingTotalElapsedForwardJumpMaxSeconds: item.turnTimingTotalElapsedForwardJumpMaxSeconds, turnTimingRecentUpdateJumpCount: item.turnTimingRecentUpdateJumpCount, turnTimingRecentUpdateMaxIncreaseSeconds: item.turnTimingRecentUpdateMaxIncreaseSeconds })),
turnColumns: recentWindow.sampleMetrics.turnColumns.slice(-12).map((item) => ({ label: item.label, source: item.source, promptIndex: item.promptIndex, lastPromptIndex: item.lastPromptIndex, firstSeq: item.firstSeq, lastSeq: item.lastSeq, traceId: item.traceId, messageId: item.messageId })),
loading: compactLoadingMetricsForOutput(recentWindow.sampleMetrics.loading),
sessionRailTitles: compactSessionRailTitleMetricsForOutput(recentWindow.sampleMetrics.sessionRailTitles),
},
pageProvenance: recentWindow.pageProvenance.summary,
pagePerformance: recentWindow.pagePerformance.summary,
@@ -2011,6 +2119,7 @@ function compactSampleForAnalysis(sample) {
messages: compactDomItems(sample.messages),
traceRows: compactDomItems(sample.traceRows),
loadings: compactLoadingItems(sample.loadings),
sessionRail: compactSessionRail(sample.sessionRail),
turns: compactDomItems(sample.turns),
diagnostics: compactDomItems(sample.diagnostics),
pageProvenance: compactSamplePageProvenance(sample.pageProvenance),
@@ -2018,6 +2127,41 @@ function compactSampleForAnalysis(sample) {
};
}
function compactSessionRail(value) {
if (!value || typeof value !== "object") return null;
const items = Array.isArray(value.items) ? value.items.slice(0, 80).map((item) => ({
index: item?.index ?? null,
tag: item?.tag ?? null,
testId: item?.testId ?? null,
role: item?.role ?? null,
active: item?.active === true,
sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null),
fallbackTitle: item?.fallbackTitle === true,
titlePreview: limitText(String(item?.titlePreview || item?.titleText || ""), 180),
titleHash: item?.titleHash ?? sha256(String(item?.titlePreview || item?.titleText || "")),
titleBytes: item?.titleBytes ?? null,
})) : [];
const fallbackItems = Array.isArray(value.fallbackItems)
? value.fallbackItems.slice(0, 20).map((item) => ({
index: item?.index ?? null,
active: item?.active === true,
sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null),
titlePreview: limitText(String(item?.titlePreview || item?.titleText || ""), 180),
titleHash: item?.titleHash ?? sha256(String(item?.titlePreview || item?.titleText || "")),
}))
: items.filter((item) => item.fallbackTitle).slice(0, 20);
const visibleCount = Number(value.visibleCount ?? items.length);
const fallbackTitleCount = Number(value.fallbackTitleCount ?? fallbackItems.length);
return {
visibleCount: Number.isFinite(visibleCount) ? visibleCount : items.length,
fallbackTitleCount: Number.isFinite(fallbackTitleCount) ? fallbackTitleCount : fallbackItems.length,
fallbackTitleRatio: Number.isFinite(Number(value.fallbackTitleRatio)) ? Number(value.fallbackTitleRatio) : (items.length > 0 ? Number((fallbackItems.length / items.length).toFixed(4)) : 0),
items,
fallbackItems,
valuesRedacted: true
};
}
function compactLoadingItems(items) {
if (!Array.isArray(items)) return [];
return items.map((item) => {
@@ -2122,6 +2266,17 @@ function compactLoadingMetricsForOutput(value) {
};
}
function compactSessionRailTitleMetricsForOutput(value) {
if (!value || typeof value !== "object") return null;
return {
summary: value.summary ?? null,
samples: Array.isArray(value.samples) ? value.samples.slice(0, 12) : [],
examples: Array.isArray(value.examples) ? value.examples.slice(0, 12) : [],
timeline: Array.isArray(value.timeline) ? value.timeline.slice(-12) : [],
valuesRedacted: true
};
}
function compactSamplePageProvenance(value) {
if (!value || typeof value !== "object") return null;
return {
@@ -2189,6 +2344,8 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN
const loadingSummary = sampleMetrics?.loading?.summary || {};
if (Number(loadingSummary.longestContinuousSeconds ?? 0) > 5) findings.push({ id: "page-loading-visible-over-5s", severity: "red", summary: "visible 加载中 stayed on screen longer than 5s; fix real loading latency instead of revealing incomplete content early", count: loadingSummary.overFiveSecondSegmentCount ?? 1, longestContinuousSeconds: loadingSummary.longestContinuousSeconds, segments: sampleMetrics.loading.segments.slice(0, 20), owners: sampleMetrics.loading.owners.slice(0, 20) });
if (Number(loadingSummary.maxSimultaneousCount ?? 0) > 1) findings.push({ id: "page-loading-concurrent", severity: "info", summary: "multiple 加载中 indicators were visible in the same sampled DOM point", count: loadingSummary.concurrentLoadingSampleCount ?? 0, maxSimultaneousCount: loadingSummary.maxSimultaneousCount, owners: sampleMetrics.loading.owners.slice(0, 20) });
const sessionRailTitleSummary = sampleMetrics?.sessionRailTitles?.summary || {};
if (Number(sessionRailTitleSummary.majorityFallbackSampleCount ?? 0) > 0) findings.push({ id: "session-rail-title-fallback-majority", severity: "red", summary: "more than half of visible session list rows used fallback Session ses_... titles", count: sessionRailTitleSummary.majorityFallbackSampleCount, maxFallbackRatio: sessionRailTitleSummary.maxFallbackRatio, maxFallbackTitleCount: sessionRailTitleSummary.maxFallbackTitleCount, samples: sampleMetrics.sessionRailTitles.samples.slice(0, 20), examples: sampleMetrics.sessionRailTitles.examples.slice(0, 20) });
if ((runtimeAlerts?.summary?.httpErrorCount ?? 0) > 0) findings.push({ id: "runtime-http-errors", severity: "amber", summary: "natural page requests returned HTTP error status during observation", count: runtimeAlerts.summary.httpErrorCount, groups: runtimeAlerts.networkHttpErrorsByPath.slice(0, 12) });
if ((runtimeAlerts?.summary?.requestFailedCount ?? 0) > 0) findings.push({ id: "runtime-requestfailed", severity: "amber", summary: "browser requestfailed events were captured during observation", count: runtimeAlerts.summary.requestFailedCount, groups: runtimeAlerts.networkRequestFailedByPath.slice(0, 12) });
if ((runtimeAlerts?.summary?.domDiagnosticSampleCount ?? 0) > 0) findings.push({ id: "runtime-dom-diagnostics", severity: "amber", summary: "diagnostic/error/warning-like text was visible in sampled DOM", count: runtimeAlerts.summary.domDiagnosticSampleCount, groupCount: runtimeAlerts.summary.domDiagnosticGroupCount ?? 0, groups: runtimeAlerts.domDiagnosticsByText.slice(0, 12), samples: runtimeAlerts.domDiagnostics.slice(0, 12) });
@@ -3171,6 +3328,7 @@ function prioritizeFindings(findings) {
const kindRank = (item) => {
const id = String(item?.id ?? item?.kind ?? item?.code ?? "");
if (id === "page-performance-slow-same-origin-api") return 0;
if (id === "session-rail-title-fallback-majority") return 0.5;
if (id.startsWith("turn-timing-total-elapsed")) return 1;
if (id.startsWith("turn-timing-recent-update")) return 2;
if (id.includes("runtime-execution") || id.includes("prompt-chat-submit-failed")) return 3;
@@ -3219,8 +3377,11 @@ function buildSampleMetrics(samples, control) {
messageCount: Array.isArray(sample.messages) ? sample.messages.length : 0,
traceRowCount: Array.isArray(sample.traceRows) ? sample.traceRows.length : 0,
loadingCount: loadings.length,
loadingOwnerCount: loadingOwners.length,
loadingOwners: loadingOwners.map((item) => ({ ownerKey: item.ownerKey, ownerKind: item.ownerKind, ownerLabel: item.ownerLabel, count: item.count })).slice(0, 12),
loadingOwnerCount: loadingOwners.length,
loadingOwners: loadingOwners.map((item) => ({ ownerKey: item.ownerKey, ownerKind: item.ownerKind, ownerLabel: item.ownerLabel, count: item.count })).slice(0, 12),
sessionRailVisibleCount: Number(sample?.sessionRail?.visibleCount ?? 0),
sessionRailFallbackTitleCount: Number(sample?.sessionRail?.fallbackTitleCount ?? 0),
sessionRailFallbackTitleRatio: Number(sample?.sessionRail?.fallbackTitleRatio ?? 0),
totalElapsedSeconds: totalElapsedValues.length > 0 ? Math.max(...totalElapsedValues) : null,
recentUpdateSeconds: recentUpdateValues.length > 0 ? Math.max(...recentUpdateValues) : null,
terminalSeen: terminalTexts.length > 0,
@@ -3257,6 +3418,7 @@ function buildSampleMetrics(samples, control) {
const withRecent = timeline.filter((item) => item.recentUpdateSeconds !== null).length;
const diagnostics = timeline.filter((item) => item.diagnosticSeen).length;
const loading = buildLoadingMetrics(samples, timeline);
const sessionRailTitles = buildSessionRailTitleMetrics(samples, timeline);
const rounds = buildRoundMetricSummaries(timeline, promptCommands, {
nonMonotonic: turnTimingNonMonotonic,
elapsedZeroResets: turnTimingElapsedZeroResets,
@@ -3280,6 +3442,12 @@ function buildSampleMetrics(samples, control) {
loadingLongestContinuousSeconds: loading.summary.longestContinuousSeconds,
loadingCurrentContinuousSeconds: loading.summary.currentContinuousSeconds,
loadingOverFiveSecondSegmentCount: loading.summary.overFiveSecondSegmentCount,
sessionRailSampleCount: sessionRailTitles.summary.sampleCount,
sessionRailVisibleSampleCount: sessionRailTitles.summary.visibleSampleCount,
sessionRailFallbackMajoritySampleCount: sessionRailTitles.summary.majorityFallbackSampleCount,
sessionRailFallbackMaxRatio: sessionRailTitles.summary.maxFallbackRatio,
sessionRailFallbackMaxVisibleCount: sessionRailTitles.summary.maxVisibleCount,
sessionRailFallbackMaxCount: sessionRailTitles.summary.maxFallbackTitleCount,
promptSegments: Math.max(0, promptTimes.length),
rounds: rounds.length,
turnColumns: turnTiming.columns.length,
@@ -3306,6 +3474,7 @@ function buildSampleMetrics(samples, control) {
roundsWithRecentUpdateJumps: rounds.filter((item) => item.turnTimingRecentUpdateJumpCount > 0).length
},
loading,
sessionRailTitles,
rounds,
turnColumns: turnTiming.columns,
turnTimingTable: turnTiming.rows,
@@ -3321,6 +3490,70 @@ function buildSampleMetrics(samples, control) {
};
}
function buildSessionRailTitleMetrics(samples, timeline) {
const rows = [];
const examplesByHash = new Map();
for (let index = 0; index < (Array.isArray(samples) ? samples : []).length; index += 1) {
const sample = samples[index];
const rail = sample?.sessionRail && typeof sample.sessionRail === "object" ? sample.sessionRail : null;
if (!rail) continue;
const visibleCount = Number(rail.visibleCount ?? 0);
const fallbackTitleCount = Number(rail.fallbackTitleCount ?? 0);
const safeVisibleCount = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0;
const safeFallbackTitleCount = Number.isFinite(fallbackTitleCount) && fallbackTitleCount > 0 ? fallbackTitleCount : 0;
const fallbackTitleRatio = safeVisibleCount > 0 ? Number((safeFallbackTitleCount / safeVisibleCount).toFixed(4)) : 0;
const fallbackItems = Array.isArray(rail.fallbackItems) ? rail.fallbackItems : [];
for (const item of fallbackItems) {
const hash = String(item?.titleHash || item?.titlePreview || item?.sessionIdPrefix || "").trim();
if (!hash || examplesByHash.has(hash)) continue;
examplesByHash.set(hash, {
titleHash: item?.titleHash ?? null,
titlePreview: limitText(String(item?.titlePreview || ""), 160),
sessionIdPrefix: item?.sessionIdPrefix ?? null,
active: item?.active === true,
firstSeq: sample?.seq ?? null,
firstAt: sample?.ts ?? null,
pageRole: sample?.pageRole ?? null,
});
}
rows.push({
...ref(sample),
promptIndex: timeline[index]?.promptIndex ?? 0,
visibleCount: safeVisibleCount,
fallbackTitleCount: safeFallbackTitleCount,
fallbackTitleRatio,
majorityFallback: safeVisibleCount > 0 && safeFallbackTitleCount > safeVisibleCount / 2,
examples: fallbackItems.slice(0, 5).map((item) => ({
titleHash: item?.titleHash ?? null,
titlePreview: limitText(String(item?.titlePreview || ""), 160),
sessionIdPrefix: item?.sessionIdPrefix ?? null,
active: item?.active === true,
})),
});
}
const visibleRows = rows.filter((item) => item.visibleCount > 0);
const majorityRows = rows.filter((item) => item.majorityFallback);
const fallbackRows = rows.filter((item) => item.fallbackTitleCount > 0);
const maxFallbackRatio = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.fallbackTitleRatio) || 0)) : 0;
const maxVisibleCount = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.visibleCount) || 0)) : 0;
const maxFallbackTitleCount = rows.length > 0 ? Math.max(...rows.map((item) => Number(item.fallbackTitleCount) || 0)) : 0;
return {
summary: {
sampleCount: rows.length,
visibleSampleCount: visibleRows.length,
fallbackSampleCount: fallbackRows.length,
majorityFallbackSampleCount: majorityRows.length,
maxFallbackRatio,
maxVisibleCount,
maxFallbackTitleCount,
},
samples: majorityRows.slice(0, 80),
examples: Array.from(examplesByHash.values()).slice(0, 80),
timeline: rows.slice(-200),
valuesRedacted: true
};
}
function uniqueLoadingOwners(loadings) {
const groups = new Map();
for (let index = 0; index < (Array.isArray(loadings) ? loadings : []).length; index += 1) {
@@ -4392,6 +4625,8 @@ function renderMarkdown(report) {
const metricSummary = report.sampleMetrics?.summary || {};
const loading = report.sampleMetrics?.loading || {};
const loadingSummary = loading.summary || {};
const sessionRailTitles = report.sampleMetrics?.sessionRailTitles || {};
const sessionRailTitleSummary = sessionRailTitles.summary || {};
const alertSummary = report.runtimeAlerts?.summary || {};
const httpAlertLines = Array.isArray(report.runtimeAlerts?.networkHttpErrorsByPath) && report.runtimeAlerts.networkHttpErrorsByPath.length > 0
? report.runtimeAlerts.networkHttpErrorsByPath.slice(0, 40).map((item) => "- HTTP " + (item.status ?? "-") + " " + item.method + " " + item.urlPath + " count=" + item.count + " prompts=" + (item.promptIndexes?.join(",") || "-") + " first=" + (item.firstAt || "-") + " last=" + (item.lastAt || "-")).join("\n")
@@ -4423,6 +4658,12 @@ function renderMarkdown(report) {
const loadingTimelineLines = Array.isArray(loading.timeline) && loading.timeline.length > 0
? loading.timeline.slice(0, 160).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " prompt=" + (item.promptIndex ?? "-") + " loadingCount=" + (item.loadingCount ?? 0) + " ownerCount=" + (item.ownerCount ?? 0) + " owners=" + ((Array.isArray(item.owners) ? item.owners : []).slice(0, 6).map((owner) => (owner.ownerKind || "-") + ":" + (owner.ownerLabel || "-") + "x" + (owner.count ?? 0)).join(",") || "-")).join("\n")
: "- 未观察到“加载中”采样点。";
const sessionRailTitleSampleLines = Array.isArray(sessionRailTitles.samples) && sessionRailTitles.samples.length > 0
? sessionRailTitles.samples.slice(0, 80).map((item) => "- #" + (item.seq ?? "-") + " " + (item.ts || "-") + " role=" + (item.pageRole || "-") + " visible=" + (item.visibleCount ?? 0) + " fallback=" + (item.fallbackTitleCount ?? 0) + " ratio=" + (item.fallbackTitleRatio ?? 0) + " examples=" + ((Array.isArray(item.examples) ? item.examples : []).slice(0, 4).map((example) => escapeMarkdownCell(example.titlePreview || example.titleHash || "-")).join(",") || "-")).join("\n")
: "- 未观察到超过一半 fallback 的 session 列表采样点。";
const sessionRailTitleExampleLines = Array.isArray(sessionRailTitles.examples) && sessionRailTitles.examples.length > 0
? sessionRailTitles.examples.slice(0, 80).map((item) => "- firstSeq=" + (item.firstSeq ?? "-") + " role=" + (item.pageRole || "-") + " active=" + String(item.active === true) + " sessionPrefix=" + (item.sessionIdPrefix || "-") + " titleHash=" + (item.titleHash || "-") + " preview=" + escapeMarkdownCell(item.titlePreview || "")).join("\n")
: "- 无 fallback 标题示例。";
const provenanceLines = Array.isArray(report.pageProvenance?.segments) && report.pageProvenance.segments.length > 0
? report.pageProvenance.segments.slice(0, 40).map((item) => "- fingerprint=" + (item.assetFingerprint || "-") + " samples=" + item.sampleCount + " seq=" + (item.firstSeq ?? "-") + ".." + (item.lastSeq ?? "-") + " ts=" + (item.firstAt || "-") + ".." + (item.lastAt || "-") + " scripts=" + (item.scriptCount ?? "-") + " styles=" + (item.stylesheetCount ?? "-") + " urlPaths=" + (Array.isArray(item.urlPaths) ? item.urlPaths.slice(0, 4).join(",") : "-")).join("\n")
: "- 无页面 provenance segment。";
@@ -4454,6 +4695,9 @@ function renderMarkdown(report) {
+ "- loadingLongestContinuousSeconds: " + (metricSummary.loadingLongestContinuousSeconds ?? 0) + "\n"
+ "- loadingCurrentContinuousSeconds: " + (metricSummary.loadingCurrentContinuousSeconds ?? 0) + "\n"
+ "- loadingOverFiveSecondSegmentCount: " + (metricSummary.loadingOverFiveSecondSegmentCount ?? 0) + "\n"
+ "- sessionRailFallbackMajoritySampleCount: " + (metricSummary.sessionRailFallbackMajoritySampleCount ?? 0) + "\n"
+ "- sessionRailFallbackMaxRatio: " + (metricSummary.sessionRailFallbackMaxRatio ?? 0) + "\n"
+ "- sessionRailFallbackMaxCount: " + (metricSummary.sessionRailFallbackMaxCount ?? 0) + "\n"
+ "- promptSegments: " + (metricSummary.promptSegments ?? 0) + "\n\n"
+ "- turnColumns: " + (metricSummary.turnColumns ?? 0) + "\n"
+ "- turnTimingRows: " + (metricSummary.turnTimingRows ?? 0) + "\n"
@@ -4485,6 +4729,17 @@ function renderMarkdown(report) {
+ "#### Loading segments\n\n" + loadingSegmentLines + "\n\n"
+ "#### Loading owners\n\n" + loadingOwnerLines + "\n\n"
+ "#### Loading sample timeline\n\n" + loadingTimelineLines + "\n\n"
+ "### Session rail titles\n\n"
+ "- sampleCount: " + (sessionRailTitleSummary.sampleCount ?? 0) + "\n"
+ "- visibleSampleCount: " + (sessionRailTitleSummary.visibleSampleCount ?? 0) + "\n"
+ "- fallbackSampleCount: " + (sessionRailTitleSummary.fallbackSampleCount ?? 0) + "\n"
+ "- majorityFallbackSampleCount: " + (sessionRailTitleSummary.majorityFallbackSampleCount ?? 0) + "\n"
+ "- maxFallbackRatio: " + (sessionRailTitleSummary.maxFallbackRatio ?? 0) + "\n"
+ "- maxVisibleCount: " + (sessionRailTitleSummary.maxVisibleCount ?? 0) + "\n"
+ "- maxFallbackTitleCount: " + (sessionRailTitleSummary.maxFallbackTitleCount ?? 0) + "\n"
+ "- policy: 可见 session 列表中 'Session ses_...' fallback 标题超过一半必须报警;修复应让上游 session list projection 直接携带名称,不能靠点击详情后下游修补。\n\n"
+ "#### Session rail fallback samples\n\n" + sessionRailTitleSampleLines + "\n\n"
+ "#### Session rail fallback examples\n\n" + sessionRailTitleExampleLines + "\n\n"
+ "### Page provenance\n\n"
+ "- segmentCount: " + (report.pageProvenance?.summary?.segmentCount ?? 0) + "\n"
+ "- controlSegmentCount: " + (report.pageProvenance?.summary?.controlSegmentCount ?? 0) + "\n\n"