From 86ff51c0e8b2063ad5af13a90f4dfeb283d55995 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 23 Jul 2026 05:52:43 -0500 Subject: [PATCH 1/2] feat(recall): show session evidence panel Distilled entries need an inspectable session-local surface so extraction regressions can be noticed without leaving the transcript. Keep review read-only and route evidence through the existing ordinal navigation path. --- frontend/messages/en.json | 8 + frontend/messages/fr.json | 8 + frontend/messages/ko.json | 8 + frontend/messages/zh-CN.json | 8 + frontend/messages/zh-TW.json | 8 + frontend/src/lib/api/recall.ts | 34 +++ frontend/src/lib/api/types/index.ts | 1 + frontend/src/lib/api/types/recall.ts | 32 +++ .../lib/components/content/RecallPanel.svelte | 236 ++++++++++++++++++ .../components/content/SessionVitals.svelte | 3 + .../components/content/SessionVitals.test.ts | 94 +++++++ 11 files changed, 440 insertions(+) create mode 100644 frontend/src/lib/api/recall.ts create mode 100644 frontend/src/lib/api/types/recall.ts create mode 100644 frontend/src/lib/components/content/RecallPanel.svelte diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3d267e711..89270bf7e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1142,6 +1142,14 @@ } } ], + "session_recall_title": "Recall", + "session_recall_loading": "Loading distilled recall…", + "session_recall_empty": "No distilled recall for this session.", + "session_recall_error": "Could not load distilled recall.", + "session_recall_generation": "Generation", + "session_recall_review_state": "Review state", + "session_recall_evidence_range": "Messages {start}–{end}", + "session_recall_jump_evidence": "Jump to transcript messages {start}–{end}", "session_vitals_title": "Analysis", "session_vitals_subtitle": "Session vital signs", "session_vitals_close": "Close session analysis", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index f733c90bf..5d127b2d7 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -1142,6 +1142,14 @@ } } ], + "session_recall_title": "Mémoire", + "session_recall_loading": "Chargement des mémoires distillées…", + "session_recall_empty": "Aucune mémoire distillée pour cette session.", + "session_recall_error": "Impossible de charger les mémoires distillées.", + "session_recall_generation": "Génération", + "session_recall_review_state": "État de révision", + "session_recall_evidence_range": "Messages {start}–{end}", + "session_recall_jump_evidence": "Aller aux messages {start}–{end} de la transcription", "session_vitals_title": "Analyse", "session_vitals_subtitle": "Signes vitaux de la session", "session_vitals_close": "Fermer l'analyse de la session", diff --git a/frontend/messages/ko.json b/frontend/messages/ko.json index 001930f7f..a4fa84ba3 100644 --- a/frontend/messages/ko.json +++ b/frontend/messages/ko.json @@ -1112,6 +1112,14 @@ } } ], + "session_recall_title": "리콜", + "session_recall_loading": "추출된 리콜을 불러오는 중…", + "session_recall_empty": "이 세션에 추출된 리콜이 없습니다.", + "session_recall_error": "추출된 리콜을 불러올 수 없습니다.", + "session_recall_generation": "세대", + "session_recall_review_state": "검토 상태", + "session_recall_evidence_range": "메시지 {start}–{end}", + "session_recall_jump_evidence": "대화 기록 메시지 {start}–{end}(으)로 이동", "session_vitals_title": "분석", "session_vitals_subtitle": "세션 바이탈 사인", "session_vitals_close": "세션 분석 닫기", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index 4bce0a529..b51b9f1b8 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -1110,6 +1110,14 @@ } } ], + "session_recall_title": "记忆", + "session_recall_loading": "正在加载提炼记忆…", + "session_recall_empty": "此会话没有提炼记忆。", + "session_recall_error": "无法加载提炼记忆。", + "session_recall_generation": "代次", + "session_recall_review_state": "审核状态", + "session_recall_evidence_range": "消息 {start}–{end}", + "session_recall_jump_evidence": "跳转到转录消息 {start}–{end}", "session_vitals_title": "分析", "session_vitals_subtitle": "会话指标", "session_vitals_close": "关闭会话分析", diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index 5b5b879d2..71d1fbca5 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -1110,6 +1110,14 @@ } } ], + "session_recall_title": "記憶", + "session_recall_loading": "正在載入提煉記憶…", + "session_recall_empty": "此工作階段沒有提煉記憶。", + "session_recall_error": "無法載入提煉記憶。", + "session_recall_generation": "代次", + "session_recall_review_state": "審核狀態", + "session_recall_evidence_range": "訊息 {start}–{end}", + "session_recall_jump_evidence": "跳至逐字稿訊息 {start}–{end}", "session_vitals_title": "分析", "session_vitals_subtitle": "會話指標", "session_vitals_close": "關閉會話分析", diff --git a/frontend/src/lib/api/recall.ts b/frontend/src/lib/api/recall.ts new file mode 100644 index 000000000..6263c3daf --- /dev/null +++ b/frontend/src/lib/api/recall.ts @@ -0,0 +1,34 @@ +import type { + RecallEntriesResponse, + RecallEntry, +} from "./types/recall.js"; +import { + ApiError, + authHeaders, + getBase, + responseErrorMessage, +} from "./runtime.js"; + +const SESSION_RECALL_LIMIT = 500; + +export async function fetchSessionRecall( + sessionId: string, + signal?: AbortSignal, +): Promise { + const query = new URLSearchParams({ + source_session_id: sessionId, + limit: String(SESSION_RECALL_LIMIT), + }); + const response = await fetch( + `${getBase()}/recall/entries?${query.toString()}`, + authHeaders({ signal }), + ); + if (!response.ok) { + throw new ApiError( + response.status, + await responseErrorMessage(response), + ); + } + const data = (await response.json()) as RecallEntriesResponse; + return data.entries ?? []; +} diff --git a/frontend/src/lib/api/types/index.ts b/frontend/src/lib/api/types/index.ts index 8c9ba85b9..b853e64de 100644 --- a/frontend/src/lib/api/types/index.ts +++ b/frontend/src/lib/api/types/index.ts @@ -7,3 +7,4 @@ export type * from "./insights.js"; export type * from "./session-activity.js"; export type * from "./timing.js"; export type * from "./usage.js"; +export type * from "./recall.js"; diff --git a/frontend/src/lib/api/types/recall.ts b/frontend/src/lib/api/types/recall.ts new file mode 100644 index 000000000..00cca74f9 --- /dev/null +++ b/frontend/src/lib/api/types/recall.ts @@ -0,0 +1,32 @@ +export interface RecallEvidence { + id: number; + entry_id: string; + session_id: string; + message_start_ordinal: number; + message_end_ordinal: number; + snippet?: string; +} + +export interface RecallEntry { + id: string; + type: string; + scope: string; + status: string; + review_state: string; + title: string; + body: string; + source_session_id: string; + source_run_id?: string; + extractor_method?: string; + model?: string; + transferable: boolean; + provenance_ok: boolean; + created_at: string; + updated_at: string; + evidence?: RecallEvidence[]; +} + +export interface RecallEntriesResponse { + entries: RecallEntry[]; + trusted_only: boolean; +} diff --git a/frontend/src/lib/components/content/RecallPanel.svelte b/frontend/src/lib/components/content/RecallPanel.svelte new file mode 100644 index 000000000..21503f143 --- /dev/null +++ b/frontend/src/lib/components/content/RecallPanel.svelte @@ -0,0 +1,236 @@ + + +
+
+ {m.session_recall_title()} + {#if !loading && !failed} + {entries.length} + {/if} +
+ + {#if loading} +

{m.session_recall_loading()}

+ {:else if failed} +

{m.session_recall_error()}

+ {:else if entries.length === 0} +

{m.session_recall_empty()}

+ {:else} +
+ {#each entries as entry (entry.id)} + +
+ {entry.type} +
+

{entry.title}

+

{entry.body}

+
+
+
{m.session_recall_generation()}
+
+ {entry.source_run_id || "—"} +
+
+
+
{m.session_recall_review_state()}
+
{entry.review_state}
+
+
+ {#if entry.evidence?.length} +
+ {#each entry.evidence as evidence (evidence.id)} +
+ {/if} +
+ {/each} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/content/SessionVitals.svelte b/frontend/src/lib/components/content/SessionVitals.svelte index a50a10f75..2a3ace23c 100644 --- a/frontend/src/lib/components/content/SessionVitals.svelte +++ b/frontend/src/lib/components/content/SessionVitals.svelte @@ -18,6 +18,7 @@ TurnTiming, } from "../../api/types/timing.js"; import ActivityLane from "./ActivityLane.svelte"; + import RecallPanel from "./RecallPanel.svelte"; import CallRow from "./CallRow.svelte"; import CallGroup from "./CallGroup.svelte"; import SubagentCalls from "./SubagentCalls.svelte"; @@ -398,6 +399,8 @@ {/if} + + {#if timing} {#if timing.by_category.length > 0}
diff --git a/frontend/src/lib/components/content/SessionVitals.test.ts b/frontend/src/lib/components/content/SessionVitals.test.ts index a50f50ad8..4a5ebdd79 100644 --- a/frontend/src/lib/components/content/SessionVitals.test.ts +++ b/frontend/src/lib/components/content/SessionVitals.test.ts @@ -64,6 +64,19 @@ describe("SessionVitals", () => { beforeEach(() => { mocks.fetchSessionTiming.mockReset().mockResolvedValue(mocks.timing); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/v1/recall/entries?")) { + return new Response( + JSON.stringify({ entries: [], trusted_only: false }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response( + JSON.stringify({ error: "not mocked" }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + })); sessionTiming.reset(); ui.vitalsOpen = true; }); @@ -181,6 +194,87 @@ describe("SessionVitals", () => { expect(writeText).toHaveBeenNthCalledWith(2, traceSession.cwd); }); + it("shows distilled recall and jumps to its transcript evidence", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response( + JSON.stringify({ + entries: [{ + id: "recall-1", + type: "fact", + scope: "project", + status: "accepted", + review_state: "unreviewed_auto", + title: "Retry bounded background work", + body: "Background retries stop after one delayed attempt.", + source_session_id: "sess-1", + source_run_id: "generation-2026-07-23", + extractor_method: "turns-v1", + transferable: false, + provenance_ok: true, + created_at: "2026-07-23T10:00:00Z", + updated_at: "2026-07-23T10:00:00Z", + evidence: [{ + id: 1, + entry_id: "recall-1", + session_id: "sess-1", + message_start_ordinal: 12, + message_end_ordinal: 14, + snippet: "Bound the retry lifecycle.", + }], + }], + trusted_only: false, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + )); + vi.stubGlobal("fetch", fetchMock); + const scroll = vi.spyOn(ui, "scrollToOrdinal"); + component = mount(SessionVitals, { + target: document.body, + props: { sessionId: "sess-1", session: traceSession }, + }); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining( + "/api/v1/recall/entries?source_session_id=sess-1", + ), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + await vi.waitFor(() => { + expect(document.body.textContent).toContain( + "Retry bounded background work", + ); + }); + expect(document.body.textContent).toContain( + "Background retries stop after one delayed attempt.", + ); + expect(document.body.textContent).toContain("fact"); + expect(document.body.textContent).toContain("unreviewed_auto"); + expect(document.body.textContent).toContain("generation-2026-07-23"); + + const evidenceButton = Array.from( + document.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("12–14")); + expect(evidenceButton).toBeDefined(); + evidenceButton!.click(); + + expect(scroll).toHaveBeenCalledWith(12, "sess-1"); + scroll.mockRestore(); + }); + + it("shows an empty Recall state for a session without distilled entries", async () => { + component = mount(SessionVitals, { + target: document.body, + props: { sessionId: "sess-1", session: traceSession }, + }); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain( + m.session_recall_empty(), + ); + }); + }); + it("aborts a pending sub-agent timing read when collapsed", async () => { const signals: AbortSignal[] = []; mocks.fetchSessionTiming.mockImplementation( From f220c45949547f0d7da1a796b09d7bb4ed2ef728 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Thu, 23 Jul 2026 08:08:13 -0500 Subject: [PATCH 2/2] fix(recall): disable revoked evidence links A revoked provenance range no longer substantiates its Recall entry and must not look actionable. Keep the entry visible for extraction review, but identify the revocation and prevent stale transcript navigation. --- frontend/messages/en.json | 1 + frontend/messages/fr.json | 1 + frontend/messages/ko.json | 1 + frontend/messages/zh-CN.json | 1 + frontend/messages/zh-TW.json | 1 + .../lib/components/content/RecallPanel.svelte | 45 ++++++++++++----- .../components/content/SessionVitals.test.ts | 48 +++++++++++++++++++ 7 files changed, 86 insertions(+), 12 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 89270bf7e..6eedd20a0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1148,6 +1148,7 @@ "session_recall_error": "Could not load distilled recall.", "session_recall_generation": "Generation", "session_recall_review_state": "Review state", + "session_recall_provenance_revoked": "Provenance revoked", "session_recall_evidence_range": "Messages {start}–{end}", "session_recall_jump_evidence": "Jump to transcript messages {start}–{end}", "session_vitals_title": "Analysis", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index 5d127b2d7..6bbe33402 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -1148,6 +1148,7 @@ "session_recall_error": "Impossible de charger les mémoires distillées.", "session_recall_generation": "Génération", "session_recall_review_state": "État de révision", + "session_recall_provenance_revoked": "Provenance révoquée", "session_recall_evidence_range": "Messages {start}–{end}", "session_recall_jump_evidence": "Aller aux messages {start}–{end} de la transcription", "session_vitals_title": "Analyse", diff --git a/frontend/messages/ko.json b/frontend/messages/ko.json index a4fa84ba3..5d9670cb3 100644 --- a/frontend/messages/ko.json +++ b/frontend/messages/ko.json @@ -1118,6 +1118,7 @@ "session_recall_error": "추출된 리콜을 불러올 수 없습니다.", "session_recall_generation": "세대", "session_recall_review_state": "검토 상태", + "session_recall_provenance_revoked": "출처가 취소됨", "session_recall_evidence_range": "메시지 {start}–{end}", "session_recall_jump_evidence": "대화 기록 메시지 {start}–{end}(으)로 이동", "session_vitals_title": "분석", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index b51b9f1b8..fa360df7f 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -1116,6 +1116,7 @@ "session_recall_error": "无法加载提炼记忆。", "session_recall_generation": "代次", "session_recall_review_state": "审核状态", + "session_recall_provenance_revoked": "来源已撤销", "session_recall_evidence_range": "消息 {start}–{end}", "session_recall_jump_evidence": "跳转到转录消息 {start}–{end}", "session_vitals_title": "分析", diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index 71d1fbca5..633842c55 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -1116,6 +1116,7 @@ "session_recall_error": "無法載入提煉記憶。", "session_recall_generation": "代次", "session_recall_review_state": "審核狀態", + "session_recall_provenance_revoked": "來源已撤銷", "session_recall_evidence_range": "訊息 {start}–{end}", "session_recall_jump_evidence": "跳至逐字稿訊息 {start}–{end}", "session_vitals_title": "分析", diff --git a/frontend/src/lib/components/content/RecallPanel.svelte b/frontend/src/lib/components/content/RecallPanel.svelte index 21503f143..a6f248f05 100644 --- a/frontend/src/lib/components/content/RecallPanel.svelte +++ b/frontend/src/lib/components/content/RecallPanel.svelte @@ -78,6 +78,11 @@
{entry.type} + {#if !entry.provenance_ok} + + {m.session_recall_provenance_revoked()} + + {/if}

{entry.title}

{entry.body}

@@ -96,18 +101,24 @@ {#if entry.evidence?.length}
{#each entry.evidence as evidence (evidence.id)} -
{/if} @@ -177,6 +188,10 @@ text-transform: uppercase; } + .recall-provenance-revoked { + color: var(--slow-fg); + } + h3 { margin: 0; color: var(--text-primary); @@ -233,4 +248,10 @@ font-family: var(--font-mono); font-size: 9px; } + + .recall-evidence-revoked { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 9px; + } diff --git a/frontend/src/lib/components/content/SessionVitals.test.ts b/frontend/src/lib/components/content/SessionVitals.test.ts index 4a5ebdd79..1a4f1e3b2 100644 --- a/frontend/src/lib/components/content/SessionVitals.test.ts +++ b/frontend/src/lib/components/content/SessionVitals.test.ts @@ -262,6 +262,54 @@ describe("SessionVitals", () => { scroll.mockRestore(); }); + it("labels revoked recall provenance and does not link its evidence", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response( + JSON.stringify({ + entries: [{ + id: "recall-revoked", + type: "fact", + scope: "project", + status: "accepted", + review_state: "unreviewed_auto", + title: "Outdated transcript claim", + body: "This entry no longer has valid source provenance.", + source_session_id: "sess-1", + source_run_id: "generation-revoked", + extractor_method: "turns-v1", + transferable: false, + provenance_ok: false, + created_at: "2026-07-23T10:00:00Z", + updated_at: "2026-07-23T10:00:00Z", + evidence: [{ + id: 2, + entry_id: "recall-revoked", + session_id: "sess-1", + message_start_ordinal: 21, + message_end_ordinal: 23, + snippet: "This source range was revoked.", + }], + }], + trusted_only: false, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + )); + vi.stubGlobal("fetch", fetchMock); + component = mount(SessionVitals, { + target: document.body, + props: { sessionId: "sess-1", session: traceSession }, + }); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain("Outdated transcript claim"); + }); + expect(document.body.textContent).toContain("Provenance revoked"); + expect(document.body.textContent).toContain("Messages 21–23"); + const evidenceButton = Array.from( + document.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("21–23")); + expect(evidenceButton).toBeUndefined(); + }); + it("shows an empty Recall state for a session without distilled entries", async () => { component = mount(SessionVitals, { target: document.body,