Skip to content

Commit 1486f9e

Browse files
timothykassisclaude
andcommitted
Fix live notebook issues found in end-to-end testing
- Evidence links that name their own chat's session id (specialists copy it from notebook_search; What-next proposal targets always carry it) resolved to nothing in This chat, single-chat exports and other per-chat reads, so they silently stopped counting as evidence. Session reads now localize them (shared localizeEvidenceLinks in notebook-evidence-core), as does the web view for provisional live entries. - All chats labelled a chat created after the notebook mounted with its raw session id; session labels are re-read when a new chat appears. - A handled "View in notebook" jump replayed on every scope or chat switch, scrolling to a stale entry or toasting "isn't in this chat's notebook". Focus tokens now fire once and page clears the request on chat change. - Incomplete artifact checks reported internal codes ("unsafe-path" for uv.lock), which the Methods draft then quoted as prose; reasons are now readable sentences. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1f18ef9 commit 1486f9e

9 files changed

Lines changed: 139 additions & 12 deletions

‎server/src/agent/notebook-artifacts.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,23 @@ export async function captureNotebookArtifacts(projectId: string, artifacts: str
7070
return out;
7171
}
7272

73+
/**
74+
* Readers (and the Methods draft, which quotes these) see prose, not codes.
75+
* "unsafe-path" mostly means an internal file such as uv.lock or a dot-path,
76+
* which reads as a security problem when shown verbatim.
77+
*/
78+
const INCOMPLETE_CHECK: Record<Exclude<NonNullable<Check["reason"]>, "missing">, string> = {
79+
"unsafe-path": "not a user-visible sandbox file (internal files such as uv.lock or dot-paths, and symlinks leaving the sandbox, are not checked)",
80+
unreadable: "the file could not be read",
81+
budget: "the check budget for this read was exhausted",
82+
"changed-during-check": "the file changed while it was being hashed",
83+
};
84+
7385
/** Pure comparison. Missing hashes and harvest-time identities never earn unchanged. */
7486
export function compareNotebookArtifact(recorded: NotebookArtifactSnapshot | undefined, current: Check): NotebookArtifactHealth {
7587
const base = { path: current.path, checkedAt: current.capturedAt };
7688
if (current.reason === "missing") return { ...base, status: "missing", reason: "Cited artifact is missing. Review this entry; this does not refute its claim." };
77-
if (current.reason) return { ...base, status: "unverified", reason: `Artifact check incomplete: ${current.reason}.` };
89+
if (current.reason) return { ...base, status: "unverified", reason: `Artifact check incomplete: ${INCOMPLETE_CHECK[current.reason] ?? current.reason}.` };
7890
if (recorded?.sha256 && current.sha256) {
7991
if (recorded.sha256 !== current.sha256) return { ...base, status: "changed", reason: recorded.timing === "harvest" ? "Bytes changed since a later identity check; original citation-time bytes are unknown." : recorded.timing === "output" ? "Bytes differ from the server-recorded compute output. Review against the retained result." : "Bytes differ from the recorded citation. Review the interpretation against the new artifact." };
8092
if (recorded.timing === "entry" || recorded.timing === "output") return { ...base, status: "unchanged", reason: recorded.timing === "output" ? "Bytes match the server-recorded compute output. This does not verify scientific validity or unchanged upstream inputs." : "Bytes match the citation-time hash. This does not verify scientific validity or unchanged upstream inputs." };

‎server/src/agent/notebook-store.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* This file is the authoritative source of truth for reload and export;
77
* the live SSE `tool_start` frame is only a provisional mirror.
88
*/
9-
import type { NotebookEvidenceLink, NotebookOutcome, NotebookArtifactSnapshot, NotebookArtifactHealth } from "../../../web/src/lib/notebook-evidence-core.ts";
9+
import { localizeEvidenceLinks, type NotebookEvidenceLink, type NotebookOutcome, type NotebookArtifactSnapshot, type NotebookArtifactHealth } from "../../../web/src/lib/notebook-evidence-core.ts";
1010
import type { AnalysisPlanInput, AnalysisPlanHistory } from "../../../web/src/lib/notebook-plans.ts";
1111
import type { NotebookResultLink, NotebookResultSnapshot } from "../../../web/src/lib/notebook-result-links.ts";
1212
import type { RobustnessDraft } from "../../../web/src/lib/notebook-robustness.ts";
@@ -158,7 +158,7 @@ export function readNotebookEntries(
158158
sessionId: string,
159159
projectId?: string,
160160
): NotebookEntry[] {
161-
return parseNotebookFile(notebookPath(sessionId, projectId));
161+
return localizeEvidenceLinks(parseNotebookFile(notebookPath(sessionId, projectId)), sessionId);
162162
}
163163

164164
export interface SessionNotebook {
@@ -182,7 +182,7 @@ export function readProjectNotebooks(projectId: string): SessionNotebook[] {
182182
if (!f.endsWith(".jsonl")) continue;
183183
const sessionId = f.slice(0, -".jsonl".length);
184184
if (!isValidSessionId(sessionId)) continue;
185-
out.push({ sessionId, entries: parseNotebookFile(path.join(dir, f)) });
185+
out.push({ sessionId, entries: localizeEvidenceLinks(parseNotebookFile(path.join(dir, f)), sessionId) });
186186
}
187187
return out;
188188
}

‎server/test/notebook-artifacts.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ describe("notebook artifact verification", () => {
4949
const checks = await captureNotebookArtifacts("default", ["../../outside.txt", ".pi/auth.json", "AGENTS.md"]);
5050
expect(checks.every((c) => c.reason === "unsafe-path" && !c.sha256)).toBe(true);
5151
});
52+
it("explains an incomplete check in words, not the internal reason code", () => {
53+
const health = compareNotebookArtifact(undefined, { path: "uv.lock", capturedAt: 1, reason: "unsafe-path" });
54+
expect(health.status).toBe("unverified");
55+
expect(health.reason).toMatch(/not a user-visible sandbox file/);
56+
expect(health.reason).not.toMatch(/unsafe-path/);
57+
});
5258
it.skipIf(process.platform === "win32")("does not follow symlinks outside the sandbox or into hidden files", async () => {
5359
write();
5460
const outside = path.join(PROJECTS_ROOT, "secret.txt"); fs.writeFileSync(outside, "secret");

‎server/test/notebook-routes.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ describe("GET /sessions/:id/notebook/export", () => {
9999
expect(resPdf.json().detail).toMatch(/format must be md, json, or zip/);
100100
});
101101

102+
it("resolves a link that names its own session and counts it as evidence", async () => {
103+
appendNotebookEntry("route-own", entry({ id: "h", type: "hypothesis", title: "Claim", timestamp: 1 }), "default");
104+
appendNotebookEntry("route-own", entry({ id: "pipeline:o", type: "observation", title: "Finding", timestamp: 2, role: "pipeline",
105+
evidence: [{ entryId: "h", sessionId: "route-own", relation: "supports" }] }), "default");
106+
const md = await app.inject({ method: "GET", url: "/sessions/route-own/notebook/export?format=md", headers: { "x-project-id": "default" } });
107+
expect(md.body).toContain("_↳ supports “Claim” (h)_");
108+
expect(md.body).not.toContain("unavailable in this export");
109+
expect(md.body).toContain("**Evidence status:** Supporting evidence");
110+
});
111+
102112
it("returns a JSON download with format=json", async () => {
103113
appendNotebookEntry("route-json", entry({ id: "tc_1" }), "default");
104114
appendNotebookEntry("route-json", entry({ id: "tc_2", type: "observation" }), "default");

‎server/test/notebook-store.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ describe("notebook-store", () => {
6363
expect("relatesTo" in got[1]).toBe(false);
6464
expect("runId" in got[1]).toBe(false);
6565
});
66+
it("treats a link naming its own session as a same-chat link", () => {
67+
// Specialists copy session ids from notebook_search, so harvested rows can
68+
// spell out the parent chat; keyed by bare id, those links resolved to nothing.
69+
const s = "sess-store-own";
70+
appendNotebookEntry(s, entry({ id: "obs", evidence: [
71+
{ entryId: "tc_1", sessionId: s, relation: "supports" },
72+
{ entryId: "tc_9", sessionId: "sess-other", relation: "context" },
73+
] }));
74+
expect(readNotebookEntries(s)[0].evidence).toEqual([
75+
{ entryId: "tc_1", relation: "supports" },
76+
{ entryId: "tc_9", sessionId: "sess-other", relation: "context" },
77+
]);
78+
expect(readProjectNotebooks("default").find((n) => n.sessionId === s)!.entries[0].evidence![0]).toEqual({ entryId: "tc_1", relation: "supports" });
79+
});
6680
});
6781

6882
describe("appendNewNotebookEntries", () => {

‎web/src/app/page.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,9 @@ function WorkspacePage({
815815
// ------------------------------------------------------------------
816816

817817
const activeSessionId = activeMeta?.sessionId ?? null;
818+
// A jump targets the chat it came from; a notebook view remounting under
819+
// another chat must not replay it ("That notebook entry isn't in this chat").
820+
useEffect(() => { setNotebookFocus(null); }, [activeSessionId]);
818821
const {
819822
activeCount: activeModalJobCount,
820823
loading: modalJobsLoading,

‎web/src/components/lab-notebook-view.test.tsx‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,53 @@ describe("LabNotebookView", () => {
504504
).toBe(true);
505505
});
506506

507+
it("relabels the project view when a chat appears after the first label lookup", async () => {
508+
// The view usually mounts before the project's first chat exists; the
509+
// divider then showed the raw session id for the rest of the visit.
510+
let chats: unknown[] = [];
511+
let entries: NotebookEntry[] = [];
512+
routeFetch((url) => {
513+
if (url === "/projects/default/notebook") return okJson({ entries });
514+
if (url === "/sessions") return okJson(chats);
515+
});
516+
const { rerender } = rtlRender(<LabNotebookView {...baseProps} sessionId={null} />);
517+
await waitFor(() => expect(spy.mock.calls.some(([u]) => u === "/sessions")).toBe(true));
518+
chats = [{ id: "sNew", firstMessage: "fit the dose curve" }];
519+
entries = [e({ id: "p1", title: "Fresh entry", sessionId: "sNew" })];
520+
rerender(<LabNotebookView {...baseProps} sessionId="sNew" streaming />);
521+
await waitFor(() => expect(screen.getByText("Fresh entry")).toBeInTheDocument());
522+
await waitFor(() => expect(screen.getByText("fit the dose curve")).toBeInTheDocument());
523+
});
524+
525+
it("counts a live link that names its own chat as same-chat evidence", () => {
526+
render(<LabNotebookView {...baseProps} liveEntries={[
527+
e({ id: "h", title: "Claim" }),
528+
e({ id: "o", type: "observation", title: "Finding", evidence: [{ entryId: "h", sessionId: "s1", relation: "supports" }] }),
529+
]} />);
530+
expect(screen.queryByText(/not in this view/)).not.toBeInTheDocument();
531+
expect(screen.getByText("Supporting evidence")).toBeInTheDocument();
532+
});
533+
534+
it("does not replay a handled focus jump when the scope or chat changes", async () => {
535+
const orig = Element.prototype.scrollIntoView;
536+
const scrollSpy = vi.fn();
537+
Element.prototype.scrollIntoView = scrollSpy;
538+
try {
539+
const focusEntry = { id: "tc_1", token: 7 };
540+
const { rerender } = render(<LabNotebookView {...baseProps} liveEntries={[e({})]} focusEntry={focusEntry} />);
541+
await waitFor(() => expect(scrollSpy).toHaveBeenCalled());
542+
scrollSpy.mockClear();
543+
fireEvent.click(screen.getByRole("button", { name: "All chats" }));
544+
fireEvent.click(screen.getByRole("button", { name: "This chat" }));
545+
rerender(<LabNotebookView {...baseProps} sessionId="s2" liveEntries={[]} focusEntry={focusEntry} />);
546+
await new Promise((r) => setTimeout(r, 50));
547+
expect(scrollSpy).not.toHaveBeenCalled();
548+
expect(toast.error).not.toHaveBeenCalled();
549+
} finally {
550+
Element.prototype.scrollIntoView = orig;
551+
}
552+
});
553+
507554
it("saves the methods draft without interrupting notebook work and offers an explicit open action", async () => {
508555
const user = userEvent.setup();
509556
routeFetch((url) => {

‎web/src/components/lab-notebook-view.tsx‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
notebookEntryKey,
1212
type NotebookEntry,
1313
} from "@/lib/notebook";
14+
import { localizeEvidenceLinks } from "@/lib/notebook-evidence-core";
1415
import { deriveThreads } from "@/lib/notebook-threads";
1516
import {
1617
countByType,
@@ -133,6 +134,14 @@ export function LabNotebookView({
133134
addNote,
134135
} = useNotebookAnnotations(sessionId, canAnnotate, scopedProjectId);
135136

137+
// Re-read labels when a chat appears that the last lookup could not have
138+
// named (the view usually mounts before the project's first chat exists).
139+
const labelledSessionsKey = useMemo(() => {
140+
const ids = new Set(projectData.entries.map((e) => e.sessionId).filter((id): id is string => Boolean(id)));
141+
if (sessionId) ids.add(sessionId);
142+
return [...ids].sort().join("\n");
143+
}, [projectData.entries, sessionId]);
144+
136145
// Labels are cosmetic; a failed label lookup must not hide notebook data.
137146
useEffect(() => {
138147
if (scope !== "project") return;
@@ -155,7 +164,7 @@ export function LabNotebookView({
155164
}
156165
})();
157166
return () => { cancelled = true; };
158-
}, [scope, scopedProjectId]);
167+
}, [scope, scopedProjectId, labelledSessionsKey]);
159168

160169
// User notes render as synthetic entries so they flow through the timeline.
161170
const noteEntries = useMemo<NotebookEntry[]>(
@@ -172,9 +181,11 @@ export function LabNotebookView({
172181
);
173182

174183
// Authoritative (fetched) entries win over provisional (live) ones by id.
184+
// The server localizes fetched rows; provisional tool frames still carry
185+
// the raw links until the refetch lands.
175186
const sessionEntries = useMemo(
176-
() => mergeNotebookEntries(mergeNotebookEntries(liveEntries, fetched), noteEntries),
177-
[liveEntries, fetched, noteEntries],
187+
() => mergeNotebookEntries(mergeNotebookEntries(sessionId ? localizeEvidenceLinks(liveEntries, sessionId) : liveEntries, fetched), noteEntries),
188+
[liveEntries, fetched, noteEntries, sessionId],
178189
);
179190
// The project stream has no separate SSE channel. Overlay this chat's live
180191
// entries immediately, then let authoritative project rows win by scoped id.
@@ -250,7 +261,10 @@ export function LabNotebookView({
250261

251262
// --- Deep-link focus (chat → notebook, and thread-reference jumps) ---
252263
const pendingFocusRef = useRef<string | null>(null);
253-
const memorySuppressedFocusToken = useRef<number | undefined>(undefined);
264+
// A chat-side jump is one request: once handled, switching scope or chat
265+
// must not replay it (that scrolled to a stale entry, or toasted "isn't in
266+
// this chat's notebook" for an entry in the previous chat).
267+
const handledFocusToken = useRef<number | undefined>(undefined);
254268
const focusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
255269

256270
const tryFocus = useCallback(() => {
@@ -299,10 +313,11 @@ export function LabNotebookView({
299313

300314
const focusToken = focusEntry?.token;
301315
useEffect(() => {
302-
if (focusEntry && focusToken !== undefined && focusToken !== memorySuppressedFocusToken.current) focusById(scope === "project" && sessionId
303-
? notebookEntryKey({ id: focusEntry.id, sessionId }) : focusEntry.id);
316+
if (!focusEntry || focusToken === undefined || focusToken === handledFocusToken.current) return;
317+
handledFocusToken.current = focusToken;
318+
focusById(scope === "project" && sessionId ? notebookEntryKey({ id: focusEntry.id, sessionId }) : focusEntry.id);
304319
// eslint-disable-next-line react-hooks/exhaustive-deps
305-
}, [focusToken, scope, sessionId]);
320+
}, [focusToken]);
306321

307322
// Retry pending focus whenever the rendered set changes (refetch landing).
308323
useEffect(() => {
@@ -430,7 +445,6 @@ export function LabNotebookView({
430445
<LabNotebookHeader
431446
packageControl={<EvidencePackageDialog projectId={scopedProjectId} candidates={displayEntries.filter((e) => !e.provisional && ["hypothesis", "method", "observation", "decision"].includes(e.type) && (e.sessionId ?? sessionId)).map((e) => ({ sessionId: e.sessionId ?? sessionId!, entryId: e.id, title: e.title, type: e.type }))} />}
432447
memory={<NotebookMemoryDialog projectId={scopedProjectId} activeSessionId={sessionId} onOpenFile={onOpenFile} onJump={(source) => {
433-
memorySuppressedFocusToken.current = focusToken;
434448
if (source.kind === "user-note") {
435449
if (source.sessionId !== sessionId) return;
436450
setScope("session");

‎web/src/lib/notebook-evidence-core.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,27 @@ export function notebookTargetKey(entry: { sessionId?: string }, id: string, ses
7070
return notebookEntryKey({ id, sessionId: sessionId ?? entry.sessionId });
7171
}
7272

73+
/**
74+
* Drop a link's `sessionId` when it names the chat the entries came from.
75+
* Session views key entries by bare id, so a link spelling out its own chat
76+
* (specialists copy it from notebook_search; proposal targets always carry
77+
* it) would otherwise resolve to nothing and silently stop counting as
78+
* evidence. An omitted sessionId already means "this chat", so this is lossless.
79+
*/
80+
export function localizeEvidenceLinks<T extends { evidence?: NotebookEvidenceLink[] }>(entries: T[], sessionId: string): T[] {
81+
return entries.map((entry) => {
82+
if (!Array.isArray(entry.evidence) || !entry.evidence.some((link) => link?.sessionId === sessionId)) return entry;
83+
return {
84+
...entry,
85+
evidence: entry.evidence.map((link) => {
86+
if (link?.sessionId !== sessionId) return link;
87+
const { sessionId: _own, ...local } = link;
88+
return local;
89+
}),
90+
};
91+
});
92+
}
93+
7394
/** Defensive parsing for old files and provisional tool frames. */
7495
export function normalizeEvidenceLinks(value: unknown): NotebookEvidenceLink[] {
7596
if (!Array.isArray(value)) return [];

0 commit comments

Comments
 (0)