Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
{value?.unknownKindCount ? <p className="research-history-note">{zh ? "未识别类型保留于已读记录" : "Unrecognized types remain in loaded records"} · {value.unknownKindCount}</p> : null}
</ContextHelp> : null}
{value?.events.length ? <details ref={records} className="research-history-all-records" open={recordsOpen} onToggle={event => setRecordsOpen(event.currentTarget.open)}><summary>{zh ? "全部已读记录" : "All loaded records"} · {value.events.length}</summary>
{recordsOpen ? <ResearchRecordSearch key={JSON.stringify([goalId, readSourceUrl])} events={history.canonical?.events ?? []}
{recordsOpen ? <ResearchRecordSearch key={JSON.stringify([goalId, readSourceUrl])} goalId={goalId} events={history.canonical?.events ?? []}
displayEvents={currentDisplay && !originalDisplay ? value.events : undefined}
renderBody={currentDisplay && !originalDisplay ? findingBody : undefined}
complete={read.complete} stale={Boolean(error || history.error)} busy={busy} zh={zh} stateLabel={stateLabel} timeLabel={timeLabel}/> : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ import {ContextHelp} from "./context-help";
import {useMemo, useState, type ReactNode} from "react";
import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts";
import {searchResearchRecords} from "./research-record-search";
import {exportResearchRecords} from "./research-record-export";
import {downloadReport} from "./report-download";

/** Local read controls over the existing validated history snapshot. */
export function ResearchRecordSearch({events, displayEvents, renderBody, complete, stale, busy, zh, stateLabel, timeLabel}: {
export function ResearchRecordSearch({goalId, events, displayEvents, renderBody, complete, stale, busy, zh, stateLabel, timeLabel}: {
goalId: string;
displayEvents?: readonly ExploreHistoryEvent[]; renderBody?: (event: ExploreHistoryEvent) => ReactNode;
events: readonly ExploreHistoryEvent[]; complete: boolean; stale: boolean; busy: boolean; zh: boolean;
stateLabel: (status: string) => string; timeLabel: (at: string) => string;
}) {
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [limit, setLimit] = useState(50);
const [downloadFailed, setDownloadFailed] = useState(false);
const matches = useMemo(() => searchResearchRecords(events, query, status), [events, query, status]);
const displayed = useMemo(() => new Map(displayEvents?.map(event => [event.event_id, event])), [displayEvents]);
const statuses = [...new Set(events.map(event => event.status).filter(Boolean))].sort();
Expand All @@ -28,7 +32,15 @@ export function ResearchRecordSearch({events, displayEvents, renderBody, complet
{[...new Set([...statuses, ...(status ? [status] : [])])].map(item => <option key={item} value={item}>{stateLabel(item)}</option>)}
</select>
{query || status ? <button type="button" onClick={reset}>{zh ? "清除筛选" : "Clear filters"}</button> : null}
<button type="button" disabled={busy || !matches.length} onClick={() => {
setDownloadFailed(false);
try {
const text = exportResearchRecords({goalId, events, query, status, complete, stale, exportedAt: new Date().toISOString()});
downloadReport(`research-${goalId}.json`, "", text);
} catch {setDownloadFailed(true);}
}}>{zh ? "下载匹配记录(JSON)" : "Download matches (JSON)"}</button>
</div>
{downloadFailed ? <p role="alert">{zh ? "无法开始下载,记录未改变。请重试下载。" : "Could not start the download. Records are unchanged; try downloading again."}</p> : null}
<p role="status">{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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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/);
});
Original file line number Diff line number Diff line change
@@ -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";
}
18 changes: 16 additions & 2 deletions examples/personal-workspace-browser/research-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
74 changes: 71 additions & 3 deletions examples/personal-workspace-browser/research-record-search.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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;
Expand Down
Loading