Skip to content

Desktop: a workflow never invents a primary knowledge base (capture, picker) - #256

Merged
Broccolito merged 5 commits into
mainfrom
claude/great-panini-672c85
Sep 12, 2026
Merged

Desktop: a workflow never invents a primary knowledge base (capture, picker)#256
Broccolito merged 5 commits into
mainfrom
claude/great-panini-672c85

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

This was going to stack on the failed-selection-read fix (#243). That PR merged into main while this one was in progress, so this branch is rebased onto main and contains only its own two commits.

What was wrong

The "Create workflow from this chat" modal (CreateWorkflowFromSessionModal.tsx) invented a primary knowledge base when the chat had none. It computed the captured default in two places, and both fell back to the first visible base:

  • The chat's selection read (GET /knowledge/active): primary && visible.includes(primary) ? primary : (visible[0] ?? null).
  • The generated workflow's knowledge_bases block: the same expression. The result was used both for the save path's fallback (generatedResourcesRef) and for the picker's state.

apply_knowledge_selection (crates/biorouter/src/workflow/runtime.rs) maps default: Some(id) to PrimaryUpdate::Set(id). So every chat the workflow started got a primary, the target of KB-less writes, that the chat it was captured from never had.

The daemon's rule is the opposite. plan_knowledge_selection never infers the primary from visible, and the doc on plan_workflow_knowledge_selection (routes/agent.rs) says why: a promoted primary "silently turns 'I did not say where to write' into a commit into someone's base". The Knowledge section of CLAUDE.md also says an explicit no-primary must not fall back.

Editing the selection in the modal invented a primary as well:

  • The picker (WorkflowResourcePicker.tsx) made a base the default when it was switched on and no default was set. Switching the default off handed the role to next[0].
  • The modal's onKnowledgeBaseIdsChange promoted ids[0] on any change while no default was set, including switching off an unrelated base.

Decisions

A captured selection with no primary saves default: null

Both capture paths now go through one helper, primaryAmong(primary, visible). It returns the named primary if that base is among the ones the workflow will see, and otherwise null. The daemon's block for a chat with no primary has no default key at all, because the field is skip_serializing_if = "Option::is_none". That shape reads as null too, and it's what the daemon actually sends, so it's tested alongside the literal null.

A primary outside visible becomes null, not unioned in

The guard that a named default must be in visible stays. What changes is the fallback, from visible[0] to null, rather than a union into visible the way plan_knowledge_selection does it:

  • The daemon unions because somebody wrote the workflow. A default that isn't in visible there is an author who "plainly meant it". Nobody wrote a captured selection. A captured primary that isn't in its own set is an inconsistent read, not an intent.
  • The generated block can't be inconsistent on today's daemon. It's one locked snapshot, and selection_unlocked filters the pointer against kb_ids. The guard on that path is defensive only.
  • The modal's own read can be. It is two requests, listBases and GET /knowledge/active, sent together and answered in either order. A base created or deleted between the two answers leaves the selection naming a base the list doesn't contain. The modal can't tell which of the two happened.
  • Union can break the workflow. If the base was deleted, union saves a missing base as the default. set_visible_kbs refuses Set(id) outside the resulting set, so every chat the workflow starts fails with a 400. After a failed listBases, where visible is empty, union would save visible: [primary] and hide every other base in each new chat.
  • null is the safe side. With no primary, a KB-less write fails and names the candidates. The user can still press Default in the picker. If the generated block does name the chat's primary, the save path already falls back to it (defaultKnowledgeBaseId ?? generated.default).

Switching bases on or off no longer names a primary

These are user gestures, not captures, and that is the case for keeping them. I removed them anyway. Switching a base on says "this workflow may search this base". The promotion added "and KB-less writes go here", which the gesture never asked for. It is the same promotion the daemon removed in 3a6e688 ("never promote a sole visible base to the primary"): "'exactly one candidate' was treated as consent, which it is not — the author who wants that base as the write target has a field to say so." In the picker, that field is the Default control, which is one click away on every selected row.

They also undid the capture fix. With a captured chat with no primary, switching one base off made the first remaining base the write target.

Gesture Before After
Switch a base on, no default set that base becomes the default selection only
Switch a base off, no default set the first remaining base becomes the default selection only
Switch the default off the first remaining base becomes the default no default
Press Default on a base names it names it
Press Default on the current default re-sent the same id clears it

The modal's handler keeps only the membership rule: a named default stays while its base is selected, and becomes null when it isn't.

The Default control is now a toggle (aria-pressed). Without that, a user who pressed Default on any base couldn't get back to "these bases, and no default", short of switching the base off and on again. That is the state a chat with no primary now captures. Each control's accessible name includes its row ("Default KB: lab-notes"), so the pressed state says which base it refers to. The visible label is still "Default".

Changes

Commit 1, the capture (fix(desktop): a workflow captured from a chat with no primary has no primary):

  • CreateWorkflowFromSessionModal.tsx adds primaryAmong and uses it on both capture paths. It also removes the duplicated ternary on the generation path.

Commit 2, the gestures (fix(desktop): only the Default control names a workflow's primary knowledge base):

  • WorkflowResourcePicker.tsx: toggleSelected changes only the selection, except that switching the default off clears it. The Default control is a toggle with aria-pressed and a per-row name.
  • CreateWorkflowFromSessionModal.tsx: onKnowledgeBaseIdsChange keeps the membership rule and never promotes.

The two commits are split so the second can be dropped on its own if the picker decision goes the other way. The first stands alone and passes its own tests.

Tests

CreateWorkflowFromSessionModal.test.tsx, new block "the workflow's primary knowledge base". Each test asserts the saved knowledge_bases:

Test Before After
Chat has no primary, read path, generation brings no block default: 'soul' default: null
Generated block has default: null (the read fails, so the block is the only source) 'lab-notes' null
Generated block has no default key (the daemon's wire shape) 'lab-notes' null
The selection names a primary the base list lacks 'soul' null (a union would give 'new-notes')
The generated block names a default outside its own visible 'lab-notes' null
Switch a base on 'grant-drafts', measured on commit 1 alone null
Switch another base off 'soul', on commit 1 null
Switch the primary off 'soul', on commit 1 null

WorkflowResourcePicker.test.tsx (new file):

Test Before After
Switching a base on leaves the default unset onDefaultIdChange('soul') not called
Switching the default off clears it onDefaultIdChange('soul') onDefaultIdChange(null)
The Default control names a base and clears it on a second press no pressed state; the second press re-sends the id aria-pressed, then null
Switching another base off leaves the default alone (guard) passes passes; it catches a fix that clears the default on every toggle

Mutation checks, run in stages with the final tests:

  • On the original code, 11 of the 12 new tests fail. The guard passes.
  • With only the capture fixed, the 6 gesture tests fail.
  • With only the handler also fixed, "switch a base on" still saves 'grant-drafts', because the picker names it.
  • With the handler reverted and the picker fixed, "switch a base on" and "switch another base off" save 'soul', because the handler promotes ids[0].

The shared settleReads and saveTheWorkflow helpers move to module scope in the modal test so both blocks can use them. They are unchanged otherwise.

Verification

  • The two test files pass 39 of 39. The whole components/workflows/ directory passes 127 of 127 on the rebased tree.
  • npm run lint:check passes on the rebased tree: typecheck, ESLint with --max-warnings 0, themes, 332 contrast assertions, and tokens.
  • npx prettier --check on the four changed files passes, run from ui/desktop.
  • npm run test:run exited 1 on every run with no failing test of mine. The machine's load average was 400–600 on 16 cores throughout, from other sessions' cargo builds. Every run is below, including a control on pristine main:
Tree Result
This change on #243's head 1 failed file: artifactCdnAssets.browser.test.ts, whose afterAll (browser.close()) timed out at 30 s. Both of its tests passed. 4993 passed, 1 skipped.
Same tree, second run The same hook timeout, plus chatStreamStore.adversarial.test.tsx › "keeps notifying React after the backlog commits" (1 failed). Both files pass when run alone.
Rebased on main (6455bc2) The same hook timeout only. 5019 passed, 1 skipped (446 files).
Control: pristine main (6455bc2) The same hook timeout. 5007 passed, 1 skipped (445 files).

5019 = 5007 + the 12 new tests, and 446 files = 445 + the new picker test file.

  • Commits carry no Co-Authored-By trailer.
  • Verified by unit tests only. I didn't create a workflow from a chat with no primary in the running app.

Not in this PR

  • An explicitly empty selection saves no knowledge_bases key. Measured with a throwaway test: switch every base off in the picker, and the saved workflow has no key, so every chat it starts sees every base. The same happens for a chat that hid every base: the daemon's block for it is {}, meaning "hide everything", and the save path's length > 0 || default check drops it. The failed-read case, where no key is right, has to stay distinguishable. Flagged as a separate task.
  • A failed knowledge-selection apply leaves the new chat behind. In routes/agent.rs, apply_workflow_knowledge_selection(...)? returns early without discard_failed_new_session, unlike the provider-bind and prompt failures just above it. A workflow whose default names a since-deleted base makes every start fail and leaves a chat behind each time. Flagged as a separate task.
  • The modal re-derives the visible set instead of reading it. The selection answer already carries kb_ids, the daemon's own coherent visible set. The modal instead computes listBases − hidden from two requests, which is the only reason its primary can fall outside visible. KnowledgeSelection (shared with the / palette) doesn't expose kb_ids today.

🤖 Generated with Claude Code

…primary

The create-workflow modal computes the captured default in two places: from the
chat's own selection read, and from the generated workflow's `knowledge_bases`
block. Both fell back to `visible[0]` when the chat named no primary, so the
saved workflow's `default` was the first visible base. `apply_knowledge_selection`
maps `default: Some(id)` to `PrimaryUpdate::Set(id)`, so every chat the workflow
started got a primary, the target of KB-less writes, that the chat it was
captured from never had.

The daemon's rule is the opposite. `plan_knowledge_selection` never infers the
primary from `visible`, and the doc on `plan_workflow_knowledge_selection` says
why: a promoted primary turns "I did not say where to write" into a commit into
someone's base. With no primary, a KB-less write fails and names the candidates.

Both paths now go through `primaryAmong`: the named primary if it is among the
bases the workflow will see, and otherwise `null`. The daemon's block for a
chat with no primary omits `default` entirely (`skip_serializing_if`), and
that reads as `null` too.

A primary outside `visible` becomes `null`, not unioned in the way the daemon
unions an author's `default`. The daemon does that because somebody wrote the
workflow and meant it. A captured primary outside its own set is an
inconsistent read. The block is one locked snapshot and never is one, but the
modal's own read is two requests (the base list and the selection), and a base
created or deleted between their answers leaves the selection naming a base the
list lacks. Unioning could save a deleted base as the default, which
`set_visible_kbs` refuses, so every chat the workflow starts would fail with a
400. After a failed list it would save the primary as the only visible base,
hiding every other one.

Tests: the read path with no generated block; the generated block with
`default: null` and with `default` absent (the daemon's own wire shape); and a
primary outside the visible bases, once from the read and once from the block.
All five fail before this change: each saved the first visible base.
…wledge base

The previous commit stops the create-workflow modal from inventing a primary when
it captures a chat's selection. Editing the selection afterwards still invented
one, in two places:

- The picker (`WorkflowResourcePicker`). Switching a base on made it the
  default whenever none was set, and switching the default off handed the role
  to `next[0]`.
- The modal's `onKnowledgeBaseIdsChange`. It promoted `ids[0]` on any change
  while no default was set, including switching off an unrelated base.

So a user who captured a chat with no primary and then removed one base got the
first remaining base as the write target of every chat the workflow starts.

These are user gestures, not captures, which is the case for keeping them. They
go anyway. The gesture is "this workflow may search this base", and the
promotion adds "and KB-less writes go here", which the user did not ask for.
It is the promotion the daemon removed in 3a6e688 ("never promote a sole visible
base to the primary"): "exactly one candidate" was treated as consent, which it
is not, and the author who wants that base as the write target has a field to
say so. In the picker, that field is the Default control, one click away on
every selected row.

- Switching a base on changes the selection only.
- Switching the default off leaves no default, rather than passing it on.
- The modal's handler keeps only the membership rule: a named default stays
  while its base is selected and becomes `null` when it is not.
- The Default control is now a toggle (`aria-pressed`), so pressing the current
  default clears it. Without that, "these bases, and no default", the state a
  chat with no primary now captures, could not be restored once any base had
  been made the default, short of switching it off and on again. Each control
  is named for its row ("Default KB: lab-notes") so the pressed state says
  which base it belongs to.

Tests, in the modal: switching a base on, switching another off, and switching
the primary off each save no primary. All three fail on the previous commit.
Switching a base on still fails with only the handler fixed (the picker names
the base) or only the picker fixed (the handler names `ids[0]`). In the picker:
switching on leaves the default unset, switching the default off clears it,
and the Default control names a base and clears it when pressed again. All
three fail before this change. A fourth, switching another base off leaves the
default alone, passes either way as a guard against clearing it on every
toggle.
No conflicts. The point of the merge is PR #262, which fixes the harness
defect this branch's `Unit tests (vitest)` run tripped over:

  FAIL src/components/MentionPopover.privateChat.test.tsx
       > offers this chat's knowledge bases, not every base, and names its primary
  TypeError: selectedElement.scrollIntoView is not a function
    at src/components/MentionPopover.tsx:819 (commitHookPassiveMountEffects)

Not this branch's feature and not an assertion: the spec installed a jsdom
`scrollIntoView` stub in `beforeEach` and DELETED it in `afterEach`, but the
palette's scroll runs from a PASSIVE effect that React flushes when
setup.ts's `cleanup()` unmounts — and vitest runs the spec's own `afterEach`
BEFORE that. The property is already gone when the queued effect fires, so
the throw lands inside React and fails a test that had already asserted its
point. The window is one scheduler turn wide, which is why it opens on a
loaded CI runner and reads as flakiness.

Measured rather than assumed:
  * the polyfill now lives once, process-wide, in ui/desktop/src/test/setup.ts
    with a regression guard in src/test/scrollIntoViewPolyfill.test.tsx —
    both present on origin/main at 99bf415 (PR #262) and on NEITHER PR
    branch, which merged an older main;
  * this branch still carried the per-test install/delete at
    MentionPopover.privateChat.test.tsx:77-78 and :109-111, and the merge
    replaces the whole spec with main's;
  * vitest.config.ts sets no `pool`/`isolate`, so the defaults (forks,
    isolate: true) apply and cross-FILE pollution is structurally
    impossible — the leak was always inside this one file.

After the merge, on this branch:
  npx vitest run --testTimeout=30000 --hookTimeout=30000
    (excluding src/utils/artifactCdnAssets.browser.test.ts, whose afterAll
     browser.close() exceeds 30 s under load on pristine main too)
  Test Files  458 passed (458)
        Tests  5179 passed | 1 skipped (5180)
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