Desktop: a failed knowledge-selection read is not an empty answer (/ palette, workflow capture) - #243
Merged
Merged
Conversation
…lection `GET /knowledge/active` naming a chat is on the reach gate's list (`session_reach.rs`), exactly like the POST beside it. The POST in `syncSelection` has always sent `userActionHeaders()`; the reads did not — the KnowledgeProvider hydrate and its recovery re-read, the `/` palette's knowledge-base rows, and the create-workflow modal. So the desktop's own daemon answered each of them as a public caller and refused every private chat, whatever model was bound. Measured on 2026-09-11 in a sandboxed dev instance on versa_azure (GPT-5.5): one turn ratcheted a new chat private, and its selection was set to primary `lab-notes` with `grant-drafts` hidden. - `GET /knowledge/active?session_id=…` without the proof: 403, "That chat is private, or there is no chat with that id. This request was made on a public model …" — for a chat bound to a private model. With `X-User-Action`: 200 and the chat's selection. - The console printed "Knowledge selection not hydrated: That chat is private, or there is no chat with that id." and the chip showed all three bases on: the renderer's cache, not the daemon's selection. - One click on another base in that stale chip wrote the stale set back; the daemon then had `grant-drafts` visible to the chat again. - The `/` palette offered `kb:Grant drafts` as "Knowledge base in this chat" and named no primary. With the proof on the reads, the same chat hydrates to the daemon's selection, the chip shows Grant drafts off, the same click leaves it hidden, and the palette offers Lab notes (as the primary) and Soul only. This reaches nothing the renderer could not already read: the selection is a strict subset of the transcript `getSession` reads with the same proof. And `userActionHeaders()` is the one place the surface is decided, so on a browser surface these reads state the host's model once SD-9's change to that helper lands (#229), with no further change here. The KnowledgeContext comment that called the refusal "a correct outcome" for "a private chat opened while a public model is bound" is corrected: the read was refused for every private chat, and what followed was not correct. Fail-before: all five new tests — the hydrate, the recovery re-read, the toggle that re-exposed a hidden base, the palette and the workflow capture — fail with the production change reverted. `src/test/reachGate.ts` is the one model of the gate they share.
…und model `selectionWarning.ts` and its test carried the premise the previous commit corrected in KnowledgeContext: that the desktop app was refused `GET /knowledge/active` for a private chat "while a public model is bound", as "a normal, correct outcome". It was refused for every private chat, because its read carried no proof. What the module does is still right — the refusal is prose addressed to a model, and a console's reader is a person — so only the reasoning changes. The pointer to the composer's pinned-model note goes with it: that note answers why a chosen model is not in effect, which this refusal never was about.
…tion is part of it The hydrate's comment called a chat's knowledge-base selection "a strict subset of what `getSession` already reads". It is not part of that response at all — the selection lives beside the session store, not in the transcript. The point the sentence was making is about reach: the same proof already reads the whole transcript, which discloses far more than which bases the chat uses.
`readPrimary` and `readHidden` move out of `KnowledgeContext` into a module of their own, `knowledge/knowledgeSelection.ts`, unchanged. The `/` palette and the create-workflow modal each re-spelled the same two reads inline (`primary_kb ?? active_kb`, `hidden_kbs ?? []`), and the next commits give them a shared reader, which needs these two in a place all three callers can import. No behavior change.
The palette's knowledge-base rows come from `GET /knowledge/active`, read with `throwOnError: false`, and a failed read left `data` undefined. The rows then read `hidden_kbs ?? []` and `primary_kb ?? active_kb ?? null`, so a failure was drawn as "nothing is hidden, nothing is primary": every base, the chat's hidden ones included, labelled "Knowledge base in this chat", and no primary. Now that the read carries the user's proof (#235), a failure is a genuine error: a surface that cannot prove the person, a dropped connection, an older daemon. None of those is a statement about the chat. `KnowledgeContext` already states the rule for `listBases`, and it holds here: a failed request is not an empty answer. `readKnowledgeSelection` is the one reader for a surface that shows or saves a chat's selection. It sends the proof, and resolves to `null` on any failure, rejection included, with one brief console line. It never rejects, which fixes a second failure: the palette loads its commands, skills and extensions in the same `Promise.all`, and a selection read that threw emptied all of them. With no selection, the palette still offers every base, because a reference names its base by id and an explicit id reaches a base whatever the chat's selection (`kb_id_or_primary` in the knowledge server). What goes is the claim. Each row reads "Knowledge base · <id>", with neither "in this chat" nor "Primary knowledge base". Tests: the refused read (the reach gate model with no proof), a transport failure, and a rejection. All three fail before this change; the rejection case failed with an empty palette.
… read The create-workflow modal reads the chat's selection beside the base list, and on a failed read (`data` undefined) it computed `visible` as every base and the default as `visible[0]`. `handleCreateWorkflow` prefers that state over the generated workflow's own `knowledge_bases` block whenever it is non-empty, so the workflow saved every base. Unlike the palette's rows, this claim is written to disk and outlives the chat. It lost in two orders: - The generation brings no block, which happens when it fails and the user fills the form in: every base is saved. - The generation's block lands first and the failed read after it, inside the ~500 ms before the form shows: every base overwrote the daemon's own block. After that window the effect has been torn down, so a late answer is dropped. A failed read, via `readKnowledgeSelection`, now captures nothing. The base list still fills the picker. The daemon's block is what the save falls back to, and `knowledge_bases_for_session` produces one whenever a base exists. Without a block, the workflow says nothing about knowledge bases, which the runtime treats as "leave the selection alone". So that an empty picker does not read as "this chat uses no knowledge bases", the picker says why: "Could not load this chat's knowledge bases, so none were selected automatically." The notice is a `Note` with `role="status"`, because the settings visual vocabulary makes every in-place notice one. It clears when the generation brings the daemon's block, because the selection is known then. Tests: no bases captured without a block, the notice, and the late failed read that overwrote the block. All three fail before this change. A fourth covers the usual order (the read fails first, the block arrives later). It passes before the change as a guard, and fails if the notice is not cleared.
Broccolito
added a commit
that referenced
this pull request
Sep 11, 2026
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 11, 2026
Seventeen PRs landed since this branch left 7c96d79. Seven files conflicted; every resolution keeps both sides' gates. - auth.rs: the served-operator standing (SD-10) and #231's failed-auth budget constants were added at the same spot. Both kept. - commands/agent.rs: #226 gave run() an exit_with_parent parameter. Taken, beside served_operator_capability(), which is unchanged. - routes/web_ui.rs: doc comment only. #226's paragraph (the token is not consumed, SD-9) and this branch's (the cookie's one narrowing reader, SD-10) both kept. - serve-decisions.md: #226's SD-9 and this branch's SD-10 were both appended after SD-8. Both kept, in number order. SD-10 gains a bullet: the standing follows the address, not a person, because SD-9 made the address reusable. - MentionPopover.tsx, CreateWorkflowFromSessionModal.tsx: #235/#243's readKnowledgeSelection() replaces this branch's inline getActive(). It sends the proof and returns null on failure, and its capture-nothing-on-failure path is kept. listBases() keeps the proof: under this branch the daemon omits a private base from a caller without it, which main did not. - KnowledgeContext.tsx: comments only. Both sides already sent the proof, and main's explanatory comments are taken. Also reconciled: browser-access.md no longer says the cookie does "nothing else" (it narrows listings, SD-10), and CLAUDE.md points at SD-1..SD-10. No route came in from main that names a chat or a knowledge base, and every gated call in the renderer files main changed still sends the proof.
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.
What was wrong
Two consumers of
GET /knowledge/activeread it withthrowOnError: false. When the read failed,datawas undefined, and both read that as the daemon saying "no base is hidden, none is primary":/palette (MentionPopover.tsx) computedhidden_kbs ?? []andprimary_kb ?? active_kb ?? null. It offered every base as "Knowledge base in this chat", including the ones the chat had hidden, and named no primary.CreateWorkflowFromSessionModal.tsx) computedvisibleas every base and the default asvisible[0].handleCreateWorkflowprefers that state over the generated workflow's ownknowledge_basesblock whenever it is non-empty, so the workflow saved every base.Now that the read carries the user's proof (#235), a failure here is a genuine error: a surface that cannot prove the person, a dropped connection, an older daemon. None of those is a statement about the chat.
KnowledgeContext.tsxalready states the rule forlistBases, and it holds here: a failed request is not an empty answer.The modal's claim is the costly one.
apply_knowledge_selectionmakes a workflow'svisiblelist the set of every chat the workflow starts. So every base, hidden ones included, would have been switched on in each of those chats.Decisions
The
/palette offers every base but makes no claim about the chatThe bases stay on offer because a reference names its base by id, and an explicit id reaches a base whatever the chat's selection.
kb_id_or_primaryin the knowledge server says so: "An explicitkb_idalways wins and is never filtered against the session's set." Offering a base doesn't grant access to it: the privacy tier is enforced at the knowledge server's read choke point either way. Omitting the rows was the other option. It would make a transient failure look like "this chat has no knowledge bases", which is also a claim.The create-workflow modal captures nothing, falls back to the daemon's block, and says so
listBases, so the user can choose.knowledge_basesitself (knowledge_bases_for_session) whenever any base exists, and the save path already falls back to it. A block that is already in place is no longer overwritten.knowledge_baseskey, andapply_knowledge_selectionleaves the new chat's selection alone.Notewithrole="status", since the settings visual vocabulary (which coverscomponents/workflows/) makes every in-place notice one. It clears when the generation brings the daemon's block, because the selection is known then.Found along the way
Promise.allwith commands, skills and extensions, and a rejection took all of them down. The new reader never rejects.hasAnalyzedflips, the effect is torn down, and a late answer is dropped. See "Not in this PR".Changes
components/knowledge/knowledgeSelection.ts(new):readKnowledgeSelection(sessionId)is the one reader for a surface that shows or saves a chat's selection. It sendsuserActionHeaders(), resolves tonullon any failure, never rejects, and logs one line throughbriefSelectionFailure.readPrimaryandreadHiddenmove here fromKnowledgeContextunchanged.components/MentionPopover.tsx: rows come from the reader, andknowledgeBaseRoledecides what a row may claim.components/workflows/CreateWorkflowFromSessionModal.tsx: a failed read captures nothing and setsknowledgeSelectionUnreadunless the daemon's block is already there. The block clears it.components/workflows/shared/WorkflowResourcePicker.tsxandWorkflowFormFields.tsx: an optionalnotice/knowledgeBaseNotice, rendered as<Note role="status">.There are three commits: a refactor with no behavior change, then the palette, then the modal. Each typechecks, lints, and passes the three affected test files on its own.
Tests
All six of the new tests that cover the bug fail with the production change reverted:
{ default: 'soul', visible: [all three] }knowledge_baseskeyrole="status"Verification
npm run test:run: 443 of 443 files, 4981 passed, 1 skipped.npm run lint:check: passes (typecheck, ESLint, themes, 332 contrast assertions, tokens).npx prettier --checkon the eight changed files: passes.Not in this PR
listBasesis slower than the generation plus 500 ms, the picker has no items. This bug predates the change. It's rare, because the generation waits on a model.listBasesin the modal still gives an empty derivation. Nothing wrong is saved, because the save falls back to the daemon's block, but the picker would say "No knowledge bases found".visible[0]as the workflow'sdefault, on both the read path and the generation path, andapply_knowledge_selectionthen pins that base as each new chat's primary. The daemon's own rule says the opposite, in the doc onplan_workflow_knowledge_selection(routes/agent.rs): the primary comes only fromdefault, and the merged model forbids inventing the pointer, because it is the target of KB-less writes. This predates the change and isn't on the failure path.🤖 Generated with Claude Code