Knowledge view: read and set a private chat's selection as the person; the Change log and the composer chip follow the daemon (QA F14, F13, F6) - #249
Merged
Conversation
…ction as the person, and never writes one on its own (QA F14) On a default UCSF install every chat is private, and the Knowledge view could not read the selection of one: `GET /knowledge/active?session_id=` is on the reach gate's list exactly as the POST is, and the reads went without the user's proof that the POST already sent. The daemon refused every one of them, and the view, the composer chip and the ingest target showed this renderer's `localStorage` instead. Measured in a sandboxed dev instance on versa_azure: the view read "Soul" while the daemon's selection for the chat was another base. Around that refusal three renderer paths made durable selection writes nobody asked for, which is how a click could look saved and not be: - `localStorage` took the optimistic value BEFORE the POST, so a refused write left it holding a selection the daemon never stored (QA found `soul` there with `.active-kb` blank), and nothing on screen said the click had failed. - deleting the primary sent `clear_primary` on top of the daemon's own repair, installing a durable "no primary" even in a chat that only inherited it. - two effects "repaired" the cache against the base list by writing (`clear_primary`, a pruned `hidden_kbs`), from whatever list one window held. Changes: - `readSelection` carries `userActionHeaders()` on every selection read. That helper is the one mechanism for proving the person on this surface, the same one the reach-gate sweep (#237) and the selection-read fix (#235) use. - `refresh` re-reads the list AND the selection. A delete or a rename is followed by a read, never by a write: the daemon's repair (D2: hiding promotes, deleting clears to the explicit no-primary, an inheriting chat keeps inheriting) is adopted, not re-made. - `localStorage` holds only what the daemon confirmed. A write that did not land raises a "Knowledge base selection not saved" error naming the daemon's reason, then the view re-reads (or falls back to the last confirmed value) and re-reads the list, because a refusal most often means the base went away. - A pointer at a base the list lacks is shown as no primary, not persisted as one. "Make primary" only sends `hidden_kbs` when it has to un-hide the base. - A background re-read never lands on top of a write that is still out. The daemon half was already right. The new session_reach test drives it end to end through the real router, the gate and two private chats: delete the base both chats show, leave no pointer at it in any scope, then choose again with the proof and have it stick. With the delete-time pointer rewrite removed, the test fails on disk (`["doomed"]` where `[""]` is expected) while every GET still passes, which is why it checks the files. Tests: KnowledgeContext.test.tsx adds five tests and rewrites the one that pinned the old prune-by-writing. Run against main's KnowledgeContext and useKnowledgeBases, four of the five new tests fail, and so does the rewritten one, and so do the two existing failed-write tests, which now also assert the error. The fifth new test (a background refresh never lands on top of a write that is still out) guards the re-read this change adds. It passes on main only because main never re-read.
…t's order (QA F13) After digesting a paragraph into a new base, the Change log listed only `create knowledge base …` while `git log` held the two `[ingest]` commits that wrote every page. Reproduced in a sandboxed dev instance: the drawer showed 1 entry and opening it made no request at all, while the route returned all 3. Two causes, one in each half: - The drawer is mounted with the Knowledge view and merely hidden, and `useHistory` read once per base, at mount, before anything had been digested. It now reads every time the drawer opens (and only then), and a late answer for another base can no longer land over the current one. The read carries the user's proof, as every other Knowledge request does. - The route itself reads git, and filters nothing: no commit-message prefix, no side-log. But `GitRepo::log` sorted with `Sort::TIME` alone. A digest commits several times inside one second (`add_raw_source`, the squash commit, a lint autofix), and libgit2 leaves equal timestamps in no useful order: with the times pinned equal it returned HEAD and then the rest of the tie OLDEST first, so a base's `create` was listed above the ingests made after it. It now sorts `TOPOLOGICAL | TIME`, so a parent is never listed before its child. Tests, all failing before the change: - ChangeLogDrawer.history.test.tsx (4) runs the real hook over a mocked route. It covers an ingest landing while the drawer is shut, re-reading on every open, not reading while shut, and a late answer for another base. - `git::tests::log_keeps_commit_order_when_commits_share_a_timestamp` pins five commits to one timestamp. Before: [step 4, step 0, step 1, step 2, step 3]. - `knowledge_routes::history_lists_every_commit_git_holds_ingest_included` drives the real write paths (create, `add_raw_source`, a squashed ingest transaction, a squashed lint autofix), then checks the route lists all four, in `git log --format=%H` order, sha for sha. On main it returned the create commit second.
…fter every turn (QA F6) The chip read "Manage knowledge bases (2 visible)" with three bases on disk, none hidden, until navigating to the Knowledge view and back remounted it. The agent had created the third base from inside the chat. In this configuration that goes through `execute_code`, where no knowledge tool call reaches the renderer at all, so there was nothing to react to except the turn ending. Reproduced in a sandboxed dev instance on versa_azure before the fix: the turn finished, the base was in `registry.yaml`, and the chip still said 2. - The provider re-reads the daemon on `message-stream-finished`, the existing end-of-turn signal. It does not add a new subscription: the sidebar, the extension chip and the tool count already re-read on the same event. The listener is mounted with the provider, once per renderer, not hung off a lookup. It calls the same `refresh` the Knowledge view uses on mount (list and selection together), so a base the agent hid or pinned in that turn is followed too. - Opening the chip re-reads as well, as the Knowledge view's picker and manager already do. A base created from the CLI or another window ends no turn here. - The chat's primary is never reset by a refresh. A refresh only reads, and a read never lands on top of a write that is still out (the previous commit), so a stale or filtered list can no longer be what clears the pin. Measured after the fix in the same instance: the chip's accessible name went "(2 visible)" to "(3 visible)" when the agent's turn ended, with no remount. The turn-end refresh made one list read and one selection read, both with the proof, and no write. The chat's pin was unchanged on disk and in storage. Tests: BottomMenuKnowledgeSelection.followsDaemon.test.tsx runs the real provider under the chip over a mocked daemon: a base created, a base deleted, the chat's set changed, a re-read on open, and no write on a refresh. All five fail against main's KnowledgeContext and chip.
Main gained #235 (the proof on knowledge-selection reads) and #243 (a failed selection read is not an empty answer), both in KnowledgeContext. Where they and this branch did the same thing, one implementation is kept: - The selection request. #243 added `readKnowledgeSelection` (never rejects, null on failure) for the `/` palette and the create-workflow modal, and #235 put the proof inline on the provider's own reads. This branch had a third copy, `readSelection`. All three now go through ONE request, `fetchKnowledgeSelection` in knowledgeSelection.ts. It carries the proof and rejects when the daemon gave no selection. `readKnowledgeSelection` is now the never-rejecting wrapper over it, and its contract is unchanged. - The provider's re-reads. #235's proof on `rehydrateSelection` is subsumed: this branch replaced that function with `recoverFromFailedWrite` and `resyncSelection`, both built on the one request. - The hydrate's catch comment. #235's text is kept; this branch's shorter duplicate of it is dropped. - The tests. #235's 'a private chat' block and its `test/reachGate.ts` model are kept. This branch's duplicate hydrate-with-proof test and its local copy of the gate constants are dropped. Its write tests are kept under 'writes only what the daemon confirmed', built on the shared model.
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
Eleven conflicts, and two of them were substantive rather than textual. `routes/agent.rs` — both sides gated `GET /agent/callable_tool_count`, this branch through a wrapper and main (8437e94, the SD-8 review of #260) inline. The resolution keeps ONE gate: main's body and its fuller reasoning, in this branch's wrapper shape, because the gate has to hand back the refusal the way `session_reach` wrote it. Taking main's `?` into this route's `ErrorResponse` compiled and passed 526 tests, then failed the two that matter — the refusal came back as `{"message": …}` where every other gated route answers the PLAIN-TEXT bytes `GET /sessions/{id}` returns. One boundary, one body; the envelope is part of the boundary, and `get_tools` beside it has the same shape for the same reason. `useSidebarSessions.ts` — auto-merged with no conflict and did not compile. Main's deleted-chat handler (#264) decremented `nextOffsetRef`, which this branch had renamed; git took both halves. The decrement is now gone rather than renamed: it existed because an offset counts rows and the server's list had lost one, and a cursor names the sort key of the last row a page returned, so the boundary lands correctly even when the deleted row is the one it names. The rest: - `CLAUDE.md`, `serve-decisions.md` — this branch appended SD-10, main SD-11; both kept, in number order, and the CLAUDE.md range is now SD-1..SD-11. - `programmatic-session-access.md` — main's turn-control row placed with the other lifecycle rows; and the `next_offset` this branch renamed, which this file still spelled the old way. - `session_reach.rs` — main's SD-11 wording supersedes ours on `/agent/cancel` (SD-11 changed that fact); main's row joins the gated-list table beside the other helper-gated one; the "last two rows" sentence now NAMES those rows, since both sides appended after them and it had already stopped being true. `get_session_extensions` is dropped as an over-read control — this branch gated it, so main's row for it would now fail — and main's `reply.rs` `routes(` control is kept. - `privacy_guard_wiring.rs` — the `agent.rs` row is the union at c(6, 6, 0), not a second row: both sides gated `callable_tool_count` (one call either way) and this branch also gated `GET /agent/tools`; main's own text says SD-11 added no call in that file. - `KnowledgeContext.tsx` — main's #249 had already absorbed this branch's proof header and error handling into `refreshBases`/`fetchKnowledgeSelection`, so main's side stands, with the one thing only our comment said (a filtered list is pruned against, so a missing base reads as "deleted") folded in. - `useKnowledgeBases.ts` — our proof header on the delete, without our `setPrimaryKbId(null)`: main removed that deliberately, the daemon repairs the pointer, and `primaryKbId` is no longer in scope. - `openapi.json`, `types.gen.ts` — regenerated from the merged routes. Verified on the merged tree: privacy_capability 4, privacy_guard_wiring 3, biorouter-server --lib routes:: 528, knowledge_routes 68, biorouter --lib session::session_manager 215, frontend lint:check + format:check clean, vitest 306 over BioRouterSidebar + knowledge. `cargo fmt --check` clean. The OpenAPI spec and TS client regenerate to no diff.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
QA tested merged
main(7c96d79) on 2026-09-10 and filed three Knowledge-view defects. Each commit here fixes one:1. The Knowledge view and a private chat's selection (F14)
What was wrong
GET /knowledge/active?session_id=is on the reach gate's list, exactly like thePOSTbeside it. ThePOSTcarried the user's proof; the reads did not. So the daemon refused every private chat's selection. The view, the chip and the ingest target then showed this renderer'slocalStorageinstead of the daemon's selection.Measured in my own sandbox on
versa_azure: the view read Soul, while the daemon's selection for the chat, read with the proof, wasf14-probe-two.Around that refusal, three renderer paths wrote durable selection state that nobody asked for:
localStoragetook the optimistic value before thePOSTwent out. A refused write left it holding a selection the daemon never stored. QA foundlocalStoragesayingsoulwhile.active-kbwas blank. Nothing on screen said the click had failed; the only trace was aconsole.warn.clear_primaryon top of the daemon's own repair. That installs a durable "this chat has no primary", even in a chat that only inherited the pointer. D2 says such a chat is left alone.clear_primary; the other sent a prunedhidden_kbs. Both acted on whatever list one window held, and every open window ran them.What changed
fetchKnowledgeSelectioninknowledgeSelection.ts, which sendsuserActionHeaders(). That helper is the mechanism for "the operator is the person" on this surface; fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) #237 and Desktop: read a private chat's knowledge selection with the user's proof (KB chip, / palette, workflow capture) #235 use the same one. Before the merge withmainthis function wasreadSelectioninKnowledgeContext; see below.refreshnow re-reads the base list and the selection together. A delete, a rename or a view mount triggers a read, never a write, so the daemon's repair is adopted rather than re-made. That repair is: hiding promotes to the next base, deleting clears to the explicit no-primary blank, and an inheriting chat keeps inheriting.localStorageholds only what the daemon confirmed.hidden_kbsonly when it has to un-hide the base. Echoing the resolved list back installed a session-level override on a chat that was inheriting the machine-wide list.The daemon half was already right
QA read the blank
.active-kbafter a delete as "the daemon does not repair the selection". That blank is the repair for a delete (D2), and it is not the Soul fallback.A new route test pins the rest of the contract, end to end, through the real router, the gate and two private chats:
I checked the test by removing the delete-time pointer rewrite: it then fails on disk (
["doomed"]where[""]is expected) while every GET still passes. That is why it checks the files.CLAUDE.md("One axis, one pointer") now spells out the two repairs.docs/knowledge-base/multi-kb-implementation-plan.mdD12 gets an amendment.2. The Change log (F13)
Reproduced before the fix: after a digest the drawer listed only the
createcommit, and opening it made no request at all, while the route returned all three commits. There were two causes:useHistoryread once per base, at mount, before anything had been digested. It now reads every time the drawer opens, and never lets a late answer for another base land over the current one.GitRepo::logsorted bySort::TIMEalone, and a digest commits several times within one second. libgit2 leaves equal timestamps in no useful order: with the times pinned equal it returned[step 4, step 0, step 1, step 2, step 3]. The log now sortsTOPOLOGICAL | TIME, so a parent is never listed before its child.3. The composer's knowledge-base chip (F6)
The agent creates bases from inside a turn, and in this configuration it does so from
execute_code, where no knowledge tool call reaches the renderer. So the chip read "(2 visible)" over three bases until a remount.message-stream-finished. That is the existing end-of-turn signal the sidebar, the extension chip and the tool count already listen to, so this adds no second subscription. The listener is mounted with the provider, once per renderer.Measured in a sandboxed dev GUI (
versa_azure, private chats)I used my own instance on CDP 9397, never the QA instances.
GET /knowledge/active?session_id=<private>SoulPOSTsclear_primaryafter theDELETEprimary_kb: null; the page reads "No primary knowledge base".active-kbis blank (the daemon's repair); the chat gets no file of its own; no renderer writePOSTwith the proof returns 200;.active-kb-sessions/<digest>=soul; the page shows Soul;localStoragematcheslocalStorageca7ffc2ingest,840b6b4ingest,4612a37create), identical togit logI reproduced the three renderer mechanisms that produce "the renderer says saved, the daemon didn't". I did not reproduce QA's exact click sequence step for step. On unfixed
mainin my sandbox, a single-window chooser click (which does carry the proof) did persist.Tests (fail-before shown)
KnowledgeContext.test.tsx(5 new, 1 rewritten, 2 extended)ChangeLogDrawer.history.test.tsx(4, real hook)BottomMenuKnowledgeSelection.followsDaemon.test.tsx(5, real provider)git::tests::log_keeps_commit_order_when_commits_share_a_timestamp[step 4, step 0, step 1, step 2, step 3]knowledge_routes::history_lists_every_commit_git_holds_ingest_includedsession_reach::…deleting_the_primary_leaves_no_pointer_at_it_and_the_user_can_choose_againSuites
All run with
BIOROUTER_DISABLE_KEYRING=true:cargo test -p biorouter-mcp --lib knowledge::: 798 passed.cargo test -p biorouter-server --test knowledge_routes: 62 passed.cargo test -p biorouter --lib privacy::: 232 passed.cargo test -p biorouter-server --lib --bins: 587 lib and 576 bin tests passed../scripts/clippy-lint.shandcargo fmt --all -- --check: clean.Renderer:
npx vitest run src/components/knowledge src/components/bottom_menu: 38 files, 340 tests.npm run test:run: 444 of 444 files, 4983 passed, 1 skipped. The first two full runs each hit one unrelated timing failure while other worktrees were compiling (sessionBindingSync.test.ts, thenartifactCdnAssets.browser.test.ts). Both files pass on their own, 3 of 3, and the third full run was clean.npm run lint:check: passes.npx prettier --checkon every changed file: passes.No route changed, so there is no OpenAPI or client drift.
Merged with
mainafter #235 and #243 landed: which implementation was kept#235 (the proof on knowledge-selection reads) and #243 (a failed selection read is not an empty answer) both landed in
KnowledgeContextwhile this was open. Merge commit 17480fb keeps one implementation of each thing they overlapped on:readKnowledgeSelection(never rejects,nullon failure) for the/palette and the create-workflow modal. Desktop: read a private chat's knowledge selection with the user's proof (KB chip, / palette, workflow capture) #235 put the proof inline on the provider's own reads, and this branch had a third copy,readSelection. All three now go throughfetchKnowledgeSelectioninknowledgeSelection.ts, which carries the proof and rejects when the daemon gave no selection.readKnowledgeSelectionis now the never-rejecting wrapper over it, with its contract unchanged; its palette and modal tests pass unmodified.rehydrateSelectionis subsumed, because this branch replaced that function withrecoverFromFailedWriteandresyncSelection, both built on the one request.a private chatblock and itstest/reachGate.tsmodel are kept. This branch's duplicate hydrate-with-proof test and its local copy of the gate constants are dropped. Its write tests now sit underwrites only what the daemon confirmed, on the shared model.After the merge:
npx vitest run src/components/knowledge src/components/bottom_menu src/components/MentionPopover src/components/workflows: 46 files, 476 tests pass.npm run lint:checkandnpx prettier --checkon every changed file pass.npm run test:run: 446 of 447 files pass. The one failure isartifactCdnAssets.browser.test.ts: both of its tests pass, but itsafterAllbrowser.close()hits the 30 s hook timeout while other sessions on this machine run headless Chromium. That file,artifactCdnAssets.ts,artifactSecurity.ts, the vitest config andpackage.jsonare all byte-identical toorigin/main.cargo check --tests -p biorouter-server -p biorouter-mcppasses on the merged tree.mainchanged none of the Rust files this PR touches, or the ones they depend on (session_reach.rs,knowledge_routes.rs, the knowledge module,state.rs,routes/knowledge.rs,routes/session.rs). The tests themselves run in CI, includingknowledge_routesnow that CI: run every tests/*.rs integration binary on ubuntu, offline #239 runs integration binaries.Coordination with #237 (still open)
X-User-Actionsent throughuserActionHeaders(), and forbiorouter serveas the configured tier (SD-10). This PR uses exactly that helper and adds no second mechanism; every selection read now funnels through the onefetchKnowledgeSelection.listBases,getActive,listHistoryandknowledgeFetch, because its gate filters an unproven caller. Expect textual conflicts inKnowledgeContext.tsxanduseHistory.ts: take this branch's structure and keep fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) #237's headers. The daemon-side filtering in fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) #237 is exactly why this PR stops the renderer writing on its own initiative; fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) #237's own comment names the prune effects as the hazard.serve) surface,userActionHeaders()returns{}until fix(serve): a new chat starts on the host's configured private model (QA F1) #229 makes it surface-aware. These reads pick that up with no further change.Not in this PR
DELETE /knowledge/bases/{id}and the other base routes still go out without the proof. They are ungated onmain; fix(privacy): one reach gate for every HTTP route that names a chat or a knowledge base (QA H2, M1, M2, F0) #237 gates them and adds the header inknowledgeFetch.🤖 Generated with Claude Code