Skip to content

Desktop: read a private chat's knowledge selection with the user's proof (KB chip, / palette, workflow capture) - #235

Merged
Broccolito merged 3 commits into
mainfrom
claude/priceless-darwin-158b11
Sep 11, 2026
Merged

Desktop: read a private chat's knowledge selection with the user's proof (KB chip, / palette, workflow capture)#235
Broccolito merged 3 commits into
mainfrom
claude/priceless-darwin-158b11

Conversation

@Broccolito

@Broccolito Broccolito commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

GET /knowledge/active naming a chat is on the reach gate's list in session_reach.rs, exactly like the POST beside it. The write (setActive in KnowledgeContext.tsx) has always sent userActionHeaders(). The reads did not. They are the KnowledgeProvider hydrate and its recovery re-read, the / palette's knowledge-base rows (MentionPopover.tsx), and the create-workflow modal. So the desktop's own daemon answered each read as a public caller and refused every private chat, whatever model was bound.

Measured on the desktop (2026-09-11)

This ran in a sandboxed dev instance on versa_azure (GPT-5.5). One turn ratcheted a new chat to private. Its selection was then set, with the proof, to primary lab-notes and grant-drafts hidden. That stands in for the agent's kb_set_active, the CLI, or another window changing it.

Before After
GET /knowledge/active?session_id=…, no headers 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 (unchanged; the renderer no longer sends this)
Same request with X-User-Action 200, the chat's selection 200, the chat's selection
Console on opening the chat Knowledge selection not hydrated: That chat is private, or there is no chat with that id. nothing
KB chip 3 visible, Grant drafts switched on (the renderer's cache) 2 visible, Grant drafts off
One click on Soul in the chip daemon ends with hidden_kbs: ["soul"], so Grant drafts is visible to the chat again daemon ends with hidden_kbs: ["grant-drafts","soul"]
/kb palette offers kb:Grant drafts as "Knowledge base in this chat"; names no primary Lab notes as "Primary knowledge base", plus Soul

The fourth row is the reason this is more than cosmetic. A set-only edit is computed from the set the renderer holds, so a refused read turns the next toggle into a silent overwrite of the chat's real selection.

Decision: these reads carry userActionHeaders(), like getSession and reply

Left alone on purpose: refreshDefaultPrimary's machine-scope getActive. It names no chat, and the gate is inert there ("Machine-wide selection requests name no chat and remain outside the session boundary").

The comment

KnowledgeContext's catch arm called the refusal "a correct outcome" for "a private chat opened while a public model is bound". Neither half held:

  • the read was refused for every private chat, measured with the chat on its own private model;
  • what followed was not correct, as the table above shows.

The comment now says so, and names where a refusal is right: a caller with neither the proof nor a private model, meaning a daemon started without a user-action key, or a browser tab not running a private model. selectionWarning.ts and its test carried the same premise and are corrected in a separate docs commit. Their one-line console trimming is unchanged and still right.

Changes

  • components/knowledge/KnowledgeContext.tsx: the proof on the hydrate and on rehydrateSelection; the corrected comment.
  • components/MentionPopover.tsx, components/workflows/CreateWorkflowFromSessionModal.tsx: the proof on their getActive. Both keep their Promise.all parallelism.
  • test/reachGate.ts (new): one model of the gate as a renderer test meets it. A private chat is answered only with the proof, and a refusal is delivered as the generated client delivers this route's text/plain 403.
  • Tests. All five new ones fail with the production change reverted:
    • KnowledgeContext.test.tsx: the hydrate, the recovery re-read, and the toggle that re-exposed a hidden base;
    • MentionPopover.privateChat.test.tsx (new): the palette's rows and primary label;
    • CreateWorkflowFromSessionModal.test.tsx: the captured knowledge_bases (it saved default: soul and all three bases).

Verification

  • npm run test:run: 443 of 443 files, 4974 passed, 1 skipped.
    • An earlier run, made while a dev GUI was building on the same machine, hit two failures that don't recur: the known Ollama teardown rejection (OllamaInlineCard.tsx:41 in ProviderCatalog.test.tsx) and a 30 s afterAll in artifactCdnAssets.browser.test.ts. That file passes on its own, and the full run above is clean.
  • The five new tests pass 10 of 10 on repeat runs; no new act() warnings.
  • npm run lint:check passes: typecheck, ESLint, themes, 332 contrast assertions, tokens.
  • npx prettier --check passes on all nine changed files.
  • The create-workflow modal was verified by its unit test only. In the app, a successful generation usually carries the daemon's own knowledge-base block and overwrites the modal's read. The modal's read decides the capture when the generation brings none.

Not in this PR

  • When the read genuinely fails (network, older daemon), the palette and the modal still fall back to "nothing hidden, nothing primary". With the proof attached that is now only a real error, but a failed read still reads as a claim about the chat.
  • The chip re-reads the selection when a chat opens, not when something else changes it mid-chat.

🤖 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.
Broccolito added a commit that referenced this pull request Sep 11, 2026
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.
@Broccolito
Broccolito merged commit 51e4c69 into main Sep 11, 2026
16 checks passed
@Broccolito
Broccolito deleted the claude/priceless-darwin-158b11 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