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 5b3c5896bb..e3beb3fd7b 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 @@ -130,6 +130,10 @@ .research-record-entry > summary::-webkit-details-marker { display: none; } .research-record-entry > summary::before { content: "▸"; position: absolute; inset-inline-start: 0; } .research-record-entry[open] > summary::before { content: "▾"; } +.research-record-group-history { list-style: none; margin: 0; padding-inline-start: 18px; } +.research-record-group-history > li { padding-block: 10px; border-top: 1px solid var(--pw-line); } +.research-record-group-history h4 { margin: 0; overflow-wrap: anywhere; } +.research-record-group-history > li > small { color: var(--pw-muted); } .research-record-sources { margin-block: 12px; } .research-record-search p { white-space: pre-wrap; line-height: var(--leading-prose); overflow-wrap: anywhere; } .research-record-search code { display: block; overflow-wrap: anywhere; font-size: var(--text-body); } 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 2c0b430c48..7d15617961 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 @@ -11,7 +11,7 @@ import {requestJson} from "../../data/chat"; import {sourceReadApiUrl} from "../../data/local-status-query"; import {ContextHelp} from "./context-help"; import type {WorkspaceGoal} from "./personal-workspace-model"; -import {groupUnlinkedHistoryQuestions, layoutExploreHistory, parseExploreHistoryPage, projectExploreHistory, type ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.js"; +import {groupUnlinkedHistoryQuestions, layoutExploreHistory, mergeExploreHistoryEvents, parseExploreHistoryPage, projectExploreHistory, type ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.js"; import "./goal-research-history.css"; import {ExploreMeasurements, formatExploreMeasurementValue} from "./explore-measurements"; import {projectTrajectoryDisplay, trajectoryDisplaySnapshotText} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_display.js"; @@ -196,7 +196,10 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist const controller = new AbortController(); setBusy(true); setError(""); setLoaded(0); void (async () => { - const events: ExploreHistoryEvent[] = []; + const events = new Map(); + // The server counts stored rows, including identical persisted replays. + // Keep their order for snapshot identity, independently of display deduplication. + let sourceRows: ExploreHistoryEvent[] | null = []; const cursors = new Set(); let total = -1; try { @@ -205,21 +208,29 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist const query = new URLSearchParams({goal_id: goalId, view: "history", ...(cursor ? {cursor} : {})}); const page = parseExploreHistoryPage(await requestJson(sourceReadApiUrl(readSourceUrl, `/api/chat/explore-results?${query}`), {signal: controller.signal}), originalGoalId); if (total !== -1 && total !== page.total) throw new Error("Explore history snapshot changed"); - total = page.total; events.push(...page.items); - if (events.length > total || (page.next_cursor && (!page.items.length || cursors.has(page.next_cursor)))) throw new Error("Invalid Explore history pagination"); - if (current) setLoaded(events.length); + total = page.total; + mergeExploreHistoryEvents(events, page.items, originalGoalId); + if (events.size > total || (page.next_cursor && (!page.items.length || cursors.has(page.next_cursor)))) throw new Error("Invalid Explore history pagination"); + if (sourceRows) { + if (sourceRows.length + page.items.length <= total) sourceRows.push(...page.items); + else sourceRows = null; + } + if (current) setLoaded(sourceRows?.length ?? events.size); cursor = page.next_cursor ?? ""; if (cursor) cursors.add(cursor); // The owning HTTP snapshot bounds size; never silently cap history here. } while (cursor && current); if (current) { - if (cursor || events.length !== total) throw new Error("Explore history coverage mismatch"); - projectExploreHistory(events, originalGoalId); // Verify replay identities before replacing a valid snapshot. - setRead({events, total, complete: true}); + // An overfull delivery is usable only if the excess consists entirely + // of exact replays and we still received every reported row as a distinct event. + if (cursor || (sourceRows ? sourceRows.length !== total : events.size !== total)) { + throw new Error("Explore history coverage mismatch"); + } + setRead({events: sourceRows ?? [...events.values()], total, complete: true}); } } catch (failure) { if (current) { - if (events.length) setRead(previous => previous.complete ? previous : {events, total: Math.max(total, events.length), complete: false}); + if (events.size) setRead(previous => previous.complete ? previous : {events: [...events.values()], total: Math.max(total, events.size), complete: false}); setError(failure instanceof Error ? failure.message : String(failure)); } } finally {if (current) setBusy(false);} @@ -468,6 +479,8 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist }; const readFailure = error || history.error; const emptyHistory = !busy && !readFailure && read.complete && read.total === 0; + const recordUnit = read.complete && history.canonical && read.events.length > history.canonical.events.length + ? (zh ? "条原始记录" : "source rows") : read.total === 1 ? copy.record : copy.records; const emptySourceHistory = preferHistory && emptyHistory; const emptyWithAnalysis = emptyHistory && Boolean(analysis.pack?.result || analysis.pack?.native_outcomes); const emptyHistoryMessage = zh ? "这个 Goal 尚无研究历程" : "No research history for this Goal yet"; @@ -513,7 +526,7 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist

{zh ? "已保存的分析、原生测量与 Explore 历程分别读取。这个 Goal 没有已记录的 Explore 历程;已保存的结果仍可在下方阅读。" : "Saved analysis, native measurements and Explore history are read separately. This Goal has no recorded Explore history; its saved results remain readable below."}

: <>{copy.time}{value?.startAt ? <> · — : null} · {busy ? `${copy.reading} ${loaded}${read.total ? ` / ${read.total}` : ""}` : read.total === null ? (zh ? "来源尚未读到" : "Source has not been read") - : `${read.events.length} / ${read.total} ${read.total === 1 ? copy.record : copy.records}${read.complete ? "" : ` · ${copy.partial}`}`}} + : `${read.events.length} / ${read.total} ${recordUnit}${read.complete ? "" : ` · ${copy.partial}`}`}} {!readingSelection ? displayControl : null} : null} {readFailure ?
@@ -647,6 +660,7 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist : null} {value?.events.length ?
setRecordsOpen(event.currentTarget.open)}>{zh ? "全部已读记录" : "All loaded records"} · {value.events.length} {recordsOpen ? ReactNode; displaySections?: ResearchRecordDisplay["sections"]; renderSummary?: (event: ExploreHistoryEvent) => ReactNode; - events: readonly ExploreHistoryEvent[]; complete: boolean; stale: boolean; busy: boolean; zh: boolean; + events: readonly ExploreHistoryEvent[]; nodes: readonly ExploreHistoryNode[]; complete: boolean; stale: boolean; busy: boolean; zh: boolean; stateLabel: (status: string) => string; timeLabel: (at: string) => string; }) { const [query, setQuery] = useState(""); @@ -25,10 +25,36 @@ export function ResearchRecordSearch({goalId, events, displayEvents, displaySect const displayed = useMemo(() => new Map(displayEvents?.map(event => [event.event_id, event])), [displayEvents]); const display = useMemo(() => ({events: displayed, sections: displaySections}), [displayed, displaySections]); const matches = useMemo(() => searchResearchRecords(events, query, status, display), [events, query, status, display]); + const groups = useMemo(() => researchRecordGroups(matches, complete ? nodes : []), [matches, nodes, complete]); const statuses = [...new Set(events.map(event => event.status).filter(Boolean))].sort(); - const countLabel = zh ? `匹配 ${matches.length} / ${events.length} 条已读记录` : `${matches.length} matches in ${events.length} loaded records`; + const groupCount = zh ? `${groups.length} 个条目` : `${groups.length} ${groups.length === 1 ? "entry" : "entries"}`; + const countLabel = zh ? `匹配 ${matches.length} / ${events.length} 条已读记录 · ${groupCount}` : `${matches.length} matches in ${events.length} loaded records · ${groupCount}`; const readState = busy ? (zh ? "读取中" : "Reading") : stale ? (zh ? "刷新失败" : "Refresh failed") : ""; const reset = () => {setQuery(""); setStatus(""); setLimit(50);}; + const originalContent = (event: ExploreHistoryEvent) => { + const shown = displayed.get(event.event_id) ?? event; + return <> + {renderBody ? renderBody(shown) : event.summary ?

{event.summary}

: null} + {shown.blocked_reason ?

{shown.blocked_reason}

: null} + {renderBody ?
{zh ? "完整原文" : "Full original text"} +

{event.title}

{event.summary}

{event.blocked_reason ?

{event.blocked_reason}

: null} +
: null} +
{zh ? "来源与标识" : "Sources and identity"} +
{zh ? "记录 ID" : "Record ID"}
{event.event_id}
+
{zh ? "结果 ID" : "Result ID"}
{event.result_id}
+ {event.run_id ? <>
{zh ? "运行 ID" : "Run ID"}
{event.run_id}
: null} + {event.supersedes ? <>
{zh ? "取代记录" : "Supersedes"}
{event.supersedes}
: null} + {event.node_id ? <>
{zh ? "问题 ID" : "Node ID"}
{event.node_id}
: null} + {event.parent_id ? <>
{zh ? "来源分组" : "Source group"}
{event.parent_id}
: null} + {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})} +
+ + ; + }; return
{zh ? "无法开始下载,记录未改变。请重试下载。" : "Could not start the download. Records are unchanged; try downloading again."}

: null}
- {matches.length} / {events.length} {zh ? "条记录" : "records"}{readState ? ` · ${readState}` : ""} + {groupCount} · {matches.length} / {events.length} {zh ? "条原始记录" : "original records"}{readState ? ` · ${readState}` : ""}

{countLabel}{zh ? "。" : "."}

+

{zh ? "明确关联的记录可在同一条目内展开。筛选和下载按原始记录匹配。" : "Open an entry to read its linked records. Filters and downloads match the original records."}

{complete ? (zh ? "已搜索完整载入的历史。" : "Search covers the complete loaded history.") : (zh ? "历史不完整;其余记录尚未核验。" : "History is incomplete; remaining records have not been verified.")}

{stale ?

{zh ? "刷新失败,显示上次读取的记录。" : "Refresh failed; showing previously read records."}

: null} {!matches.length ?

{complete && !stale && !busy @@ -70,31 +97,50 @@ export function ResearchRecordSearch({goalId, events, displayEvents, displaySect : (zh ? "已读部分没有匹配记录;其余历史尚未核验。" : "No matches in the loaded records; the remaining history is not verified.")}

: null}
-
    {matches.slice(0, limit).map(event => { +
      {groups.slice(0, limit).map(group => { + const event = group.events[0]!; const shown = displayed.get(event.event_id) ?? event; - return
    1. -
      {shown.title}{timeLabel(event.recorded_at)} · {stateLabel(event.status)} - {renderBody ? renderBody(shown) : event.summary ?

      {event.summary}

      : null} - {shown.blocked_reason ?

      {shown.blocked_reason}

      : null} - {renderBody ?
      {zh ? "完整原文" : "Full original text"} -

      {event.title}

      {event.summary}

      {event.blocked_reason ?

      {event.blocked_reason}

      : null} -
      : null} -
      {zh ? "来源与标识" : "Sources and identity"} -
      {zh ? "记录 ID" : "Record ID"}
      {event.event_id}
      -
      {zh ? "结果 ID" : "Result ID"}
      {event.result_id}
      - {event.run_id ? <>
      {zh ? "运行 ID" : "Run ID"}
      {event.run_id}
      : null} - {event.supersedes ? <>
      {zh ? "取代记录" : "Supersedes"}
      {event.supersedes}
      : null} - {event.node_id ? <>
      {zh ? "问题 ID" : "Node ID"}
      {event.node_id}
      : null} - {event.parent_id ? <>
      {zh ? "来源分组" : "Source group"}
      {event.parent_id}
      : null} - {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})} -
      - -
      + return
    2. + {group.node && group.events.length > 1 + ? + :
      + {shown.title}{timeLabel(event.recorded_at)} · {stateLabel(event.status)} + {originalContent(event)} +
      }
    3. ;})}
    - {limit < matches.length ? : null} + {limit < groups.length ? : null}
; } + +function GroupedResearchRecord({title, events, displayed, renderRecord, zh, stateLabel, timeLabel}: { + title: string; events: readonly ExploreHistoryEvent[]; displayed: ReadonlyMap; + renderRecord: (event: ExploreHistoryEvent) => ReactNode; zh: boolean; + stateLabel: (status: string) => string; timeLabel: (at: string) => string; +}) { + const [open, setOpen] = useState(false); + const findings = events.filter(event => event.event_kind === "finding"); + const lifecycle = events.filter(event => event.event_kind === "node").at(-1)?.status; + const statuses = [...new Set(findings.map(event => event.status))].filter(Boolean).map(stateLabel); + if (lifecycle) statuses.push(stateLabel(lifecycle)); + const summary = [timeLabel(events.at(-1)!.recorded_at), ...statuses, zh ? `${events.length} 条记录` : `${events.length} records`]; + return
setOpen(event.currentTarget.open)} + onKeyDown={event => { + if (event.key !== "Escape" || !event.currentTarget.open || !(event.target instanceof Element) + || event.target.closest("details") !== event.currentTarget) return; + event.preventDefault(); event.stopPropagation(); + event.currentTarget.open = false; + event.currentTarget.querySelector("summary")?.focus(); + }}> + {title}{summary.join(" · ")} + {open ?
    {events.map(event => { + const eventTitle = (displayed.get(event.event_id) ?? event).title; + const kind = event.event_kind === "finding" ? (zh ? "结论记录" : "Finding") : (zh ? "研究项记录" : "Research item"); + return
  1. +

    {eventTitle === title ? kind : eventTitle}

    + {timeLabel(event.recorded_at)} · {stateLabel(event.status)} + {renderRecord(event)} +
  2. ; + })}
: null} +
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-record-search.ts b/apps/presentation/dashboard/src/features/personal-workspace/research-record-search.ts index 43452b6e71..ce3a92682c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-record-search.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-record-search.ts @@ -1,4 +1,4 @@ -import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts"; +import type {ExploreHistoryEvent, ExploreHistoryNode} from "../../../../../../loopx/control_plane/presentation/explore_history.ts"; import type {ExploreFindingSections} from "../../../../../../loopx/control_plane/presentation/explore_timeline.ts"; /** The history reader supplies its already qualified, current-language projection. @@ -8,6 +8,29 @@ export type ResearchRecordDisplay = { sections?: ReadonlyMap; }; +/** A node and its sole linked finding may describe the same research entry. + * Multiple finding identities or conflicting runs leave the records separate. + * Keep original records, including revisions, in their recorded order. */ +export function researchRecordGroups(matches: readonly ExploreHistoryEvent[], nodes: readonly ExploreHistoryNode[]) { + const linked = new Map(); + for (const node of nodes) { + const members = [...node.revisions, ...node.findings]; + const findingIds = new Set(members.filter(event => event.event_kind === "finding").map(event => event.result_id)); + const runs = new Set(members.map(event => event.run_id).filter(Boolean)); + if (findingIds.size !== 1 || runs.size > 1) continue; + for (const event of members) linked.set(event.event_id, node); + } + const groups = new Map(); + for (const event of matches) { + const node = linked.get(event.event_id); + const id = node ? `node:${node.id}` : `event:${event.event_id}`; + const group = groups.get(id); + if (group) group.events.push(event); + else groups.set(id, {id, node, events: [event]}); + } + return [...groups.values()]; +} + /** Search source records, including superseded revisions, before display paging. * Optional display text broadens matching; results remain the original records. * Tokens are literal, case-insensitive AND terms; no inferred evidence or status. */ diff --git a/docs/development/design.md b/docs/development/design.md index 6b81f3e987..6d50d68d3c 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -222,6 +222,11 @@ and expose earlier records in place. Counts and date spans describe the selected time range. Keep negative and tentative record counts visible when older rows are folded, and open each row's exact finding in the existing reader. +In the loaded-record list, a node and its unambiguously linked finding share an +entry after a complete read. Show the finding status beside the node's lifecycle +status and retain each event, time and source when opened. Keep raw record +counts, search and export independent of display grouping. + Reading a question from a day or nearby-point group keeps that collection as navigation context. Offer a compact question selector and previous/next controls inside the reader, rather than requiring repeated trips through a long list. diff --git a/examples/personal-workspace-browser/research-display-language.mjs b/examples/personal-workspace-browser/research-display-language.mjs index fc26ba394e..e5fb3eaaf7 100644 --- a/examples/personal-workspace-browser/research-display-language.mjs +++ b/examples/personal-workspace-browser/research-display-language.mjs @@ -243,12 +243,15 @@ export const researchDisplayLanguageScenario = { assert.equal(await search.inputValue(), capture.events[0].title, "Language display toggles retain the original query"); await history.getByRole("button", {name: "查看译文", exact: true}).click(); await allRecords.getByRole("button", {name: "清除筛选", exact: true}).click(); - assert.equal(await allRecords.locator(".research-history-records > li").count(), capture.events.length); + assert.equal(await allRecords.locator(".research-history-records > li").count(), 20, "One entry per unambiguously linked synthetic question"); await search.fill("finding-0"); - assert.equal(await allRecords.locator(".research-history-records > li").count(), 2); + assert.equal(await allRecords.locator(".research-history-records > li").count(), 1); const revision = allRecords.locator(".research-record-entry").last(); await revision.locator(":scope > summary").click(); - const comparison = revision.locator(".research-record-comparison"); + const revisionHistory = revision.locator(".research-record-group-history"); + await revisionHistory.waitFor(); + assert.equal(await revisionHistory.locator(":scope > li").count(), 2); + const comparison = revision.locator(".research-record-comparison").last(); const compareEntry = comparison.locator(":scope > summary"); await compareEntry.focus(); await compareEntry.press("Enter"); const comparisonText = comparison.locator(".research-comparison-field").nth(1); @@ -340,8 +343,9 @@ export const researchDisplayLanguageScenario = { await allRecords.getByRole("searchbox").fill("finding-0"); const partialComparison = allRecords.locator(".research-record-entry").last(); await partialComparison.locator(":scope > summary").click(); - await partialComparison.locator(".research-record-comparison > summary").click(); - const partialText = partialComparison.locator(".research-comparison-field").nth(1); + const partialVersions = partialComparison.locator(".research-record-comparison").last(); + await partialVersions.locator(":scope > summary").click(); + const partialText = partialVersions.locator(".research-comparison-field").nth(1); await partialText.waitFor(); assert.match(await partialText.innerText(), /Keep V18/, "A missing translation retains the exact source item beside translated fields"); diff --git a/examples/personal-workspace-browser/research-real-benchmark.mjs b/examples/personal-workspace-browser/research-real-benchmark.mjs index 31f2c5b367..2da8f96646 100644 --- a/examples/personal-workspace-browser/research-real-benchmark.mjs +++ b/examples/personal-workspace-browser/research-real-benchmark.mjs @@ -266,11 +266,11 @@ export const researchRealBenchmarkScenario = { const coverageEntries = []; coverageEntries.push(...await readUnanalyzedNativeResults(browser, collectCoverage)); const observations = []; - for (const [arm, fixture] of [ - ["grid-on", "edgebench-grid-context-on-20261103-history.json"], - ["grid-off", "edgebench-grid-context-off-20261103-history.json"], - ["ad", "edgebench-ad-20261023-history.json"], - ["schema", "edgebench-schema-20261025-history.json"], + for (const [arm, fixture, entries] of [ + ["grid-on", "edgebench-grid-context-on-20261103-history.json", 31], + ["grid-off", "edgebench-grid-context-off-20261103-history.json", 11], + ["ad", "edgebench-ad-20261023-history.json", 254], + ["schema", "edgebench-schema-20261025-history.json", 92], ]) { const path = resolve(repoRoot, "apps/presentation/dashboard/src/data/fixtures", fixture); const capture = JSON.parse(await readFile(path, "utf8")); @@ -295,11 +295,11 @@ export const researchRealBenchmarkScenario = { await history.getByRole("button", {name: "Search research records", exact: true}).click(); const records = history.locator(".research-record-search"); const search = records.getByRole("searchbox", {name: "Search research records"}); - assert.equal(await records.locator(".research-record-entry").count(), Math.min(50, capture.events.length)); - while (await records.getByRole("button", {name: /Show more records/}).count()) { - await records.getByRole("button", {name: /Show more records/}).click(); + assert.equal(await records.locator(".research-record-entry").count(), Math.min(50, entries)); + while (await records.getByRole("button", {name: /Show more entries/}).count()) { + await records.getByRole("button", {name: /Show more entries/}).click(); } - assert.equal(await records.locator(".research-record-entry").count(), capture.events.length, "Later real records stay reachable across display pages"); + assert.equal(await records.locator(".research-record-entry").count(), entries, "Later real entries stay reachable across display pages"); const received = page.waitForEvent("download"); await records.getByRole("button", {name: "Download matches (JSON)", exact: true}).click(); const download = await received; diff --git a/examples/personal-workspace-browser/research-record-search.mjs b/examples/personal-workspace-browser/research-record-search.mjs index 1ca850aebf..c478dd7f4c 100644 --- a/examples/personal-workspace-browser/research-record-search.mjs +++ b/examples/personal-workspace-browser/research-record-search.mjs @@ -43,8 +43,15 @@ events.append(build_explore_finding_event(goal_id=goal,node_id='question',findin 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' +negative=build_explore_finding_event(goal_id=other,node_id='other-question',finding_id='negative',title='Independent evidence scope', + summary='The alternative missed the target.',status='refuted',evidence_refs=['synthetic:negative'],recorded_at='2026-01-01T00:00:00Z') Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other synthetic research','events':[ - build_explore_node_event(goal_id=other,node_id='other-question',title='Independent evidence scope',recorded_at='2026-01-01T00:00:00Z')]})) + build_explore_node_event(goal_id=other,node_id='other-question',title='Independent evidence scope',status='resolved',recorded_at='2026-01-01T00:00:00Z'), + negative, + build_explore_node_event(goal_id=other,node_id='separate-attempt',title='Independent evidence scope',recorded_at='2026-01-01T00:00:00Z'), + build_explore_finding_event(goal_id=other,node_id='missing-anchor',finding_id='unlinked',title='Independent evidence scope', + summary='This finding has an absent node.',status='tentative',recorded_at='2026-01-01T00:00:00Z'), + negative]})) `, fixture, otherFixture], {cwd: repoRoot}); owner = await startHistoryOwner([fixture, otherFixture]); page = await browser.newPage({viewport: {width: 1440, height: 900}}); @@ -237,7 +244,7 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await results.getByRole("status", {name: /^1 matches in 79 loaded records/}).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(); + await results.getByRole("button", {name: /Show more entries/}).click(); 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."); @@ -267,12 +274,26 @@ Path(sys.argv[2]).write_text(json.dumps({'goal_id':other,'display_name':'Other s await results.getByRole("status", {name: /^1 matches in 79 loaded records/}).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.getByRole("status", {name: "1 matches in 1 loaded records", exact: true}).waitFor(); + await results.getByRole("status", {name: "4 matches in 4 loaded records · 3 entries", 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 grouped = results.locator(".research-record-entry").first(); + const groupSummary = grouped.locator(":scope > summary"); + assert.match(await groupSummary.innerText(), /Refuted.*Resolved.*2 records/); + assert.equal(await results.locator(".research-record-entry").count(), 3, "Identical titles and times leave independent attempts and absent links separate"); + await groupSummary.press("Enter"); + await grouped.getByRole("paragraph").filter({hasText: "The alternative missed the target."}).waitFor(); + assert.equal(await grouped.locator(".research-record-group-history > li").count(), 2); + await grouped.locator(".research-record-sources > summary").last().click(); + await grouped.getByText("synthetic:negative", {exact: true}).waitFor(); + await groupSummary.press("Escape"); + assert(await groupSummary.evaluate(element => element === document.activeElement && !element.parentElement.open)); const other = await download(); assert.equal(other.goal_id, "synthetic-other-research"); - assert.equal(other.records.length, 1); + assert.deepEqual(other.coverage, {loaded_records: 4, exported_records: 4, history_complete: true, refresh_failed: false}); + assert.equal(other.records.length, 4); + assert.match(await history.locator(".research-history-coverage").innerText(), /5 \/ 5 source rows/); + await page.screenshot({path: resolve(outputDir, "research-history-persisted-replay.png"), animations: "disabled"}); 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"); @@ -281,6 +302,35 @@ 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 results.getByRole("status", {name: /^1 matches in 79 loaded records/}).waitFor(); + let firstRecord, pageMode = "replay"; + await page.route("**/api/chat/explore-results?*", async route => { + const response = await route.fetch(); + const historyPage = await response.json(); + if (new URL(route.request().url()).searchParams.has("cursor")) { + if (pageMode === "short") historyPage.items.pop(); + else historyPage.items.unshift(pageMode === "conflict" ? {...firstRecord, summary: "Conflicting replay"} : firstRecord); + } else [firstRecord] = historyPage.items; + await route.fulfill({response, json: historyPage}); + }); + await history.getByRole("button", {name: "Refresh history", exact: true}).click(); + await page.waitForFunction(() => document.querySelector(".research-history")?.getAttribute("aria-busy") === "false"); + const replayed = await download(); + assert.equal(replayed.coverage.history_complete, true); + assert.equal(replayed.coverage.refresh_failed, false); + assert.deepEqual(replayed.records, selected.records, "Overlapping pages keep one copy per exact event ID"); + for (const invalid of ["conflict", "short"]) { + pageMode = invalid; + const tail = page.waitForResponse(response => response.url().includes("/api/chat/explore-results?") + && new URL(response.url()).searchParams.has("cursor")); + await history.getByRole("button", {name: "Refresh history", exact: true}).click(); + await tail; + await results.getByRole("status", {name: /1 matches in 79 loaded records.*Refresh failed/}).waitFor(); + const stale = await download(); + assert.deepEqual(stale.records, selected.records, "Invalid pages preserve the previous complete snapshot"); + assert.equal(stale.coverage.history_complete, true, invalid); + assert.equal(stale.coverage.refresh_failed, true, invalid); + } + await page.unroute("**/api/chat/explore-results?*"); // A pending refresh cannot export an intermediate mixture of pages. let release, observed; const hold = new Promise(resolve => {release = resolve;}); diff --git a/loopx/control_plane/presentation/explore_history.ts b/loopx/control_plane/presentation/explore_history.ts index 954b6c7dd0..e0f137e243 100644 --- a/loopx/control_plane/presentation/explore_history.ts +++ b/loopx/control_plane/presentation/explore_history.ts @@ -77,11 +77,7 @@ export function parseExploreHistoryPage(input: unknown, goalId: string): Explore return {goal_id: goalId, items, total: page.total as number, next_cursor: page.next_cursor as string | null}; } -export function projectExploreHistory(events: readonly ExploreHistoryEvent[], goalId: string) { - const unique = new Map(); - const nodes = new Map(); - const edges = new Map(); - const findings = new Map(); +export function mergeExploreHistoryEvents(unique: Map, events: readonly ExploreHistoryEvent[], goalId: string) { for (const event of events) { if (event.goal_id !== goalId) throw new Error("Explore history Goal mismatch"); const previous = unique.get(event.event_id); @@ -90,6 +86,16 @@ export function projectExploreHistory(events: readonly ExploreHistoryEvent[], go continue; } unique.set(event.event_id, event); + } +} + +export function projectExploreHistory(events: readonly ExploreHistoryEvent[], goalId: string) { + const unique = new Map(); + mergeExploreHistoryEvents(unique, events, goalId); + const nodes = new Map(); + const edges = new Map(); + const findings = new Map(); + for (const event of unique.values()) { if (event.event_kind === "node") { const node = nodes.get(event.result_id); if (node) {node.latest = event; node.updates.push(event);} diff --git a/tests/control_plane_ts/explore_history.test.ts b/tests/control_plane_ts/explore_history.test.ts index c3d22f9b82..afd50c6416 100644 --- a/tests/control_plane_ts/explore_history.test.ts +++ b/tests/control_plane_ts/explore_history.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import {readFileSync} from "node:fs"; import {parseExploreHistoryPage, projectExploreHistory, layoutExploreHistory, groupUnlinkedHistoryQuestions} from "../../loopx/control_plane/presentation/explore_history.ts"; +import {researchRecordGroups} from "../../apps/presentation/dashboard/src/features/personal-workspace/research-record-search.ts"; const goal = "history-fixture"; const event = (extra: Record = {}) => parseExploreHistoryPage({ok: true, goal_id: goal, @@ -65,6 +66,21 @@ test("replays deduplicate only identical events and reject conflicting identitie assert.throws(() => event({confidence: 2}), /evidence/); }); +test("record entries pair explicit single findings and keep runs, evidence versions and absent links distinguishable", () => { + const question = event({event_id: "q", status: "resolved", run_id: "run-a"}); + const finding = event({event_id: "f1", event_kind: "finding", result_id: "f", node_id: "route", status: "refuted"}); + const revised = {...finding, event_id: "f2", status: "confirmed", supersedes: "f1"}; + const unrelated = {...finding, event_id: "orphan", result_id: "other-f", node_id: "absent"}; + const sameTitle = {...question, event_id: "q2", result_id: "other-attempt"}; + const project = (rows: ReturnType[]) => { + const view = projectExploreHistory(rows, goal); + return researchRecordGroups(view.events, view.nodes).map(group => group.events.map(row => row.event_id)); + }; + assert.deepEqual(project([finding, question, unrelated, sameTitle, revised, finding]), [["f1", "q", "f2"], ["orphan"], ["q2"]]); + assert.deepEqual(project([question, {...finding, run_id: "run-b"}]), [["q"], ["f1"]]); + assert.deepEqual(project([question, finding, {...revised, result_id: "separate-candidate"}]), [["q"], ["f1"], ["f2"]]); +}); + test("sparse question capture is grouped without inventing routes or hiding versions and negative evidence", () => { const rows = Array.from({length: 120}, (_, index) => event({event_id: `question-${index}`, result_id: `q-${index}`, recorded_at: `2026-01-${String(index % 10 + 1).padStart(2, "0")}T12:00:00Z`}));