diff --git a/config/hwlab-web-probe-sentinel/check-catalog.yaml b/config/hwlab-web-probe-sentinel/check-catalog.yaml index aa8a0732..3698ce4f 100644 --- a/config/hwlab-web-probe-sentinel/check-catalog.yaml +++ b/config/hwlab-web-probe-sentinel/check-catalog.yaml @@ -785,3 +785,11 @@ sentinel: actionZh: 查看详情后处理。 blocking: true order: 960 + - code: WBC-097 + id: workbench-turn-state-triad-inconsistent + level: error + titleZh: Workbench 状态三元组不一致 + summaryZh: 会话栏状态、对话卡片状态和最终回复正文没有收敛到同一个运行或完成状态。 + actionZh: 查看详情后处理。 + blocking: true + order: 970 diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js index 71c09775..60b3605e 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js @@ -1296,6 +1296,7 @@ function displayFindingCode(code) { const normalized = String(code || "").toLowerCase(); const labels = { "quick-verify-no-business-turn": "quick verify 未触达业务 turn", + "workbench-turn-state-triad-inconsistent": "Workbench 状态三元组不一致", "session-rail-title-fallback-root-cause": "INV-02 会话标题 fallback 根因", "trace-events-page-read-404-root-cause": "INV-07 trace events 404 根因", "trace-events-page-read-http-error-root-cause": "trace events HTTP 错误根因", @@ -1315,6 +1316,7 @@ function displayFindingSummary(code, summary) { const normalized = String(code || "").toLowerCase(); const summaries = { "quick-verify-no-business-turn": "quick verify 没有形成 sendPrompt、session、trace rows 或 Final Response;不能把公开 dashboard 200 当作 HWLAB 恢复证据。", + "workbench-turn-state-triad-inconsistent": "会话栏状态、turn 卡片状态和 Final Response 正文没有收敛到 completed/completed/present 或 running/running/absent。", "session-rail-title-fallback-root-cause": "会话列表可见 Session ses_* fallback 标题;根因定位到 session list projection/read model 或 rail 数据绑定缺稳定 title/preview。", "trace-events-page-read-404-root-cause": "trace events page read 返回 404;根因定位到 /v1/workbench/traces/:traceId/events API 分页/read-model 合约,早于 DOM 渲染。", "trace-events-page-read-http-error-root-cause": "trace events page read 返回 HTTP 错误;根因定位到 trace-events API 路径,不是通用前端渲染失败。", @@ -1336,6 +1338,7 @@ function findingNextAction(code) { const normalized = String(code || "").toLowerCase(); const actions = { "quick-verify-no-business-turn": "下一步: 打开该 run 的 turn-summary/trace-frame,并用 CLI 对照命令确认没有业务 turn。", + "workbench-turn-state-triad-inconsistent": "下一步: 查看 samples 中的 railStatus/cardStatus/finalResponseTextBytes 和 collectorMissing,再修 Workbench terminal projection seal。", "session-rail-title-fallback-root-cause": "下一步: 查同 run 的 OTel session_list_read fallbackTitleCount/fallbackTitleRatio,修 session list projection/read model title/preview。", "trace-events-page-read-404-root-cause": "下一步: 查同 trace 的 OTel trace_events_read sinceSeq/range/hasMore/fullTraceLoaded,修 trace-events 分页合约或 instrumentation。", "trace-events-page-read-http-error-root-cause": "下一步: 按 traceId/afterProjectedSeq 对齐 OTel trace_events_read 和 HTTP route span。", diff --git a/scripts/src/hwlab-node-web-observe-analyzer-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-source.ts index 9b24df4b..757eb4ee 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-source.ts @@ -176,6 +176,7 @@ console.log(JSON.stringify({ turnColumns: recentWindow.sampleMetrics.turnColumns.slice(-12).map((item) => ({ label: item.label, source: item.source, pageRole: item.pageRole ?? null, pageId: item.pageId ?? null, pageEpoch: item.pageEpoch ?? null, 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), + workbenchTurnStateTriad: compactWorkbenchTurnStateTriadForOutput(recentWindow.sampleMetrics.workbenchTurnStateTriad), }, pageProvenance: recentWindow.pageProvenance.summary, pagePerformance: recentWindow.pagePerformance.summary, @@ -943,6 +944,11 @@ function compactSessionRail(value) { testId: item?.testId ?? null, role: item?.role ?? null, active: item?.active === true, + status: item?.status ?? null, + dataStatus: item?.dataStatus ?? null, + running: item?.running === true, + dataRunning: item?.dataRunning ?? null, + ariaBusy: item?.ariaBusy ?? null, sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null), fallbackTitle: item?.fallbackTitle === true, titlePreview: limitText(String(item?.titlePreview || item?.titleText || ""), 180), @@ -964,6 +970,7 @@ function compactSessionRail(value) { 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), + activeItem: items.find((item) => item.active) || null, items, fallbackItems, valuesRedacted: true @@ -1028,6 +1035,8 @@ function compactDomItem(item) { if (!item || typeof item !== "object") return item; const rawText = String(item.text ?? item.textPreview ?? ""); const preview = String(item.textPreview ?? limitText(rawText, 240)); + const hasBodyTextPresent = Object.prototype.hasOwnProperty.call(item, "bodyTextPresent"); + const hasFinalResponsePresent = Object.prototype.hasOwnProperty.call(item, "finalResponsePresent"); return { index: item.index ?? null, tag: item.tag ?? null, @@ -1047,6 +1056,18 @@ function compactDomItem(item) { eventKind: item.eventKind ?? null, durationText: item.durationText ?? null, activityText: item.activityText ?? null, + bodyTextSource: item.bodyTextSource ?? null, + bodyTextCandidateCount: Number.isFinite(Number(item.bodyTextCandidateCount)) ? Number(item.bodyTextCandidateCount) : null, + bodyTextPresent: hasBodyTextPresent ? item.bodyTextPresent === true : null, + bodyTextPreview: item.bodyTextPreview ? limitText(item.bodyTextPreview, 600) : undefined, + bodyTextHash: item.bodyTextHash ?? null, + bodyTextBytes: Number.isFinite(Number(item.bodyTextBytes)) ? Number(item.bodyTextBytes) : null, + finalResponsePresent: hasFinalResponsePresent ? item.finalResponsePresent === true : null, + finalResponseTextSource: item.finalResponseTextSource ?? null, + finalResponseCandidateCount: Number.isFinite(Number(item.finalResponseCandidateCount)) ? Number(item.finalResponseCandidateCount) : null, + finalResponseTextPreview: item.finalResponseTextPreview ? limitText(item.finalResponseTextPreview, 600) : undefined, + finalResponseTextHash: item.finalResponseTextHash ?? null, + finalResponseTextBytes: Number.isFinite(Number(item.finalResponseTextBytes)) ? Number(item.finalResponseTextBytes) : null, className: item.className ?? null, diagnosticCode: item.diagnosticCode ?? null, source: item.source ?? null, @@ -1107,6 +1128,17 @@ function compactSessionRailTitleMetricsForOutput(value) { }; } +function compactWorkbenchTurnStateTriadForOutput(value) { + if (!value || typeof value !== "object") return null; + return { + summary: value.summary ?? null, + invalidFullTriads: Array.isArray(value.invalidFullTriads) ? value.invalidFullTriads.slice(0, 8) : [], + cardFinalResponseMismatches: Array.isArray(value.cardFinalResponseMismatches) ? value.cardFinalResponseMismatches.slice(0, 8) : [], + collectorMissingRows: Array.isArray(value.collectorMissingRows) ? value.collectorMissingRows.slice(0, 8) : [], + valuesRedacted: true + }; +} + function compactSamplePageProvenance(value) { if (!value || typeof value !== "object") return null; return { @@ -2807,6 +2839,163 @@ function workbenchDomItemLooksFinal(item) { return workbenchDomItemIsTerminal(item) && isFinalResultText(text); } +function buildWorkbenchTurnStateTriadMetrics(samples, timeline = []) { + const rows = []; + const sourceSamples = Array.isArray(samples) ? samples : []; + for (let index = 0; index < sourceSamples.length; index += 1) { + const sample = sourceSamples[index]; + if (!isWorkbenchPathSample(sample)) continue; + const turns = Array.isArray(sample?.turns) ? sample.turns.filter((turn) => String(turn?.role || turn?.dataRole || "agent") === "agent") : []; + if (turns.length === 0) continue; + const rail = activeSessionRailItemForSample(sample); + for (const turn of turns.slice(-6)) { + const row = workbenchTurnStateTriadRow(sample, turn, rail, timeline[index]?.promptIndex ?? 0); + if (row) rows.push(row); + } + } + const invalidFullTriads = rows.filter((row) => row.invalid === true && row.fullTriad === true); + const cardFinalResponseMismatches = rows.filter((row) => row.cardFinalResponseMismatch === true || row.legacyCardFinalResponseMismatch === true); + const collectorMissingRows = rows.filter((row) => Array.isArray(row.collectorMissing) && row.collectorMissing.length > 0); + const invalidRowKeys = new Set([...invalidFullTriads, ...cardFinalResponseMismatches].map((row) => row.rowKey)); + const collectorMissingFields = uniqueSorted(collectorMissingRows.flatMap((row) => row.collectorMissing || [])); + return { + summary: { + sampleCount: sourceSamples.length, + rowCount: rows.length, + fullTriadRowCount: rows.filter((row) => row.fullTriad === true).length, + invalidRowCount: invalidRowKeys.size, + invalidFullTriadCount: invalidFullTriads.length, + cardFinalResponseMismatchCount: cardFinalResponseMismatches.length, + collectorMissingRowCount: collectorMissingRows.length, + collectorMissingFields, + valuesRedacted: true + }, + invalidFullTriads: invalidFullTriads.slice(0, 120), + cardFinalResponseMismatches: cardFinalResponseMismatches.slice(0, 120), + collectorMissingRows: collectorMissingRows.slice(0, 120), + timeline: rows.slice(-300), + valuesRedacted: true + }; +} + +function workbenchTurnStateTriadRow(sample, turn, rail, promptIndex) { + const collectorMissing = []; + const railRawStatus = firstString(rail?.status, rail?.dataStatus); + const railRunning = rail?.running === true || String(rail?.dataRunning || rail?.ariaBusy || "").toLowerCase() === "true"; + const railStatus = normalizeWorkbenchTriadStatus(railRawStatus, railRunning); + if (!rail) collectorMissing.push("sessionRail.activeItem"); + if (!railRawStatus && railRunning !== true) collectorMissing.push("sessionRail.status"); + const cardRawStatus = firstString(turn?.status); + const cardStatus = normalizeWorkbenchTriadStatus(cardRawStatus, false) || (workbenchDomItemIsTerminal(turn) ? "completed" : null); + if (!cardRawStatus && !cardStatus) collectorMissing.push("turn.status"); + const finalPresence = finalResponsePresenceFromTurn(turn); + if (finalPresence.known !== true) collectorMissing.push("turn.finalResponseTextBytes"); + const fullTriad = Boolean(railStatus && cardStatus && finalPresence.known === true); + const finalResponsePresent = finalPresence.known === true ? finalPresence.present : null; + const tupleAllowed = fullTriad + ? (railStatus === "completed" && cardStatus === "completed" && finalResponsePresent === true) + || (railStatus === "running" && cardStatus === "running" && finalResponsePresent === false) + : null; + const cardFinalResponseMismatch = cardStatus === "completed" && finalPresence.known === true && finalResponsePresent !== true; + const legacyCardFinalResponseMismatch = cardStatus === "completed" && finalPresence.known !== true && !workbenchDomItemLooksFinal(turn); + const railCardMismatch = Boolean(railStatus && cardStatus && railStatus !== cardStatus); + const invalid = tupleAllowed === false || cardFinalResponseMismatch || legacyCardFinalResponseMismatch || railCardMismatch; + const traceId = firstString(turn?.traceId, turn?.turnId); + const messageId = firstString(turn?.messageId); + const rowKey = [ + sample?.seq ?? "-", + sample?.pageRole ?? "control", + sample?.pageId ?? "default", + sample?.routeSessionId ?? sample?.activeSessionId ?? rail?.sessionIdPrefix ?? "-", + traceId ?? messageId ?? turn?.index ?? "-" + ].join("|"); + return { + ...ref(sample), + rowKey, + promptIndex, + sessionIdPrefix: sessionPrefixForSample(sample, rail), + traceId, + messageId, + turnId: firstString(turn?.turnId), + turnIndex: turn?.index ?? null, + railStatus, + railStatusRaw: railRawStatus ?? null, + railRunning: railRunning === true, + railSource: rail ? "sessionRail.activeItem" : null, + cardStatus, + cardStatusRaw: cardRawStatus ?? null, + finalResponsePresent, + finalResponseTextBytes: turn?.finalResponseTextBytes !== null && turn?.finalResponseTextBytes !== undefined && Number.isFinite(Number(turn.finalResponseTextBytes)) ? Number(turn.finalResponseTextBytes) : null, + finalResponseSource: turn?.finalResponseTextSource ?? null, + finalResponseTextHash: turn?.finalResponseTextHash ?? null, + finalResponseTextPreview: turn?.finalResponseTextPreview ? limitText(turn.finalResponseTextPreview, 160) : null, + fullTriad, + tupleAllowed, + invalid, + cardFinalResponseMismatch, + legacyCardFinalResponseMismatch, + railCardMismatch, + collectorMissing, + nearestCommandId: sample?.commandId ?? null, + relatedChecks: ["WBC-001", "WBC-003", "WBC-011", "WBC-022"], + valuesRedacted: true + }; +} + +function activeSessionRailItemForSample(sample) { + const rail = sample?.sessionRail && typeof sample.sessionRail === "object" ? sample.sessionRail : null; + if (!rail) return null; + const items = Array.isArray(rail.items) ? rail.items : []; + if (rail.activeItem) return rail.activeItem; + const routeSessionId = String(sample?.routeSessionId || ""); + const activeSessionId = String(sample?.activeSessionId || ""); + return items.find((item) => item?.active === true) + || items.find((item) => sessionRailItemMatchesSession(item, activeSessionId)) + || items.find((item) => sessionRailItemMatchesSession(item, routeSessionId)) + || null; +} + +function sessionRailItemMatchesSession(item, sessionId) { + const id = String(sessionId || ""); + const prefix = String(item?.sessionIdPrefix || item?.sessionId || ""); + return Boolean(id && prefix && (id === prefix || id.startsWith(prefix) || prefix.startsWith(id))); +} + +function sessionPrefixForSample(sample, rail) { + const id = firstString(sample?.activeSessionId, sample?.routeSessionId, rail?.sessionIdPrefix, rail?.sessionId); + return id ? String(id).slice(0, 12) : null; +} + +function finalResponsePresenceFromTurn(turn) { + if (!turn || typeof turn !== "object") return { known: false, present: null }; + if (turn.finalResponseTextBytes !== null && turn.finalResponseTextBytes !== undefined && Number.isFinite(Number(turn.finalResponseTextBytes))) { + const bytes = Number(turn.finalResponseTextBytes); + return { known: true, present: bytes > 0 }; + } + if (Object.prototype.hasOwnProperty.call(turn, "finalResponsePresent") && turn.finalResponsePresent !== null && turn.finalResponsePresent !== undefined) { + return { known: true, present: turn.finalResponsePresent === true }; + } + return { known: false, present: null }; +} + +function normalizeWorkbenchTriadStatus(status, running = false) { + const value = String(status || "").trim().toLowerCase().replace(/_/gu, "-"); + if (running === true) return "running"; + if (!value) return null; + if (/^(completed|complete|succeeded|success|finished|done|terminal|sealed)$/u.test(value)) return "completed"; + if (/^(pending|running|active|busy|admitted|dispatching|executing|streaming|processing|queued|in-progress|creating)$/u.test(value)) return "running"; + return null; +} + +function firstString(...values) { + for (const value of values) { + if (typeof value !== "string") continue; + const text = value.trim(); + if (text) return text; + } + return null; +} + function promptCommandHasAuthoritativeSubmitSideEffect(control, promptRound) { const commandId = stringOrNull(promptRound?.promptCommandId); if (!commandId) return false; @@ -2958,6 +3147,33 @@ function buildFindings(samples, control, network, errors, sampleMetrics, promptN rootCauseConfidence: "high", valuesRedacted: true }); + const turnStateTriad = sampleMetrics?.workbenchTurnStateTriad || {}; + const turnStateTriadSummary = turnStateTriad.summary || {}; + const turnStateTriadRows = [ + ...(Array.isArray(turnStateTriad.invalidFullTriads) ? turnStateTriad.invalidFullTriads : []), + ...(Array.isArray(turnStateTriad.cardFinalResponseMismatches) ? turnStateTriad.cardFinalResponseMismatches : []) + ]; + if (Number(turnStateTriadSummary.invalidRowCount ?? 0) > 0) findings.push({ + id: "workbench-turn-state-triad-inconsistent", + severity: "red", + summary: "Workbench session rail status, turn card status, and Final Response body presence diverged from the allowed running/running/absent or completed/completed/present tuples", + count: turnStateTriadSummary.invalidRowCount, + fullTriadCount: turnStateTriadSummary.fullTriadRowCount, + invalidFullTriadCount: turnStateTriadSummary.invalidFullTriadCount, + cardFinalResponseMismatchCount: turnStateTriadSummary.cardFinalResponseMismatchCount, + legacyCollectorMissingCount: turnStateTriadSummary.collectorMissingRowCount, + collectorMissingFields: Array.isArray(turnStateTriadSummary.collectorMissingFields) ? turnStateTriadSummary.collectorMissingFields : [], + allowedTuples: [ + { railStatus: "completed", cardStatus: "completed", finalResponsePresent: true }, + { railStatus: "running", cardStatus: "running", finalResponsePresent: false } + ], + samples: turnStateTriadRows.slice(0, 20), + collectorMissingSamples: Array.isArray(turnStateTriad.collectorMissingRows) ? turnStateTriad.collectorMissingRows.slice(0, 10) : [], + rootCause: "workbench_projection_state_triad_not_sealed", + rootCauseStatus: "confirmed-from-dom-samples", + rootCauseConfidence: "high", + valuesRedacted: true + }); const promptFailures = Array.isArray(promptNetwork?.rounds) ? promptNetwork.rounds.filter((item) => item.chatPostOk === false && !promptCommandHasAuthoritativeSubmitSideEffect(control, item)) : []; if (promptFailures.length > 0) findings.push({ id: "prompt-chat-submit-failed", severity: "red", summary: "sendPrompt command had no successful /v1/agent/chat or /v1/agent/chat/steer POST response in the sampling window", count: promptFailures.length, rounds: promptFailures.slice(0, 10) }); const promptSteerRounds = Array.isArray(promptNetwork?.rounds) ? promptNetwork.rounds.filter((item) => item.steerUsed === true) : []; @@ -4875,6 +5091,7 @@ function prioritizeFindings(findings) { if (id.startsWith("project-management-") || id.startsWith("mdtodo-") || id === "workbench-launch-button-unavailable") return 0; if (id === "page-performance-slow-same-origin-api") return 0; if (id === "session-rail-title-fallback-majority") return 0.5; + if (id === "workbench-turn-state-triad-inconsistent") return 0.55; if (id.startsWith("workbench-terminal-")) return 0.6; if (id.startsWith("code-agent-card-")) return 0.8; if (id.startsWith("round-completion-")) return 0.9; @@ -4975,6 +5192,7 @@ function buildSampleMetrics(samples, control) { const diagnostics = timeline.filter((item) => item.diagnosticSeen).length; const loading = buildLoadingMetrics(samples, timeline); const sessionRailTitles = buildSessionRailTitleMetrics(samples, timeline); + const workbenchTurnStateTriad = buildWorkbenchTurnStateTriadMetrics(samples, timeline); const reportTurnTimingRows = boundedTurnTimingRowsForReport(turnTiming.rows); const reportTimeline = boundedRowsForReport(timeline); const rounds = buildRoundMetricSummaries(timeline, promptCommands, { @@ -5009,6 +5227,11 @@ function buildSampleMetrics(samples, control) { sessionRailFallbackMaxRatio: sessionRailTitles.summary.maxFallbackRatio, sessionRailFallbackMaxVisibleCount: sessionRailTitles.summary.maxVisibleCount, sessionRailFallbackMaxCount: sessionRailTitles.summary.maxFallbackTitleCount, + workbenchTurnStateTriadRows: workbenchTurnStateTriad.summary.rowCount, + workbenchTurnStateTriadInvalidRows: workbenchTurnStateTriad.summary.invalidRowCount, + workbenchTurnStateTriadFullInvalidRows: workbenchTurnStateTriad.summary.invalidFullTriadCount, + workbenchTurnStateCardFinalResponseMismatchRows: workbenchTurnStateTriad.summary.cardFinalResponseMismatchCount, + workbenchTurnStateCollectorMissingRows: workbenchTurnStateTriad.summary.collectorMissingRowCount, promptSegments: Math.max(0, promptTimes.length), rounds: rounds.length, turnColumns: turnTiming.columns.length, @@ -5048,6 +5271,7 @@ function buildSampleMetrics(samples, control) { }, loading, sessionRailTitles, + workbenchTurnStateTriad, codeAgentCardTiming, traceOrder, rounds, diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index 1d1fd76a..cec218a4 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -4580,12 +4580,20 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag seen.add(key); const rect = element.getBoundingClientRect(); const fallbackTitle = looksLikeSessionTitleFallback(titleText, sessionId); + const dataStatus = element.getAttribute("data-status") || element.getAttribute("data-state") || null; + const dataRunning = element.getAttribute("data-running") || element.getAttribute("data-busy") || null; + const ariaBusy = element.getAttribute("aria-busy") || null; 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", + status: dataStatus || ariaBusy || null, + dataStatus, + running: dataRunning === "true" || ariaBusy === "true" || element.classList.contains("is-running") || element.classList.contains("running"), + dataRunning, + ariaBusy, sessionId, sessionIdPrefix: sessionId ? String(sessionId).slice(0, 12) : null, titleText, @@ -4601,6 +4609,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag visibleCount, fallbackTitleCount, fallbackTitleRatio: visibleCount > 0 ? Number((fallbackTitleCount / visibleCount).toFixed(4)) : 0, + activeItem: visibleItems.find((item) => item.active) || null, items: visibleItems.slice(0, 60), fallbackItems: fallbackItems.slice(0, 12), }; @@ -4637,7 +4646,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag const title = element.getAttribute("title") || ""; return tag === "button" || role === "button" || /诊断/u.test(aria) || /诊断/u.test(title); }; - const stableMessageText = (element) => { + const messageBodyTextDetail = (element) => { const bodySelectors = [ ".message-markdown.message-text", ".message-text", @@ -4651,10 +4660,21 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag 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); + if (text && !parts.some((part) => part.text === text)) parts.push({ text, selector }); } } - if (parts.length > 0) return parts.join(" "); + if (parts.length > 0) { + return { + text: parts.map((part) => part.text).join(" "), + source: parts[0]?.selector || "body-selector", + candidateCount: parts.length + }; + } + return { text: "", source: null, candidateCount: 0 }; + }; + const stableMessageText = (element) => { + const body = messageBodyTextDetail(element); + if (body.text) return body.text; const clone = element.cloneNode(true); for (const selector of [ ".message-duration-meta", @@ -4715,6 +4735,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag const summarize = (selector, limit) => summarizeElements(Array.from(document.querySelectorAll(selector)), limit); const summarizeMessages = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(visible).slice(-limit).map((element, index) => { const rect = element.getBoundingClientRect(); + const body = messageBodyTextDetail(element); return { index, tag: element.tagName.toLowerCase(), @@ -4728,6 +4749,9 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag turnId: element.getAttribute("data-turn-id") || null, durationText: trim(element.querySelector(".message-duration-meta")?.textContent || "", 120), activityText: trim(element.querySelector(".message-activity-meta")?.textContent || "", 120), + bodyText: body.text, + bodyTextSource: body.source, + bodyTextCandidateCount: body.candidateCount, text: stableMessageText(element), rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }; @@ -4914,6 +4938,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag const turns = Array.from(document.querySelectorAll('article.message-card[data-role="agent"], .message-card[data-role="agent"], article[data-role="agent"]')).filter(visible).map((element, index) => { const rect = element.getBoundingClientRect(); const text = textHashInput(element); + const finalResponse = messageBodyTextDetail(element); const traceElement = element.matches("[data-trace-id]") ? element : element.querySelector("[data-trace-id]"); const traceMatch = text.match(/\btrc_[A-Za-z0-9_-]+\b/u); const durationText = trim(element.querySelector(".message-duration-meta")?.textContent || "", 120); @@ -4929,6 +4954,10 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag turnId: element.getAttribute("data-turn-id") || directTraceId, durationText, activityText, + finalResponseText: finalResponse.text, + finalResponseTextSource: finalResponse.source, + finalResponseCandidateCount: finalResponse.candidateCount, + finalResponsePresent: Boolean(finalResponse.text && finalResponse.text.trim()), text, rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, }; @@ -5014,12 +5043,12 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag function digestDom(dom, pageRole = "control") { if (dom && dom.error) return dom; - 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 messages = Array.isArray(dom.messages) ? dom.messages.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 160), textBytes: Buffer.byteLength(item.text || ""), bodyTextHash: sha256Text(item.bodyText || ""), bodyTextPreview: truncate(item.bodyText || "", 160), bodyTextBytes: Buffer.byteLength(item.bodyText || ""), bodyTextPresent: Boolean(String(item.bodyText || "").trim()) })) : []; 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 turns = Array.isArray(dom.turns) ? dom.turns.map((item) => ({ ...item, textHash: sha256Text(item.text || ""), textPreview: truncate(item.text || "", 200), textBytes: Buffer.byteLength(item.text || ""), finalResponseTextHash: sha256Text(item.finalResponseText || ""), finalResponseTextPreview: truncate(item.finalResponseText || "", 200), finalResponseTextBytes: Buffer.byteLength(item.finalResponseText || ""), finalResponsePresent: Boolean(String(item.finalResponseText || "").trim()) })) : []; const projectManagementSample = digestProjectManagement(dom.projectManagement); const pageProvenance = normalizePageProvenance(dom.pageProvenance, { reason: "sample", pageLoadSeq: currentPageProvenance?.pageLoadSeq ?? pageLoadSeq }); if (pageRole === "control") currentPageProvenance = pageProvenance; @@ -5112,6 +5141,11 @@ function digestSessionRail(value) { testId: item?.testId ?? null, role: item?.role ?? null, active: item?.active === true, + status: item?.status ?? null, + dataStatus: item?.dataStatus ?? null, + running: item?.running === true, + dataRunning: item?.dataRunning ?? null, + ariaBusy: item?.ariaBusy ?? null, sessionId: item?.sessionId ?? null, sessionIdPrefix: item?.sessionIdPrefix ?? (item?.sessionId ? String(item.sessionId).slice(0, 12) : null), fallbackTitle: item?.fallbackTitle === true, @@ -5128,6 +5162,7 @@ function digestSessionRail(value) { 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), + activeItem: items.find((item) => item.active) || null, items, fallbackItems, valuesRedacted: true, diff --git a/scripts/src/hwlab-node-web-sentinel-p5-observe.ts b/scripts/src/hwlab-node-web-sentinel-p5-observe.ts index a82f3218..0e2170bc 100644 --- a/scripts/src/hwlab-node-web-sentinel-p5-observe.ts +++ b/scripts/src/hwlab-node-web-sentinel-p5-observe.ts @@ -1515,6 +1515,7 @@ function isQuickVerifyBlockingFinding(item: Record): boolean { "quick-verify-observer-start-failed", "quick-verify-account-secret-missing", "prompt-chat-submit-failed", + "workbench-turn-state-triad-inconsistent", "route-active-session-mismatch", "final-response-flicker", "round-completion-final-response-missing",