Skip to content

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
Broccolito merged 6 commits into
mainfrom
claude/optimistic-bhabha-7f501e
Sep 12, 2026
Merged

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
Broccolito merged 6 commits into
mainfrom
claude/optimistic-bhabha-7f501e

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

QA tested merged main (7c96d79) on 2026-09-10 and filed three Knowledge-view defects. Each commit here fixes one:

  1. A private chat's selection could not be read, and a failed write looked like a success (F14, high). On a default UCSF install every chat is private.
  2. The Change log left out every ingest (F13).
  3. The composer's knowledge-base chip missed bases the agent created (F6).

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 the POST beside it. The POST carried 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's localStorage instead 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, was f14-probe-two.

Around that refusal, three renderer paths wrote durable selection state that nobody asked for:

  • localStorage took the optimistic value before the POST went out. A refused write left it holding a selection the daemon never stored. QA found localStorage saying soul while .active-kb was blank. Nothing on screen said the click had failed; the only trace was a console.warn.
  • Deleting the primary sent clear_primary on 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.
  • Two effects "repaired" the renderer's cache against its base list by writing. One sent clear_primary; the other sent a pruned hidden_kbs. Both acted on whatever list one window held, and every open window ran them.

What changed

  • One read function, always with the proof. Every selection read goes through fetchKnowledgeSelection in knowledgeSelection.ts, which sends userActionHeaders(). 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 with main this function was readSelection in KnowledgeContext; see below.
  • The renderer never writes on its own initiative. refresh now 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.
  • localStorage holds only what the daemon confirmed.
  • A write that did not land is reported. It shows a "Knowledge base selection not saved" error with the daemon's reason. The view then re-reads the daemon's selection, or falls back to the last confirmed value if it cannot. It also re-reads the base list, because a refusal most often means the base went away in another window.
  • A missing base is hidden, not cleared. A pointer at a base the list lacks is shown as "no primary", but nothing is persisted.
  • "Make primary" sends hidden_kbs only 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.
  • A background re-read never lands on top of a write that is still out.

The daemon half was already right

QA read the blank .active-kb after 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:

  • delete the base that both chats show as primary;
  • no scope reports it, and no file stores it;
  • the chat that only inherited the pointer gets no file of its own;
  • choosing again with the proof sticks: in the response, in a fresh read, and on disk;
  • the same write without the proof is still refused.

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.md D12 gets an amendment.

2. The Change log (F13)

Reproduced before the fix: after a digest the drawer listed only the create commit, and opening it made no request at all, while the route returned all three commits. There were two causes:

  • Renderer. The drawer is mounted with the view and merely hidden. useHistory read 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.
  • Backend. The route reads git and filters nothing (no commit-message prefix, no side-log). But GitRepo::log sorted by Sort::TIME alone, 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 sorts TOPOLOGICAL | 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.

  • The provider re-reads on 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.
  • Opening the chip re-reads too, as the Knowledge view's picker and manager already do.
  • A refresh never resets the chat's primary. It only reads, and never over a write in flight.

Measured in a sandboxed dev GUI (versa_azure, private chats)

I used my own instance on CDP 9397, never the QA instances.

Check Before (7c96d79) After
GET /knowledge/active?session_id=<private> 403, "That chat is private…"; the view showed the cached Soul 200 with the proof; the view shows the daemon's selection
Delete the chat's primary the renderer POSTs clear_primary after the DELETE no write; one read with the proof returns primary_kb: null; the page reads "No primary knowledge base"
QA's configuration: the chat inherits a machine primary, which is then deleted n/a .active-kb is blank (the daemon's repair); the chat gets no file of its own; no renderer write
…then choose Soul in the manager n/a POST with the proof returns 200; .active-kb-sessions/<digest> = soul; the page shows Soul; localStorage matches
A choice the daemon refuses (a base deleted underneath) the optimistic value stayed on screen and in localStorage error toast with the daemon's sentence; view back on the confirmed base; stale row gone from the list
Change log after a digest 1 entry; no request made when opened 3 entries (ca7ffc2 ingest, 840b6b4 ingest, 4612a37 create), identical to git log
Agent creates a base from the chat chip stayed "(2 visible)" "(2 visible)" → "(3 visible)" when the turn ended; one list read and one selection read, both with the proof, no write; the pin unchanged
Agent deletes a base (approved on the card) n/a "(3 visible)" → "(2 visible)"

I 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 main in my sandbox, a single-window chooser click (which does carry the proof) did persist.

Tests (fail-before shown)

Test Before the fix After
KnowledgeContext.test.tsx (5 new, 1 rewritten, 2 extended) 7 fail against main's sources. The one that passes guards the new re-read and passes only because main never re-read. pass
ChangeLogDrawer.history.test.tsx (4, real hook) 4/4 fail pass
BottomMenuKnowledgeSelection.followsDaemon.test.tsx (5, real provider) 5/5 fail pass
git::tests::log_keeps_commit_order_when_commits_share_a_timestamp fails: [step 4, step 0, step 1, step 2, step 3] pass
knowledge_routes::history_lists_every_commit_git_holds_ingest_included fails: the create commit comes back second pass
session_reach::…deleting_the_primary_leaves_no_pointer_at_it_and_the_user_can_choose_again passes, because the daemon was right; the mutation check above shows it can fail pass

Suites

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.sh and cargo 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, then artifactCdnAssets.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 --check on every changed file: passes.

No route changed, so there is no OpenAPI or client drift.

Merged with main after #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 KnowledgeContext while this was open. Merge commit 17480fb keeps one implementation of each thing they overlapped on:

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:check and npx prettier --check on every changed file pass.
  • npm run test:run: 446 of 447 files pass. The one failure is artifactCdnAssets.browser.test.ts: both of its tests pass, but its afterAll browser.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 and package.json are all byte-identical to origin/main.
  • Rust: cargo check --tests -p biorouter-server -p biorouter-mcp passes on the merged tree. main changed 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, including knowledge_routes now that CI: run every tests/*.rs integration binary on ubuntu, offline #239 runs integration binaries.

Coordination with #237 (still open)

Not in this PR

🤖 Generated with Claude Code

…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
Broccolito merged commit 110e983 into main Sep 12, 2026
17 checks passed
@Broccolito
Broccolito deleted the claude/optimistic-bhabha-7f501e branch September 12, 2026 06:47
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant