diff --git a/apps/presentation/dashboard/docs/assets/research-record-export/after-1440.png b/apps/presentation/dashboard/docs/assets/research-record-export/after-1440.png new file mode 100644 index 0000000000..fc5b4c124d Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-export/after-1440.png differ diff --git a/apps/presentation/dashboard/docs/assets/research-record-export/after-390.png b/apps/presentation/dashboard/docs/assets/research-record-export/after-390.png new file mode 100644 index 0000000000..9b3a751876 Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-export/after-390.png differ diff --git a/apps/presentation/dashboard/docs/assets/research-record-export/before-1440.png b/apps/presentation/dashboard/docs/assets/research-record-export/before-1440.png new file mode 100644 index 0000000000..c81a34b76f Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-export/before-1440.png differ diff --git a/apps/presentation/dashboard/docs/assets/research-record-export/before-390.png b/apps/presentation/dashboard/docs/assets/research-record-export/before-390.png new file mode 100644 index 0000000000..acf6731c7c Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-export/before-390.png differ diff --git a/apps/presentation/dashboard/docs/assets/research-record-export/download-error.png b/apps/presentation/dashboard/docs/assets/research-record-export/download-error.png new file mode 100644 index 0000000000..bcf9a641ee Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-export/download-error.png differ diff --git a/apps/presentation/dashboard/docs/assets/research-record-export/stale.png b/apps/presentation/dashboard/docs/assets/research-record-export/stale.png new file mode 100644 index 0000000000..3525e64404 Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/research-record-export/stale.png differ 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 6f4a8c2f88..f60c846b50 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 @@ -518,7 +518,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re {value?.unknownKindCount ?
{zh ? "未识别类型保留于已读记录" : "Unrecognized types remain in loaded records"} · {value.unknownKindCount}
: null} : null} {value?.events.length ?{zh ? "无法开始下载,记录未改变。请重试下载。" : "Could not start the download. Records are unchanged; try downloading again."}
: null}{zh ? `匹配 ${matches.length} / ${events.length} 条已读记录` : `${matches.length} matches in ${events.length} loaded records`} {!complete ? (zh ? " · 历史不完整" : " · History incomplete") : ""} {busy ? (zh ? " · 正在读取历史" : " · Reading history") : stale ? (zh ? " · 刷新失败,显示上次读取的记录" : " · Refresh failed; showing previously read records") diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-export.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/research-record-export.test.mjs new file mode 100644 index 0000000000..02619798af --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-export.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {exportResearchRecords} from "./research-record-export.ts"; + +const record = (overrides = {}) => ({goal_id: "research", title: "Evidence", summary: "", blocked_reason: "", event_id: "event", + result_id: "finding", node_id: "", parent_id: "", from_node: "", to_node: "", run_id: "", + agent_id: "", supersedes: "", evidence_refs: [], status: "tentative", ...overrides}); +const snapshot = (events, overrides = {}) => JSON.parse(exportResearchRecords({goalId: "research", events, + query: "", status: "", complete: true, stale: false, exportedAt: "2026-01-01T00:00:00Z", ...overrides})); + +test("exports every match beyond display paging with explicit coverage and literal filters", () => { + const events = Array.from({length: 75}, (_, i) => record({event_id: `event-${i}`})); + const result = snapshot(events, {query: "EVIDENCE", status: "tentative"}); + assert.deepEqual(result.records, events); + assert.deepEqual(result.coverage, {loaded_records: 75, exported_records: 75, history_complete: true, refresh_failed: false}); + assert.deepEqual(result.filters, {query: "EVIDENCE", status: "tentative"}); + assert.equal(snapshot(events, {query: "Evidence.*"}).records.length, 0); +}); + +test("keeps exact original revisions, Unicode, multiline source and measurements without mutation", () => { + const old = Object.freeze(record({event_id: "old", status: "refuted", summary: "质量下降\nCost unknown", + evidence_refs: Object.freeze(["candidate:v1"]), confidence: null, measurements: [{metric_id: "accuracy", value: 0.5}]})); + const current = Object.freeze(record({event_id: "new", status: "confirmed", supersedes: "old", evidence_refs: ["candidate:v2"]})); + const events = Object.freeze([old, current]); + assert.deepEqual(snapshot(events, {query: "candidate:v1", status: "refuted"}).records, [old]); + assert.deepEqual(snapshot(events).records, [old, current]); +}); + +test("never equates loaded or stale records with current complete history", () => { + for (const complete of [false, true]) for (const stale of [false, true]) { + const result = snapshot([record()], {complete, stale}); + assert.equal(result.coverage.history_complete, complete); + assert.equal(result.coverage.refresh_failed, stale); + assert.equal(result.goal_id, "research"); + assert.equal(result.exported_at, "2026-01-01T00:00:00Z"); + } +}); + +test("rejects a mismatched Goal even when its records would be filtered out", () => { + assert.throws(() => snapshot([record({goal_id: "other"})], {query: "no match"}), /Goal mismatch/); + assert.throws(() => snapshot([], {goalId: ""}), /Goal mismatch/); +}); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-export.ts b/apps/presentation/dashboard/src/features/personal-workspace/research-record-export.ts new file mode 100644 index 0000000000..e55605b7f7 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-export.ts @@ -0,0 +1,26 @@ +import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts"; +import {searchResearchRecords} from "./research-record-search.ts"; + +/** A portable reading snapshot, not an import format or an acceptance receipt. + * Export canonical records, never translated/analysed display replacements. + * Source endpoints are deliberately absent: opening a file needs no connection. + */ +export function exportResearchRecords({goalId, events, query, status, complete, stale, exportedAt}: { + goalId: string; events: readonly ExploreHistoryEvent[]; query: string; status: string; + complete: boolean; stale: boolean; exportedAt: string; +}): string { + if (!goalId || events.some(event => event.goal_id !== goalId)) throw new Error("Research export Goal mismatch"); + const records = searchResearchRecords(events, query, status); + return JSON.stringify({ + goal_id: goalId, + exported_at: exportedAt, + filters: {query, status}, + coverage: { + loaded_records: events.length, + exported_records: records.length, + history_complete: complete, + refresh_failed: stale, + }, + records, + }, null, 2) + "\n"; +} diff --git a/examples/personal-workspace-browser/research-history.md b/examples/personal-workspace-browser/research-history.md index 01a1f271ba..0c26c7f6e5 100644 --- a/examples/personal-workspace-browser/research-history.md +++ b/examples/personal-workspace-browser/research-history.md @@ -121,12 +121,26 @@ 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. +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 +the reading limits: a partial or stale snapshot is not current complete evidence. +Translations and generated analysis do not replace original text. Source references, +revisions and native measurements stay with each record; no source endpoint or +credentials are added. Treat downloaded content with the same privacy as the +original research. The file is a reading snapshot, not an import or acceptance +receipt. Downloads do not launch research or change source state. While history is +loading or no records match, downloading is unavailable; retry after a download +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, interrupted reads and retry, -Goal isolation and desktop/narrow layouts without dispatching an Agent: +revision after 75 other findings, status filtering, full/filtered JSON downloads, +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-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 5a229f99bc..1892b13130 100644 --- a/examples/personal-workspace-browser/research-record-search.mjs +++ b/examples/personal-workspace-browser/research-record-search.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import {execFileSync} from "node:child_process"; -import {mkdtemp, rm} from "node:fs/promises"; +import {mkdtemp, readFile, rm} from "node:fs/promises"; import {tmpdir} from "node:os"; import {resolve} from "node:path"; import {resolveTestPython} from "../../scripts/test-python.mjs"; @@ -45,17 +45,29 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s owner = await startHistoryOwner([fixture, otherFixture]); page = await browser.newPage({viewport: {width: 1440, height: 900}}); if (collectCoverage) await page.coverage.startJSCoverage({resetOnNavigation: false}); - await page.addInitScript(() => localStorage.setItem("loopx-pw-locale", "en")); + await page.addInitScript(() => {if (!localStorage.getItem("loopx-pw-locale")) localStorage.setItem("loopx-pw-locale", "en");}); const url = `${owner.base}/chat/?goalId=synthetic-record-search&view=research&statusUrl=%2Fstatus.json`; await page.goto(url); const history = page.locator(".research-history"); await history.getByRole("button", {name: "Search research records", exact: true}).click(); const search = history.getByRole("searchbox", {name: "Search research records"}); const results = history.locator(".research-record-search"); + const downloadButton = results.getByRole("button", {name: "Download matches (JSON)", exact: true}); + const download = async () => { + const received = page.waitForEvent("download"); + await downloadButton.click(); + const file = await received; + assert.match(file.suggestedFilename(), /^report-research-synthetic-[a-z-]+-readback\.json$/); + return JSON.parse(await readFile(await file.path(), "utf8")); + }; await search.waitFor(); assert(await search.evaluate(element => element === document.activeElement), "The search entry moves keyboard focus to the input"); await page.waitForFunction(() => document.querySelector('.research-history')?.getAttribute('aria-busy') === 'false'); 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}); await search.fill("AMBER-V1 run:old"); await results.getByText("1 matches in 78 loaded records", {exact: true}).waitFor(); assert.equal(await results.locator("li").count(), 1, "Search reaches beyond both the display limit and HTTP page boundary"); @@ -66,9 +78,27 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s const status = results.getByRole("combobox", {name: "Recorded status"}); await status.selectOption("confirmed"); 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(); + 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"); + // A browser download interruption is recoverable without rereading or mutation. + await page.evaluate(() => { + const original = URL.createObjectURL; + URL.createObjectURL = (...args) => {URL.createObjectURL = original; throw new Error("Synthetic download interruption");}; + }); + await downloadButton.click(); + await results.getByRole("alert").getByText(/Could not start the download/).waitFor(); + await results.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "research-record-export-download-error.png")}); + assert.deepEqual((await download()).records, selected.records); + assert.equal(await results.getByRole("alert").count(), 0); for (const width of [1440, 390]) { await page.setViewportSize({width, height: 900}); await results.scrollIntoViewIfNeeded(); @@ -81,9 +111,15 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await results.getByText(/Refresh failed; showing previously read records/).waitFor(); assert.equal(await search.inputValue(), "AMBER-V1 run:old"); assert.equal(await results.locator("li").count(), 1); + const stale = await download(); + assert.equal(stale.coverage.refresh_failed, true); + 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(); + 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); @@ -98,6 +134,14 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await history.getByRole("button", {name: "Search research records", exact: true}).click(); await search.fill("amber-v1"); await readEmptyScope(page, results, "No matches in the loaded records; the remaining history is not verified."); + assert.equal(await downloadButton.isDisabled(), true); + await results.getByRole("button", {name: "Clear filters"}).click(); + const partial = await download(); + 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); + 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(); @@ -106,15 +150,39 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await results.getByText("1 matches in 1 loaded records", {exact: true}).waitFor(); assert.equal(await search.inputValue(), "", "Another Goal starts with its own search scope"); assert.doesNotMatch(await results.innerText(), /amber|Earlier negative/); + const other = await download(); + assert.equal(other.goal_id, "synthetic-other-research"); + assert.equal(other.records.length, 1); + 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(); + // A pending refresh cannot export an intermediate mixture of pages. + let release, observed; + const hold = new Promise(resolve => {release = resolve;}); + const seen = new Promise(resolve => {observed = resolve;}); + 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; + assert.equal(await downloadButton.isDisabled(), true); + release(); + await page.waitForFunction(() => document.querySelector('.research-history')?.getAttribute('aria-busy') === 'false'); + await page.unroute("**/api/chat/explore-results?*"); + assert.equal(await downloadButton.isEnabled(), true); const readback = await (await page.request.get(`${owner.base}/fixture/readback`)).json(); assert(readback.source_unchanged && readback.mutation_attempts === 0); - return {coverageEntries: collectCoverage ? await page.coverage.stopJSCoverage() : [], note: "packaged evidence search through native paged history; exact old revision, literal query, filters, interrupted reads/retry and narrow viewport"}; + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "zh-CN")); + await page.reload(); + await history.getByRole("button", {name: "搜索研究记录", exact: true}).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"}; } catch (error) { if (page && !page.isClosed()) await page.screenshot({path: resolve(outputDir, "research-record-search-failed.png")}); throw error;