diff --git a/apps/presentation/dashboard/docs/assets/research-record-comparison/after-1440.png b/apps/presentation/dashboard/docs/assets/research-record-comparison/after-1440.png
new file mode 100644
index 0000000000..80f4ea8895
Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-comparison/after-1440.png differ
diff --git a/apps/presentation/dashboard/docs/assets/research-record-comparison/after-390.png b/apps/presentation/dashboard/docs/assets/research-record-comparison/after-390.png
new file mode 100644
index 0000000000..fd0a102be1
Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-comparison/after-390.png differ
diff --git a/apps/presentation/dashboard/docs/assets/research-record-comparison/before-1440.png b/apps/presentation/dashboard/docs/assets/research-record-comparison/before-1440.png
new file mode 100644
index 0000000000..339735c1e3
Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-comparison/before-1440.png differ
diff --git a/apps/presentation/dashboard/docs/assets/research-record-comparison/before-390.png b/apps/presentation/dashboard/docs/assets/research-record-comparison/before-390.png
new file mode 100644
index 0000000000..62aeee6c2d
Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-comparison/before-390.png differ
diff --git a/apps/presentation/dashboard/docs/assets/research-record-comparison/compare-1440.png b/apps/presentation/dashboard/docs/assets/research-record-comparison/compare-1440.png
new file mode 100644
index 0000000000..0b1bd4c577
Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-comparison/compare-1440.png differ
diff --git a/apps/presentation/dashboard/docs/assets/research-record-comparison/compare-390.png b/apps/presentation/dashboard/docs/assets/research-record-comparison/compare-390.png
new file mode 100644
index 0000000000..fa4a58302d
Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-comparison/compare-390.png differ
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css
index 58702688d6..d874ad9cf1 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css
@@ -279,3 +279,17 @@
.research-history-loading-chart { gap: 3px; padding-inline: 8px; }
}
@media (prefers-reduced-motion: reduce) { .research-history-loading-phases, .research-history-loading-chart { animation: none; } }
+
+.research-record-comparison { margin-block: 16px; border-block: 1px solid var(--pw-line); padding-block: 12px; }
+.research-record-comparison > label { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-block: 12px; }
+.research-record-comparison select { min-width: 0; max-width: 100%; min-height: 44px; font: inherit; color: var(--pw-text); background: var(--pw-surface, #fff); border: 1px solid var(--pw-line); border-radius: 6px; padding: 8px; }
+.research-comparison-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
+.research-comparison-pair > div { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
+.research-comparison-pair pre { white-space: pre-wrap; }
+.research-comparison-field { border-block-start: 1px solid var(--pw-line); padding-block: 12px; }
+.research-comparison-side, .research-comparison-missing { color: var(--pw-muted); }
+.research-comparison-side { display: none; }
+@media (max-width: 640px) {
+ .research-comparison-pair { grid-template-columns: 1fr; gap: 16px; }
+ .research-comparison-side { display: block; margin-block-end: 4px; }
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx b/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx
index fb1c2c84a5..2afc3b3ab5 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-browser.tsx
@@ -1,3 +1,4 @@
+import {ResearchRecordComparison} from "./research-record-comparison";
import {CircleHelp} from "lucide-react";
import {ContextHelp} from "./context-help";
import {useMemo, useState, type ReactNode} from "react";
@@ -66,6 +67,7 @@ export function ResearchRecordSearch({goalId, events, displayEvents, renderBody,
{event.from_node || event.to_node ? <>
{zh ? "显式关联" : "Recorded link"}{event.from_node} → {event.to_node}> : null}
{event.agent_id ? <>Agent ID{event.agent_id}> : null}
+
{event.evidence_refs.map(ref => {ref})}
;})}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-comparison.tsx b/apps/presentation/dashboard/src/features/personal-workspace/research-record-comparison.tsx
new file mode 100644
index 0000000000..c78d987eb2
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-comparison.tsx
@@ -0,0 +1,61 @@
+import {useState} from "react";
+import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts";
+import {changedResearchFields, researchComparisonFields, researchRecordVersions, type ResearchComparisonField} from "./research-record-versions.ts";
+import {ExploreMeasurements} from "./explore-measurements";
+
+const labels: Record = {
+ title: ["Title", "标题"], summary: ["Original text", "原文"], status: ["Recorded status", "记录状态"],
+ blocked_reason: ["Blocked reason", "受阻原因"], evidence_refs: ["Evidence references", "证据引用"],
+ measurements: ["Measurements", "测量"], confidence: ["Recorded confidence", "记录置信度"],
+ run_id: ["Run ID", "运行 ID"], agent_id: ["Agent ID", "Agent ID"], node_kind: ["Node kind", "节点类型"],
+ node_id: ["Node ID", "问题 ID"], parent_id: ["Source group", "来源分组"],
+ from_node: ["Link source", "关联起点"], to_node: ["Link target", "关联终点"], edge_type: ["Link type", "关联类型"],
+ supersedes: ["Supersedes", "取代记录"], execution_observation: ["Execution observation", "执行观察"],
+};
+
+/** Requested original-source detail; no analysis, acceptance or write path. */
+export function ResearchRecordComparison({events, selected, zh, stateLabel, timeLabel}: {
+ events: readonly ExploreHistoryEvent[]; selected: ExploreHistoryEvent; zh: boolean;
+ stateLabel: (status: string) => string; timeLabel: (at: string) => string;
+}) {
+ const versions = researchRecordVersions(events, selected);
+ const index = versions.findIndex(event => event.event_id === selected.event_id);
+ const alternatives = versions.filter(event => event.event_id !== selected.event_id);
+ const [chosen, setChosen] = useState("");
+ const [showAll, setShowAll] = useState(false);
+ const [open, setOpen] = useState(false);
+ const comparison = alternatives.find(event => event.event_id === chosen)
+ ?? versions[index - 1] ?? alternatives[0];
+ if (!comparison) return null;
+ const changed = changedResearchFields(selected, comparison);
+ const value = (event: ExploreHistoryEvent, field: ResearchComparisonField) => {
+ const raw = event[field];
+ if (raw === undefined || raw === null || raw === "" || (Array.isArray(raw) && !raw.length)) return {zh ? "未记录" : "Not recorded"};
+ if (field === "measurements") return ;
+ if (field === "status") return stateLabel(event.status);
+ if (Array.isArray(raw)) return {raw.map((item, i) => {String(item)} )}
;
+ if (typeof raw === "object") return {JSON.stringify(raw, null, 2)};
+ return String(raw);
+ };
+ return setOpen(event.currentTarget.open)}>
+ {zh ? "比较记录版本" : "Compare recorded versions"} · {versions.length}
+ {open ? <>
+
+ {zh ? "比较原始记录,不代表同条件评测或研究质量变化。" : "Compares original records, not matched evaluations or changes in research quality."}
+
+ {[selected, comparison].map((event, i) =>
{i === 0 ? (zh ? "所选记录" : "Selected record") : (zh ? "对照记录" : "Comparison record")}{timeLabel(event.recorded_at)}
{event.event_id} )}
+
+ {zh ? `${changed.length} 个字段不同` : `${changed.length} fields differ`}
+
+ {(showAll ? researchComparisonFields : changed).map(field =>
+ {labels[field][zh ? 1 : 0]}
+ {[selected, comparison].map((event, i) =>
+
{i === 0 ? (zh ? "所选记录" : "Selected record") : (zh ? "对照记录" : "Comparison record")}
+
{value(event, field)}
+
)}
+ )}
+ > : null}
+ ;
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-versions.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/research-record-versions.test.mjs
new file mode 100644
index 0000000000..e697414633
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-versions.test.mjs
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict";
+import {test} from "node:test";
+import {researchRecordVersions, changedResearchFields} from "./research-record-versions.ts";
+
+const record = (overrides = {}) => ({goal_id: "goal", event_kind: "finding", result_id: "result", event_id: "old",
+ recorded_at: "2026-01-02T00:00:00Z", title: "Observation", summary: "Negative evidence", status: "refuted",
+ evidence_refs: ["source:old"], confidence: null, ...overrides});
+
+test("versions retain source order and original evidence, independent of dates and search", () => {
+ const old = Object.freeze(record());
+ const next = Object.freeze(record({event_id: "new", recorded_at: "2026-01-01T00:00:00Z", summary: "Different scope", status: "confirmed"}));
+ assert.deepEqual(researchRecordVersions([old, next], next), [old, next]);
+ assert.deepEqual(changedResearchFields(old, next), ["summary", "status"]);
+});
+
+test("same title, supersedes and result id cannot cross Goal or kind boundaries", () => {
+ const old = record();
+ const next = record({event_id: "new"});
+ const foreign = record({event_id: "foreign", goal_id: "other", supersedes: "old"});
+ const node = record({event_id: "node", event_kind: "node"});
+ const unrelated = record({event_id: "unrelated", result_id: "different"});
+ assert.deepEqual(researchRecordVersions([foreign, old, node, unrelated, next], next), [old, next]);
+ assert.deepEqual(researchRecordVersions([next], next), [next], "Missing history does not synthesize a prior version");
+});
+
+test("comparison preserves zero, missing measurements, source changes and original whitespace", () => {
+ const old = record({measurements: [{metric: "score", value: null, unit: "points"}]});
+ const next = record({summary: "Negative evidence\n", confidence: 0, evidence_refs: [], measurements: [{metric: "score", value: 0, unit: "points"}]});
+ assert.deepEqual(changedResearchFields(old, next), ["summary", "evidence_refs", "measurements", "confidence"]);
+ assert.deepEqual(changedResearchFields(old, record({measurements: [{unit: "points", value: null, metric: "score"}]})), [], "Object serialization order is not a content change");
+ assert.deepEqual(changedResearchFields(old, record()), ["measurements"]);
+});
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-versions.ts b/apps/presentation/dashboard/src/features/personal-workspace/research-record-versions.ts
new file mode 100644
index 0000000000..c6aebc18aa
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-versions.ts
@@ -0,0 +1,24 @@
+import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts";
+
+/** Recorded versions of one result, in source order. Dates and supersedes text
+ * do not establish lineage across result kinds or Goals. Never use search hits
+ * as the source: a filtered-out revision remains available for comparison. */
+export function researchRecordVersions(events: readonly ExploreHistoryEvent[], selected: ExploreHistoryEvent) {
+ return events.filter(event => event.goal_id === selected.goal_id
+ && event.event_kind === selected.event_kind && event.result_id === selected.result_id);
+}
+
+// Local display fields, not a new evidence or lifecycle vocabulary.
+export const researchComparisonFields = ["title", "summary", "status", "blocked_reason", "evidence_refs",
+ "measurements", "confidence", "run_id", "agent_id", "node_kind", "node_id", "parent_id",
+ "from_node", "to_node", "edge_type", "supersedes", "execution_observation"] as const;
+export type ResearchComparisonField = typeof researchComparisonFields[number];
+
+const normalized = (value: unknown): unknown => {
+ if (Array.isArray(value)) return value.map(normalized);
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, normalized(item)]));
+ return value;
+};
+export function changedResearchFields(left: ExploreHistoryEvent, right: ExploreHistoryEvent) {
+ return researchComparisonFields.filter(field => JSON.stringify(normalized(left[field])) !== JSON.stringify(normalized(right[field])));
+}
diff --git a/examples/personal-workspace-browser/research-history.md b/examples/personal-workspace-browser/research-history.md
index 0c26c7f6e5..f3f81f8f85 100644
--- a/examples/personal-workspace-browser/research-history.md
+++ b/examples/personal-workspace-browser/research-history.md
@@ -121,6 +121,16 @@ historical revisions. Search covers the loaded snapshot before display paginatio
incomplete reads and failed refreshes stay visible. Clear filters to browse again.
Queries are local to this open reader and reset when it closes or the Goal changes.
+For a result with more than one loaded version, expand **Compare recorded versions**.
+Choose another version of that same result to read changed original text, recorded
+status, evidence references and measurements together. **Include unchanged fields**
+restores shared context. Alternatives come from the loaded history, even when the
+search or status filter hides them. Missing values remain unrecorded; zero remains
+zero. The comparison preserves original record IDs and source order, and does not
+claim matched evaluations, improved quality or acceptance. Partial/stale history
+retains the reader's coverage warning; refresh to retry. Closing and reopening the
+comparison does not change either source record.
+
Choose **Download matches (JSON)** to keep all matching original records, including
those not yet expanded on screen. The file includes the Goal, export time, literal
filters and loaded/exported counts. `history_complete` and `refresh_failed` preserve
@@ -135,11 +145,12 @@ failure or refresh the history after a read failure.
The synthetic packaged regression exercises native paged history, an old negative
revision after 75 other findings, status filtering, full/filtered JSON downloads,
-partial/stale coverage, interrupted reads/downloads and retry, Goal isolation and
+original-version comparison (including unmeasured versus zero), partial/stale
+coverage, interrupted reads/downloads and retry, Goal isolation and
desktop/narrow layouts without dispatching an Agent:
```sh
-node --experimental-strip-types --test apps/presentation/dashboard/src/features/personal-workspace/research-record-search.test.mjs
+node --experimental-strip-types --test apps/presentation/dashboard/src/features/personal-workspace/research-record-search.test.mjs apps/presentation/dashboard/src/features/personal-workspace/research-record-versions.test.mjs
node --experimental-strip-types --test apps/presentation/dashboard/src/features/personal-workspace/research-record-export.test.mjs
LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PERSONAL_WORKSPACE_SCENARIO=research-record-search node examples/personal-workspace-browser-smoke.mjs
```
diff --git a/examples/personal-workspace-browser/research-record-search.mjs b/examples/personal-workspace-browser/research-record-search.mjs
index 1892b13130..671aef5deb 100644
--- a/examples/personal-workspace-browser/research-record-search.mjs
+++ b/examples/personal-workspace-browser/research-record-search.mjs
@@ -31,12 +31,16 @@ for index in range(75):
events.append(build_explore_finding_event(goal_id=goal,node_id='question',finding_id=f'finding-{index}',
title=f'Candidate {index}',summary='Original observation; complete costs remain unknown.',
status='tentative',recorded_at='2026-01-01T01:00:00Z'))
+measurement=dict(metric='Synthetic score', unit='points', higher_is_better=True, comparison_scope='synthetic:fixed', input_revision='input:v1', evaluation_ref='method:v1', candidate_ref='candidate:amber', evidence_ref='result:amber')
events.append(build_explore_finding_event(goal_id=goal,node_id='question',finding_id='retained-finding',
title='Earlier negative observation',summary='质量下降 — original negative evidence for candidate amber.',
- status='refuted',evidence_refs=['candidate:amber-v1'],run_id='run:old',recorded_at='2026-01-02T00:00:00Z'))
+ measurements=[dict(measurement,value=None,evidence_ref='candidate:amber-v1')],status='refuted',evidence_refs=['candidate:amber-v1'],run_id='run:old',recorded_at='2026-01-02T00:00:00Z'))
events.append(build_explore_finding_event(goal_id=goal,node_id='question',finding_id='retained-finding',
title='Later observation',summary='A later scope does not rewrite the earlier result.',
- status='confirmed',evidence_refs=['candidate:amber-v2'],run_id='run:new',recorded_at='2026-01-03T00:00:00Z'))
+ measurements=[dict(measurement,value=0,evidence_ref='candidate:amber-v2')],status='confirmed',evidence_refs=['candidate:amber-v2'],run_id='run:new',recorded_at='2026-01-03T00:00:00Z'))
+events.append(build_explore_finding_event(goal_id=goal,node_id='question',finding_id='retained-finding',
+ title='Unmeasured follow-up',summary='A new question remains open; earlier evidence is retained.',
+ status='tentative',evidence_refs=['candidate:amber-v3'],run_id='run:follow-up',recorded_at='2026-01-04T00:00:00Z'))
Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Synthetic evidence search','events':events},ensure_ascii=False))
other='synthetic-other-research'
Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other synthetic research','events':[
@@ -66,12 +70,12 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s
assert.equal(await results.locator("li").count(), 50);
const all = await download();
assert.equal(all.goal_id, "synthetic-record-search");
- assert.equal(all.records.length, 78, "Download includes matches beyond both HTTP and display paging");
- assert.deepEqual(all.coverage, {loaded_records: 78, exported_records: 78, history_complete: true, refresh_failed: false});
+ assert.equal(all.records.length, 79, "Download includes matches beyond both HTTP and display paging");
+ assert.deepEqual(all.coverage, {loaded_records: 79, exported_records: 79, history_complete: true, refresh_failed: false});
await search.fill("AMBER-V1 run:old");
- await results.getByText("1 matches in 78 loaded records", {exact: true}).waitFor();
+ await results.getByText("1 matches in 79 loaded records", {exact: true}).waitFor();
assert.equal(await results.locator("li").count(), 1, "Search reaches beyond both the display limit and HTTP page boundary");
- await results.locator("li summary").click();
+ await results.locator(".research-history-records > li > details > summary").click();
assert.match(await results.locator("li").innerText(), /质量下降.*original negative evidence/);
assert.match(await results.locator("li").innerText(), /candidate:amber-v1/);
assert.doesNotMatch(await results.locator("li").innerText(), /amber-v2|Later observation/);
@@ -80,14 +84,48 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s
await readEmptyScope(page, results, "No matching records. Try another term or clear the filters.");
assert.equal(await downloadButton.isDisabled(), true);
await status.selectOption("refuted");
- await results.getByText("1 matches in 78 loaded records", {exact: true}).waitFor();
- await results.locator("li summary").click();
+ await results.getByText("1 matches in 79 loaded records", {exact: true}).waitFor();
+ await results.locator(".research-history-records > li > details > summary").click();
const selected = await download();
assert.deepEqual(selected.filters, {query: "AMBER-V1 run:old", status: "refuted"});
assert.equal(selected.records.length, 1);
assert.equal(selected.records[0].summary, "质量下降 — original negative evidence for candidate amber.");
assert.deepEqual(selected.records[0].evidence_refs, ["candidate:amber-v1"]);
assert.equal(selected.records[0].run_id, "run:old");
+ // Compare the original negative record with a revision excluded by both filters.
+ const comparison = results.locator(".research-record-comparison");
+ const comparisonSummary = comparison.locator(":scope > summary");
+ await comparisonSummary.focus();
+ await page.keyboard.press("Enter");
+ await comparison.getByRole("combobox", {name: "Compare with"}).waitFor();
+ assert.match(await comparison.innerText(), /Later observation/);
+ assert.match(await comparison.innerText(), /质量下降/);
+ assert.match(await comparison.innerText(), /candidate:amber-v1/);
+ assert.match(await comparison.innerText(), /candidate:amber-v2/);
+ assert.deepEqual(await comparison.locator(".explore-measurement-reading strong").allTextContents(), ["Not measured", "0"]);
+ const versionSelect = comparison.getByRole("combobox", {name: "Compare with"});
+ await versionSelect.selectOption({index: 1});
+ await comparison.getByText("Unmeasured follow-up", {exact: true}).waitFor();
+ assert.match(await comparison.innerText(), /candidate:amber-v3/);
+ assert.doesNotMatch(await comparison.innerText(), /candidate:amber-v2/);
+ await versionSelect.selectOption({index: 0});
+ await comparison.getByText("Later observation", {exact: true}).waitFor();
+ const includeUnchanged = comparison.getByRole("checkbox", {name: "Include unchanged fields"});
+ await includeUnchanged.check();
+ await comparison.getByRole("heading", {name: "Node ID", exact: true}).waitFor();
+ await includeUnchanged.uncheck();
+ assert.equal(await comparison.getByRole("heading", {name: "Node ID", exact: true}).count(), 0);
+ for (const width of [1440, 390]) {
+ await page.setViewportSize({width, height: 900});
+ await comparison.scrollIntoViewIfNeeded();
+ assert(await comparison.evaluate(element => element.scrollWidth <= element.clientWidth + 1), "Comparison fits without horizontal overflow");
+ await page.screenshot({path: resolve(outputDir, `research-record-comparison-${width}.png`), animations: "disabled"});
+ }
+ await comparisonSummary.click();
+ await comparisonSummary.click();
+ await comparison.getByRole("combobox", {name: "Compare with"}).waitFor();
+ assert.match(await comparison.innerText(), /candidate:amber-v2/, "Repeated opening preserves the original pair");
+ await comparisonSummary.click();
// A browser download interruption is recoverable without rereading or mutation.
await page.evaluate(() => {
const original = URL.createObjectURL;
@@ -113,16 +151,20 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s
assert.equal(await results.locator("li").count(), 1);
const stale = await download();
assert.equal(stale.coverage.refresh_failed, true);
+ await comparisonSummary.click();
+ await comparison.getByRole("combobox", {name: "Compare with"}).waitFor();
+ assert.match(await comparison.innerText(), /candidate:amber-v2/);
+ await comparisonSummary.click();
assert.deepEqual(stale.records, selected.records);
await results.scrollIntoViewIfNeeded();
await page.screenshot({path: resolve(outputDir, "research-record-export-stale.png")});
await page.unroute("**/api/chat/explore-results?*");
await history.getByRole("button", {name: "Refresh history", exact: true}).click();
- await results.getByText("1 matches in 78 loaded records", {exact: true}).waitFor();
+ await results.getByText("1 matches in 79 loaded records", {exact: true}).waitFor();
assert.equal((await download()).coverage.refresh_failed, false);
await results.getByRole("button", {name: "Clear filters"}).click();
await results.getByRole("button", {name: /Show more records/}).click();
- assert.equal(await results.locator("li").count(), 78);
+ assert.equal(await results.locator("li").count(), 79);
await search.fill("no such evidence");
await readEmptyScope(page, results, "No matching records. Try another term or clear the filters.");
await results.getByRole("button", {name: "Clear filters"}).click();
@@ -140,11 +182,11 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s
assert.equal(partial.coverage.history_complete, false);
assert.equal(partial.coverage.refresh_failed, true);
assert.equal(partial.records.length, partial.coverage.loaded_records);
- assert(partial.records.length > 0 && partial.records.length < 78);
+ assert(partial.records.length > 0 && partial.records.length < 79);
await search.fill("amber-v1");
await page.unroute("**/api/chat/explore-results?*");
await history.getByRole("button", {name: "Refresh history", exact: true}).click();
- await results.getByText("1 matches in 78 loaded records", {exact: true}).waitFor();
+ await results.getByText("1 matches in 79 loaded records", {exact: true}).waitFor();
await page.goto(url.replace("synthetic-record-search", "synthetic-other-research"));
await history.getByRole("button", {name: "Search research records", exact: true}).click();
await results.getByText("1 matches in 1 loaded records", {exact: true}).waitFor();
@@ -153,13 +195,14 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s
const other = await download();
assert.equal(other.goal_id, "synthetic-other-research");
assert.equal(other.records.length, 1);
+ assert.equal(await comparison.count(), 0, "A single-version Goal offers no inferred comparison");
assert(other.records.every(record => record.goal_id === other.goal_id));
await search.fill("amber-v1");
await readEmptyScope(page, results, "No matching records. Try another term or clear the filters.");
await page.goto(url);
await history.getByRole("button", {name: "Search research records", exact: true}).click();
await search.fill("amber-v1");
- await results.getByText("1 matches in 78 loaded records", {exact: true}).waitFor();
+ await results.getByText("1 matches in 79 loaded records", {exact: true}).waitFor();
// A pending refresh cannot export an intermediate mixture of pages.
let release, observed;
const hold = new Promise(resolve => {release = resolve;});
@@ -167,6 +210,7 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s
await page.route("**/api/chat/explore-results?*", async route => {observed(); await hold; await route.continue();});
await history.getByRole("button", {name: "Refresh history", exact: true}).click();
await seen;
+ await page.waitForFunction(() => [...document.querySelectorAll(".research-record-filters button")].some(button => button.textContent.includes("Download matches") && button.disabled));
assert.equal(await downloadButton.isDisabled(), true);
release();
await page.waitForFunction(() => document.querySelector('.research-history')?.getAttribute('aria-busy') === 'false');
@@ -177,12 +221,18 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s
await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "zh-CN"));
await page.reload();
await history.getByRole("button", {name: "搜索研究记录", exact: true}).click();
+ await history.getByRole("searchbox", {name: "搜索研究记录"}).fill("amber-v1");
+ await results.locator("li > details > summary").click();
+ await results.getByText(/比较记录版本/).click();
+ await results.getByRole("combobox", {name: "比较对象"}).waitFor();
+ assert.match(await comparison.innerText(), /未测量/);
+ await results.getByRole("button", {name: "清除筛选"}).click();
const chineseDownload = results.getByRole("button", {name: "下载匹配记录(JSON)", exact: true});
const received = page.waitForEvent("download");
await chineseDownload.focus();
await page.keyboard.press("Enter");
- assert.equal(JSON.parse(await readFile(await (await received).path(), "utf8")).records.length, 78);
- return {coverageEntries: collectCoverage ? await page.coverage.stopJSCoverage() : [], note: "packaged evidence search/export through native paged history; full and filtered downloads, original revisions, coverage/staleness, download/read recovery, Goal isolation and narrow viewport"};
+ assert.equal(JSON.parse(await readFile(await (await received).path(), "utf8")).records.length, 79);
+ return {coverageEntries: collectCoverage ? await page.coverage.stopJSCoverage() : [], note: "packaged evidence search/export through native paged history; full and filtered downloads, original revisions, coverage/staleness, original revision comparison, download/read recovery, Goal isolation and narrow viewport"};
} catch (error) {
if (page && !page.isClosed()) await page.screenshot({path: resolve(outputDir, "research-record-search-failed.png")});
throw error;