diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index a4dd4cdcec..0051fbfde2 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -78,6 +78,7 @@ "smoke:task-session-observation": "vite build --ssr smoke/task-session-observation-smoke.ts --outDir node_modules/.cache/loopx-task-session-observation --emptyOutDir && node node_modules/.cache/loopx-task-session-observation/task-session-observation-smoke.js", "smoke:conversation-history-order": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/conversation-history-order-browser-smoke.mjs", "smoke:conversation-returns-packaged": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/conversation-return-browser-smoke.mjs", + "smoke:research-evidence-version": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/research-evidence-version-browser-smoke.mjs", "smoke:workspace-scope-packaged": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/workspace-scope-browser-smoke.mjs", "smoke:recent-completions": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --types node --outDir /tmp/loopx-recent-completions-smoke smoke/recent-completions-smoke.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-recent-completions-smoke/apps/presentation/dashboard/smoke/recent-completions-smoke.js", "smoke:task-inspector-route": "npm run smoke:goal-work-browse && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/task-inspector-route-smoke.mjs", diff --git a/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs b/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs index 43685e580f..d9a6ebd0b0 100644 --- a/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs +++ b/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs @@ -8,7 +8,7 @@ import {createInterface} from "node:readline"; import {createElement} from "react"; import {renderToStaticMarkup} from "react-dom/server"; import {resolveTestPython} from "../../../../scripts/test-python.mjs"; -import {readAnalysisSnapshot, ResearchAnalysisPanel} from "../src/features/personal-workspace/goal-research-analysis.tsx"; +import {readAnalysisSnapshot, ResearchAnalysisPanel, ResearchCaseReading, ResearchCitationPanel} from "../src/features/personal-workspace/goal-research-analysis.tsx"; import {ResearchCaseStudy} from "../src/features/personal-workspace/research-case-study.tsx"; import {fetchExploreResults} from "../src/data/chat.ts"; import {GoalResearchWorkbench} from "../src/features/personal-workspace/goal-research-workbench.tsx"; @@ -26,6 +26,20 @@ assert.ok(renderedStudy.includes('not current results')); assert.match(renderedStudy, /候选 A<.*?>7<.*?候选 B<.*?>Not recorded<.*?候选 C<.*?>3 renderToStaticMarkup(createElement(ResearchCaseReading, { + analysis: {zh: false, pack: casePack, snapshot: {catalog: {packs}}, location: {open: false}}, +})); +assert.ok(!renderCase([casePack]).includes(" { + globalThis.window = {location: new URL(search, "http://127.0.0.1/chat/")}; + return renderToStaticMarkup(createElement(GoalResearchWorkbench, {goalId: "child", zh: false, active: true, refreshRevision: 0})); + }; + assert.ok(renderEntry("?research_goal=child&research_reading=case").includes("research-case-reader"), + "A direct case address retains its reader while the catalogue is pending"); + assert.ok(!renderEntry("?research_goal=another&research_reading=case").includes("research-case-reader"), + "Another Goal's case address cannot replace this Goal's history"); +} finally {globalThis.window = savedLocation;} + // Transport uses the workspace view; immutable findings still identify the native Goal. const sourceFetch = globalThis.fetch, sourceWindow = globalThis.window; diff --git a/apps/presentation/dashboard/smoke/research-evidence-version-browser-smoke.mjs b/apps/presentation/dashboard/smoke/research-evidence-version-browser-smoke.mjs new file mode 100644 index 0000000000..0edb1736b4 --- /dev/null +++ b/apps/presentation/dashboard/smoke/research-evidence-version-browser-smoke.mjs @@ -0,0 +1,135 @@ +// Exercise a saved original and its newly available language-specific result +// through the packaged reader. Both snapshots and citation excerpts come from +// the native test backend; interception controls only when the result arrives. +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {mkdtemp, readFile, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {resolve} from "node:path"; +import {createInterface} from "node:readline"; +import {resolveTestPython} from "../../../../scripts/test-python.mjs"; +import {launchBrowser, loadPlaywright} from "../../../../examples/dashboard-browser-smoke-support.mjs"; + +const repo = resolve(import.meta.dirname, "../../../.."); +const root = await mkdtemp(resolve(tmpdir(), "loopx-evidence-version-")); +const assets = process.env.LOOPX_RESEARCH_EVIDENCE_ASSETS ?? resolve(repo, "loopx/web/chat"); +const servers = []; +let browser; +let releaseOriginal; +try { + const capture = JSON.parse(await readFile(resolve(import.meta.dirname, "../src/data/fixtures/research-native-readback.json"), "utf8")); + const analysis = capture.analyses[0]; + analysis.config = {model: "fixture", effort: "max", service_tier: "fast", research_synthesis: false}; + const observation = (line, verdict, reason) => ({ + assessments: [{episode_id: "search-episode", metric: "benefit_test", verdict, reason, + citations: [{source_id: "facts", first_line: line, last_line: line}]}], + limitations: ["Synthetic reader fixture."], + }); + analysis.outputs = {original: observation(1, "no", "The saved original examines the first observation.")}; + const initial = await start("original", capture); + analysis.outputs.zh = {...observation(3, "yes", "当前分析判读后续观测。"), + episode_questions: [{episode_id: "search-episode", question: "后续观测是否检验了任务进展?"}]}; + const ready = await start("localized", capture); + const endpoints = new URLSearchParams({goal_id: capture.goal_id, output_language: "zh"}); + const analysisPath = "/api/chat/research-analysis"; + const getPack = async backend => (await (await fetch(new URL(analysisPath + "?" + endpoints, backend))).json()).pack; + const [before, after] = await Promise.all([getPack(initial), getPack(ready)]); + assert.equal(before.pack_id, after.pack_id); + assert.equal(before.analysis_key, after.analysis_key, "Same requested analysis identity"); + assert.equal(before.result, null); + assert.equal(after.result.output_language, "zh"); + + browser = await launchBrowser(loadPlaywright().chromium); + const page = await browser.newPage({locale: "zh-CN", viewport: {width: 1200, height: 900}}); + let localizedReady = false; + let holdOriginal = false; + await page.route(/\/api\/chat\/research-analysis(?:\/citation)?\?/, async route => { + const url = new URL(route.request().url()); + if (url.searchParams.get("output_language") === "zh" && localizedReady) { + return route.fulfill({response: await route.fetch({url: new URL(url.pathname + url.search, ready).href})}); + } + if (url.pathname.endsWith("/citation") && holdOriginal) { + holdOriginal = false; + await new Promise(resolveOriginal => {releaseOriginal = resolveOriginal;}); + } + return route.continue(); + }); + const citation = page.locator(".analysis-source"); + const citationButton = page.locator(".analysis-episode[open] .analysis-citations button"); + const research = new URL("/chat/", initial); + research.search = new URLSearchParams({goalId: capture.goal_id, view: "research", + research_analysis: capture.goal_id, research_pack: before.pack_id}); + const refresh = page.getByRole("button", {name: "刷新分析结果", exact: true}); + const showEpisode = () => page.locator(".analysis-episode > summary").click(); + async function openOriginal() { + localizedReady = false; + await page.goto(research.href); + await page.getByText("显示已保存的原文分析", {exact: false}).waitFor(); + await showEpisode(); + } + async function refreshToLocalized() { + localizedReady = true; + await refresh.click(); + await page.getByText("显示已保存的原文分析", {exact: false}).waitFor({state: "hidden"}); + await page.getByRole("button", {name: /收益检验 · 1/}).first().waitFor(); + assert.equal(await citation.count(), 0, "No prior version citation content or loading state in the new reading"); + } + + await openOriginal(); + await citationButton.click(); + await citation.getByText(/candidate-one/).waitFor(); + await refreshToLocalized(); + await showEpisode(); + await citationButton.click(); + await citation.getByText(/candidate-three/).waitFor(); + + await openOriginal(); + holdOriginal = true; + const requested = page.waitForRequest(request => request.url().includes("/citation?")); + await citationButton.click(); + await requested; + await citation.getByText("正在读取引用原文…", {exact: true}).waitFor(); + await refreshToLocalized(); + const late = page.waitForResponse(response => response.url().includes("/citation?") && response.ok()); + assert.equal(typeof releaseOriginal, "function"); + releaseOriginal(); + await (await late).finished(); + await page.evaluate(() => new Promise(resolvePaint => requestAnimationFrame(() => requestAnimationFrame(resolvePaint)))); + assert.equal(await citation.count(), 0, "Late original response cannot reopen the previous evidence"); + console.log("research evidence version: saved and late original excerpts isolated"); +} finally { + releaseOriginal?.(); + await browser?.close(); + await Promise.all(servers.map(async child => { + if (child.exitCode !== null || child.signalCode !== null) return; + const closed = once(child, "exit"); + child.kill("SIGTERM"); + await closed; + })); + await rm(root, {recursive: true, force: true}); +} + +async function start(name, capture) { + const fixture = resolve(root, name + ".json"); + await writeFile(fixture, JSON.stringify(capture)); + const child = spawn(resolveTestPython({repoRoot: repo}), [ + "-u", "examples/personal-workspace-browser/research-history-backend.py", + "--fixture", fixture, "--workspace-directory", "--assets-dir", assets, + ], {cwd: repo, env: {...process.env, PYTHONPATH: repo}, stdio: ["ignore", "pipe", "pipe"]}); + servers.push(child); + let error = ""; + child.stderr.on("data", chunk => {error = (error + chunk).slice(-3000);}); + const lines = createInterface({input: child.stdout}); + try { + return await new Promise((accept, reject) => { + const timer = setTimeout(() => reject(new Error("Research test server startup timed out: " + error)), 25_000); + child.once("error", failure => {clearTimeout(timer); reject(failure);}); + child.once("exit", () => {clearTimeout(timer); reject(new Error("Research test server exited: " + error));}); + lines.once("line", line => { + clearTimeout(timer); + try {accept(JSON.parse(line).url);} catch (failure) {reject(failure);} + }); + }); + } finally {lines.close();} +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index 085f9f582b..5c573ada19 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -30,6 +30,12 @@ type Pack = ResearchProgressPack & {source_count: number; analysis_key: string; type Snapshot = {ok: true; execution_enabled?: boolean; catalog: {availability: string; reason_code: string | null; default_pack_id: string | null; packs: Pack[]; unreadable_pack_ids?: string[]}; pack: Pack | null; originalPack?: Pack | null; originalReadError?: string}; type Citation = AssessmentCitation & {evidence_kind?: "usage_counters"; artifact: string; text: string; applicability: string}; +type EvidenceRead = { + scope: string; + busy?: boolean; + value?: Citation; + failure?: {message: string; route: string; selection: Record}; +}; const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); const locationFor = (goal: string) => { const query = new URLSearchParams(window.location.search); @@ -86,12 +92,15 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean || !["observed", "unavailable"].includes(value.comparison.status)) throw new Error("Comparison readback unavailable"); return value.comparison; }, [goalId, readSourceUrl, outputLanguage]); - const [citation, setCitation] = useState(null); - const [citationError, setCitationError] = useState(""); - const [citationBusy, setCitationBusy] = useState(false); + const [evidenceRead, setEvidenceRead] = useState(null); const citeGeneration = useRef(0); const sourcePanel = useRef(null); const sourceTrigger = useRef(null); + const invalidateCitation = useCallback(() => { + citeGeneration.current++; + sourceTrigger.current = null; + setEvidenceRead(null); + }, []); const [location, setLocation] = useState(() => locationFor(goalId)); const sourceScope = JSON.stringify([goalId, outputLanguage, readSourceUrl ?? ""]); const [readState, setReadState] = useState<{scope: string; packId: string | null; snapshot: Snapshot | null; error: string; loading: boolean}>( @@ -145,14 +154,14 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean // pending reads/actions before a new scope can display the old snapshot. if (scope.current.packId !== next.packId) { generation.current++; readSequence.current++; submitting.current = false; - citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); + invalidateCitation(); setPending(false); setCancelPending(false); setError(""); setReadState(previous => ({scope: sourceScope, packId: next.packId, snapshot: previous.scope === sourceScope && previous.snapshot ? previous.snapshot.pack?.pack_id === next.packId ? previous.snapshot : {...previous.snapshot, pack: null, originalPack: null, originalReadError: ""} : null, error: "", loading: true})); } scope.current = {goalId, packId: next.packId, outputLanguage, sourceScope}; setLocation(next); - }, [goalId, outputLanguage, sourceScope]); + }, [goalId, outputLanguage, sourceScope, invalidateCitation]); useEffect(() => { generation.current++; submitting.current = false; applyLocation(locationFor(goalId)); @@ -171,8 +180,16 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean const currentSnapshot = snapshot; const pack = currentSnapshot?.pack ?? null; const readingPack = currentSnapshot?.originalPack ?? pack; + // A saved original and a later result in the requested language can share + // the selection and pack ID. Evidence belongs to the result being displayed. + const evidenceScope = readingPack ? JSON.stringify([sourceScope, readingPack.pack_id, readingPack.analysis_key]) : null; + const visibleEvidence = evidenceRead?.scope === evidenceScope ? evidenceRead : null; + const citation = visibleEvidence?.value ?? null; + const citationBusy = Boolean(visibleEvidence?.busy); + const failedCitation = visibleEvidence?.failure; + const citationError = failedCitation?.message ?? ""; const savedOriginal = Boolean(currentSnapshot?.originalPack); - useEffect(() => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false);}, [goalId, readSourceUrl, snapshot?.pack?.pack_id, snapshot?.pack?.analysis_key]); + useLayoutEffect(invalidateCitation, [evidenceScope, invalidateCitation]); useEffect(() => { if ((!citation && !citationBusy && !citationError) || !active) return; // A response can arrive before React mounts the source panel. Move only @@ -233,20 +250,32 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean }; const readEvidence = async (selection: Record, route: string, origin?: HTMLButtonElement) => { const p = readingPack; - if (!p) return; + if (!p || evidenceScope === null) return; if (origin) sourceTrigger.current = origin; - const current = ++citeGeneration.current; setCitation(null); setCitationBusy(true); setCitationError(""); + const current = ++citeGeneration.current; + setEvidenceRead({scope: evidenceScope, busy: true}); const query = new URLSearchParams({goal_id: goalId, pack_id: p.pack_id, analysis_key: p.analysis_key, ...(p.config.output_language ? {output_language: p.config.output_language} : {}), ...selection}); - try {const value = await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}/${route}?${query}`)); if (current === citeGeneration.current) setCitation(value);} - catch (e) {if (current === citeGeneration.current) setCitationError(errorText(e));} - finally {if (current === citeGeneration.current) setCitationBusy(false);} + try { + const value = await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}/${route}?${query}`)); + if (current === citeGeneration.current) setEvidenceRead({scope: evidenceScope, value}); + } catch (e) { + if (current === citeGeneration.current) setEvidenceRead({ + scope: evidenceScope, failure: {message: errorText(e), route, selection}, + }); + } }; const readCitation = (c: AssessmentCitation, origin?: HTMLButtonElement) => readEvidence({source_id: c.source_id, first_line: String(c.first_line), last_line: String(c.last_line)}, "citation", origin); const readUsageTurn = (turnId: string, origin: HTMLButtonElement) => readEvidence({turn_id: turnId}, "usage-turn", origin); - const closeCitation = () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); requestAnimationFrame(() => sourceTrigger.current?.focus());}; - return {readOnly: currentSnapshot?.execution_enabled === false || (Boolean(readSourceUrl) && currentSnapshot?.execution_enabled !== true), sourceScope, readUsageComparison, citation, citationBusy, citationError, sourcePanel, readCitation, readUsageTurn, closeCitation, goalId, zh, location, snapshot: currentSnapshot, pack: readingPack, requestPack: pack, savedOriginal, readError, error, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, - close: () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id, true)}; + const retryCitation = failedCitation ? () => void readEvidence(failedCitation.selection, failedCitation.route) : undefined; + const closeCitation = () => { + const origin = sourceTrigger.current; + invalidateCitation(); + const closedAt = citeGeneration.current; + requestAnimationFrame(() => {if (closedAt === citeGeneration.current) origin?.focus();}); + }; + return {readOnly: currentSnapshot?.execution_enabled === false || (Boolean(readSourceUrl) && currentSnapshot?.execution_enabled !== true), sourceScope, readUsageComparison, citation, citationBusy, citationError, sourcePanel, readCitation, readUsageTurn, retryCitation, closeCitation, goalId, zh, location, snapshot: currentSnapshot, pack: readingPack, requestPack: pack, savedOriginal, readError, error, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, + close: () => {invalidateCitation(); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id, true)}; } type Controller = ReturnType; @@ -358,11 +387,11 @@ export function ResearchCaseReading({analysis: a, onHistory}: {analysis: Control if (!a.loading && !a.readError && p && !p.case_study && firstStudy) a.select(firstStudy); }, [a.loading, a.readError, p?.pack_id, Boolean(p?.case_study), firstStudy]); return
-
: null}
{p?.case_study ? : !a.loading && !a.readError ?

{a.zh ? "选择一份解读,阅读问题背景、策略差异、关键转折与证据。" : "Choose a study to read its background, approaches, turning points and evidence."}

: null} @@ -452,7 +481,11 @@ export function ResearchCitationPanel({analysis: a}: {analysis: Controller}) { return
{if (event.key === "Escape") {event.stopPropagation(); a.closeCitation();}}}>

{zh ? "引用原文" : "Cited original"}

{a.citationBusy ?

{zh ? "正在读取引用原文…" : "Reading cited source…"}

: null} - {a.citationError ?

{a.citationError}

: null} + {a.citationError ?
+ {a.citationError} + +
: null} {a.citation ? <>

{a.citation.artifact} · {zh ? "行" : "lines"} {a.citation.first_line}–{a.citation.last_line}

{a.citation.evidence_kind === "usage_counters" ?

{zh ? "计数事件:前一累计观测、本轮开始、末次计数及完成(如有)。仅显示计数字段;提示词、工具内容与账户信息不传入浏览器。" : "Counter events: preceding cumulative observation, Turn start, latest counters and completion if present. Only counter fields are shown; prompts, tool content and account metadata stay outside the browser."}

: null} : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index 6991d16bf6..83ecf891de 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -60,20 +60,22 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist const analysis = useResearchAnalysis(goalId, active, zh, readSourceUrl); const readCaseMode = () => { const query = new URLSearchParams(window.location.search); - if (query.get("research_goal") === goalId && query.has("research_reading")) return query.get("research_reading") === "case"; - return !preferHistory; + const reading = query.get("research_goal") === goalId ? query.get("research_reading") : null; + return {goalId, requested: reading === null ? null : reading === "case"}; }; - const [caseReading, setCaseReading] = useState(readCaseMode); + const [caseMode, setCaseReading] = useState(readCaseMode); + const requestedCase = caseMode.goalId === goalId ? caseMode.requested : null; + const caseReading = requestedCase ?? !preferHistory; useEffect(() => { const changed = () => setCaseReading(readCaseMode()); changed(); window.addEventListener("popstate", changed); return () => window.removeEventListener("popstate", changed); - }, [goalId, preferHistory, readSourceUrl]); + }, [goalId, readSourceUrl]); const setCaseMode = (reading: boolean) => { const url = new URL(window.location.href); url.searchParams.set("research_goal", goalId); url.searchParams.set("research_reading", reading ? "case" : "history"); - window.history.pushState({}, "", url); setCaseReading(reading); + window.history.pushState({}, "", url); setCaseReading({goalId, requested: reading}); }; const [read, setRead] = useState({events: [], total: null, complete: false}); const [originalDisplay, setOriginalDisplay] = useState(false); @@ -487,7 +489,12 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist const emptyWithAnalysis = emptyHistory && Boolean(analysis.pack?.result || analysis.pack?.native_outcomes); const emptyHistoryMessage = zh ? "这个 Goal 尚无研究历程" : "No research history for this Goal yet"; const hasCases = (analysis.snapshot?.catalog.packs ?? []).some(p => p.case_study_summary); - if (hasCases && caseReading) return setCaseMode(false)}/>; + // Keep an explicitly opened study in place during a read failure. A complete + // catalogue with no study still uses the ordinary history view. + const awaitingRequestedCase = requestedCase === true && (analysis.loading || Boolean(analysis.readError)); + if (caseReading && (hasCases || awaitingRequestedCase)) { + return setCaseMode(false)}/>; + } return
{hasCases ? : null} {progressiveOverview ? <> diff --git a/examples/personal-workspace-browser/research-real-benchmark.mjs b/examples/personal-workspace-browser/research-real-benchmark.mjs index 2da8f96646..cf1525a4b0 100644 --- a/examples/personal-workspace-browser/research-real-benchmark.mjs +++ b/examples/personal-workspace-browser/research-real-benchmark.mjs @@ -99,7 +99,7 @@ async function readOriginalNativeSnapshot(page, history, capture, language) { } await missing.getByRole("button", {name: /native-report-1/}).first().click(); const source = history.locator(".analysis-source"); - await source.locator(".analysis-error").waitFor(); + await source.getByRole("alert").waitFor(); assert.equal(await source.locator("pre").count(), 0, "An uncaptured source is not borrowed from a different snapshot"); await source.getByRole("button", {name: language === "zh" ? "关闭原文" : "Close source", exact: true}).click(); await progress.locator('[data-native-point="best_valid"] button').click(); @@ -215,9 +215,13 @@ async function readSavedGraphAnalysis(browser, collectCoverage) { for (const summary of await missingEpisode.locator(".analysis-judgments > details:not([open]) > summary").all()) await summary.click(); await missingEpisode.getByRole("button", {name: /native-report-1/}).first().click(); const unavailable = history.locator(".analysis-source"); - await unavailable.locator(".analysis-error").waitFor(); - assert.match(await unavailable.locator(".analysis-error").innerText(), /not captured/); + const sourceFailure = unavailable.getByRole("alert"); + await sourceFailure.waitFor(); + assert.match(await sourceFailure.innerText(), /not captured/); assert.equal(await unavailable.locator("pre").count(), 0, "Uncaptured citations do not substitute unrelated evidence"); + await unavailable.getByRole("button", { + name: language === "zh" ? "重试读取原文" : "Retry reading source", exact: true, + }).waitFor(); await unavailable.getByRole("button", {name: language === "zh" ? "关闭原文" : "Close source", exact: true}).click(); const citation = capture.analysis_citations[0].response; const cited = saved.result.assessments.find(row => row.citations.some(c => c.source_id === citation.source_id