Skip to content

Desktop: a failed knowledge-selection read is not an empty answer (/ palette, workflow capture) - #243

Merged
Broccolito merged 6 commits into
mainfrom
claude/happy-brattain-5fffb0
Sep 11, 2026
Merged

Desktop: a failed knowledge-selection read is not an empty answer (/ palette, workflow capture)#243
Broccolito merged 6 commits into
mainfrom
claude/happy-brattain-5fffb0

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Stacked on the proof for knowledge-selection reads (#235). Merge that first. This PR targets main rather than #235's branch because the repo auto-deletes a merged head branch, which would close a PR based on it. Until #235 merges, its three commits show here too. This PR's own change is the last three commits: compare against #235's branch.

What was wrong

Two consumers of GET /knowledge/active read it with throwOnError: false. When the read failed, data was undefined, and both read that as the daemon saying "no base is hidden, none is primary":

  • The / palette (MentionPopover.tsx) computed hidden_kbs ?? [] and primary_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.
  • The create-workflow modal (CreateWorkflowFromSessionModal.tsx) 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.

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.tsx already states the rule for listBases, and it holds here: a failed request is not an empty answer.

The modal's claim is the costly one. apply_knowledge_selection makes a workflow's visible list 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 chat

Read answered Read failed, before Read failed, after
Bases offered the chat's all all
Row label "Primary knowledge base" or "Knowledge base in this chat" "Knowledge base in this chat" on every row "Knowledge base"
Primary named yes no no
Read that rejects n/a the whole palette empties (commands, skills, extensions too) same as a failed read

The 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_primary in the knowledge server says so: "An explicit kb_id always 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

  • A failed read captures nothing. The picker still lists every base from listBases, so the user can choose.
  • The daemon's block takes over. On a successful generation, the daemon builds knowledge_bases itself (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.
  • With no block, the generation failed and the user is filling the form in, or the daemon predates the block. The workflow is then saved with no knowledge_bases key, and apply_knowledge_selection leaves the new chat's selection alone.
  • The picker says why nothing is selected, so an empty picker doesn't read as "this chat uses no knowledge bases": "Could not load this chat's knowledge bases, so none were selected automatically." It is a Note with role="status", since the settings visual vocabulary (which covers components/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

  • A rejected read emptied the palette. The selection read shares a Promise.all with commands, skills and extensions, and a rejection took all of them down. The new reader never rejects.
  • The modal's overwrite only happens inside a ~500 ms window. It lost in two orders: with no generated block, and when the failed read landed after the generation's block but before the form showed. After that window hasAnalyzed flips, 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 sends userActionHeaders(), resolves to null on any failure, never rejects, and logs one line through briefSelectionFailure.
    • readPrimary and readHidden move here from KnowledgeContext unchanged.
  • components/MentionPopover.tsx: rows come from the reader, and knowledgeBaseRole decides what a row may claim.
  • components/workflows/CreateWorkflowFromSessionModal.tsx: a failed read captures nothing and sets knowledgeSelectionUnread unless the daemon's block is already there. The block clears it.
  • components/workflows/shared/WorkflowResourcePicker.tsx and WorkflowFormFields.tsx: an optional notice / 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:

Test Before After
Palette, read refused (the shared gate model with a bridge that has no proof) rows say "Knowledge base in this chat" "Knowledge base · ", no primary
Palette, read fails in transit same same as above
Palette, read rejects empty palette same as above
Modal, no generated block saved { default: 'soul', visible: [all three] } no knowledge_bases key
Modal, the notice absent present, role="status"
Modal, failed read lands after the generation's block "3 KBs selected", every base saved the daemon's block is kept
Modal, read fails first and the block arrives later (guard) passes passes; fails if the notice isn't cleared (mutation-checked)

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 --check on the eight changed files: passes.
  • Verified by unit tests only. I didn't produce a failed read in the running app.

Not in this PR

  • The modal drops any answer that lands after the analysis window, including a successful selection read and the base list. If listBases is 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.
  • A failed listBases in 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".
  • The modal invents a primary. When the chat has none, it writes visible[0] as the workflow's default, on both the read path and the generation path, and apply_knowledge_selection then pins that base as each new chat's primary. The daemon's own rule says the opposite, in the doc on plan_workflow_knowledge_selection (routes/agent.rs): the primary comes only from default, 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

…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
Broccolito merged commit 4b56122 into main Sep 11, 2026
16 checks passed
@Broccolito
Broccolito deleted the claude/happy-brattain-5fffb0 branch September 11, 2026 20:49
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.
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