Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions ui/desktop/src/components/MentionPopover.privateChat.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MentionPopover from './MentionPopover';
import { reachGatedGetActive, USER_ACTION_KEY } from '../test/reachGate';

const mocks = vi.hoisted(() => ({
getActive: vi.fn(),
listBases: vi.fn(),
getSlashCommands: vi.fn(),
getSessionExtensions: vi.fn(),
// ONE array, as the real context's state is: a fresh `[]` per render
// recreates `loadReferenceItems` every render, and the palette reloads itself
// in a loop that detaches every row it has just drawn.
extensionsList: [] as never[],
}));

vi.mock('../api', () => ({
getActive: mocks.getActive,
listBases: mocks.listBases,
getSlashCommands: mocks.getSlashCommands,
getSessionExtensions: mocks.getSessionExtensions,
}));

vi.mock('./ConfigContext', () => ({
useConfig: () => ({ extensionsList: mocks.extensionsList }),
}));

vi.mock('./skills/useSkillCatalog', () => ({
fetchSkillCatalog: vi.fn(async () => ({ generation: 0, roots: [], skills: [], bundles: [] })),
pickerBundles: () => [],
standaloneSkills: () => [],
}));

const PRIVATE_CHAT = 'chat-private';

function base(id: string, name: string) {
return { id, name, color: '#cf6d47', created_at: '', schema_version: 1, tier: 'public' };
}

function renderPalette() {
return render(
<MentionPopover
isOpen
isSlashCommand
query="kb"
sessionId={PRIVATE_CHAT}
workingDir="/w"
position={{ x: 0, y: 400 }}
selectedIndex={0}
onSelectedIndexChange={() => {}}
onSelect={() => {}}
onClose={() => {}}
/>
);
}

/**
* Issue #56 Task 58: the `/` palette's knowledge-base rows come from the chat's
* selection, and `GET /knowledge/active` naming a PRIVATE chat is on the
* daemon's reach gate. Measured in the running desktop app on 2026-09-11: the
* read went out with no proof, was refused, and the palette fell back to "no
* base is hidden, none is primary" — offering a base the chat had hidden as
* "Knowledge base in this chat", and naming no primary at all.
*/
describe('the / palette in a private chat', () => {
let savedElectron: unknown;
let savedScrollIntoView: PropertyDescriptor | undefined;

beforeEach(() => {
vi.clearAllMocks();
savedElectron = (window as { electron?: unknown }).electron;
Object.assign(window, {
electron: { getUserActionKey: vi.fn(async () => USER_ACTION_KEY) },
});
// jsdom has no `scrollIntoView`, and the palette scrolls its selected row
// into view on every render — an effect that throws unmounts the palette.
savedScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView');
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
writable: true,
value: vi.fn(),
});
mocks.getSlashCommands.mockResolvedValue({ data: { commands: [] } });
mocks.getSessionExtensions.mockResolvedValue({ data: { extensions: [] } });
mocks.listBases.mockResolvedValue({
data: [
base('soul', 'Soul'),
base('lab-notes', 'Lab notes'),
base('grant-drafts', 'Grant drafts'),
],
});
mocks.getActive.mockImplementation(
reachGatedGetActive([PRIVATE_CHAT], (sessionId) =>
sessionId
? {
kb_ids: ['lab-notes', 'soul'],
primary_kb: 'lab-notes',
active_kb: 'lab-notes',
hidden_kbs: ['grant-drafts'],
}
: { kb_ids: ['grant-drafts', 'lab-notes', 'soul'], primary_kb: 'soul', hidden_kbs: [] }
)
);
});

afterEach(() => {
Object.assign(window, { electron: savedElectron });
if (savedScrollIntoView) {
Object.defineProperty(Element.prototype, 'scrollIntoView', savedScrollIntoView);
} else {
delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView;
}
});

it("offers this chat's knowledge bases, not every base, and names its primary", async () => {
renderPalette();

expect(await screen.findByText('kb:Lab notes')).toBeInTheDocument();
expect(screen.getByText('Primary knowledge base · lab-notes')).toBeInTheDocument();
expect(screen.getByText('kb:Soul')).toBeInTheDocument();
expect(screen.queryByText('kb:Grant drafts')).not.toBeInTheDocument();
expect(mocks.getActive).toHaveBeenCalledWith(
expect.objectContaining({
query: { session_id: PRIVATE_CHAT },
headers: { 'X-User-Action': USER_ACTION_KEY },
})
);
});
});
16 changes: 12 additions & 4 deletions ui/desktop/src/components/MentionPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ItemIcon } from './ItemIcon';
import BuiltInBadge from './ui/BuiltInBadge';
import { CommandType, getActive, getSessionExtensions, getSlashCommands, listBases } from '../api';
import type { CatalogView } from '../api';
import { userActionHeaders } from '../utils/userAction';
import { getInitialWorkingDir } from '../utils/workingDir';
import { IMAGE_EXTENSIONS } from '../utils/imageFormats';
import { labelledRefTag, refTag, type RefKind } from '../utils/resourceRefs';
Expand Down Expand Up @@ -549,10 +550,17 @@ const MentionPopover = forwardRef<
? getSlashCommands({ throwOnError: true })
: Promise.resolve({ data: { commands: [] } }),
listBases({ throwOnError: false }),
getActive({
query: sessionId ? { session_id: sessionId } : undefined,
throwOnError: false,
}),
// Issue #56 Task 58: a GET naming a PRIVATE chat needs the user's
// proof, as `setActive` sends it. Refused, the rows below fell back
// to "nothing hidden, nothing primary" and offered every base as
// one this chat has.
userActionHeaders().then((headers) =>
getActive({
query: sessionId ? { session_id: sessionId } : undefined,
headers,
throwOnError: false,
})
),
// The daemon's catalog, not a renderer scan: a skill bundled inside
// an installed extension was loadable by the model and absent from
// this list, so `@skill:word` completed to nothing (#113).
Expand Down
90 changes: 89 additions & 1 deletion ui/desktop/src/components/knowledge/KnowledgeContext.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { KnowledgeProvider, useKnowledge } from './KnowledgeContext';
import { reachGatedGetActive, USER_ACTION_KEY } from '../../test/reachGate';

/** A promise the test resolves by hand, so "after the response settled" is a fact, not a race. */
function deferred<T>() {
Expand Down Expand Up @@ -583,4 +584,91 @@ describe('KnowledgeContext', () => {
expect(mocks.setActive).not.toHaveBeenCalled();
});
});

// Issue #56 Task 58. `GET /knowledge/active` naming a PRIVATE chat is on the
// reach gate's list, and the desktop app gets through it the way `setActive`
// already does: with the user's proof. The reads carried none, so the daemon
// refused every private chat's selection — whatever model was bound — and the
// chip fell back to whatever this renderer had cached, which the daemon may
// have moved past (the agent's `kb_set_active`, the CLI, another window).
describe('a private chat', () => {
let savedElectron: unknown;

beforeEach(() => {
savedElectron = (window as { electron?: unknown }).electron;
Object.assign(window, {
electron: { getUserActionKey: vi.fn(async () => USER_ACTION_KEY) },
});
mocks.getActive.mockImplementation(
reachGatedGetActive(['chat-1'], (sessionId) =>
sessionId ? daemon.session : daemon.machine
)
);
});

afterEach(() => {
Object.assign(window, { electron: savedElectron });
});

it('hydrates its selection from the daemon, not from what this renderer cached', async () => {
localStorage.setItem('knowledge_active_kb:chat-1', 'beta');
localStorage.setItem('knowledge_hidden_kbs:chat-1', '[]');

renderProvider();

await waitFor(() => expect(screen.getByTestId('primary').textContent).toBe('alpha'));
expect(screen.getByTestId('hidden').textContent).toBe('beta');
expect(mocks.getActive).toHaveBeenCalledWith(
expect.objectContaining({
query: { session_id: 'chat-1' },
headers: { 'X-User-Action': USER_ACTION_KEY },
})
);
});

// Seeded to match the daemon, so the first read cannot be what puts the
// selection back: only the recovery read can.
it('re-reads its selection with the proof when a write does not land', async () => {
localStorage.setItem('knowledge_active_kb:chat-1', 'alpha');
localStorage.setItem('knowledge_hidden_kbs:chat-1', '["beta"]');
const pending = deferred<unknown>();
renderProvider();
await waitFor(() => expect(mocks.getActive).toHaveBeenCalledTimes(1));
await settle(() => {});

mocks.setActive.mockReturnValue(pending.promise);
await userEvent.click(screen.getByRole('button', { name: 'make beta primary' }));
expect(screen.getByTestId('primary').textContent).toBe('beta');

await settle(() => pending.reject(new Error('network down')));

await waitFor(() => expect(mocks.getActive).toHaveBeenCalledTimes(2));
expect(mocks.getActive.mock.calls[1]?.[0]).toMatchObject({
query: { session_id: 'chat-1' },
headers: { 'X-User-Action': USER_ACTION_KEY },
});
await waitFor(() => expect(screen.getByTestId('primary').textContent).toBe('alpha'));
expect(screen.getByTestId('hidden').textContent).toBe('beta');
});

// What the refused read cost, measured in the running app on 2026-09-11:
// the chip showed a hidden base as switched on, and one click on another
// base wrote that stale set back — the hidden base was in the chat again.
// A set-only edit is only as right as the set it starts from.
it('toggles from the set the daemon holds, so a toggle cannot re-expose a hidden base', async () => {
localStorage.setItem('knowledge_hidden_kbs:chat-1', '[]');
renderProvider();
await waitFor(() => expect(mocks.getActive).toHaveBeenCalled());
// Let the hydrate's answer land, whichever way the daemon answered it.
await act(async () => {
await Promise.allSettled(mocks.getActive.mock.results.map((result) => result.value));
});

await userEvent.click(screen.getByRole('button', { name: 'toggle alpha' }));

await waitFor(() => expect(mocks.setActive).toHaveBeenCalled());
const calls = mocks.setActive.mock.calls;
expect(calls[calls.length - 1]?.[0]?.body.hidden_kbs).toEqual(['alpha', 'beta']);
});
});
});
30 changes: 24 additions & 6 deletions ui/desktop/src/components/knowledge/KnowledgeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ export function KnowledgeProvider({
try {
const res = await getActive({
query: sessionId ? { session_id: sessionId } : undefined,
// The same proof the hydrate below sends, for the same reason.
headers: await userActionHeaders(),
throwOnError: true,
});
if (generation !== selectionGenerationRef.current) return;
Expand Down Expand Up @@ -455,19 +457,35 @@ export function KnowledgeProvider({
try {
const res = await getActive({
query: sessionId ? { session_id: sessionId } : undefined,
// Issue #56 Task 58: a GET naming a PRIVATE chat is on the reach
// gate's list exactly as the POST in `syncSelection` is, and the
// desktop gets through it the same way — by proving the person. It
// reaches nothing new: the same proof already reads the chat's whole
// transcript through `getSession`, which says far more than which
// knowledge bases the chat uses.
headers: await userActionHeaders(),
throwOnError: true,
});
if (cancelled || generation !== selectionGenerationRef.current) return;
applyPrimary(readPrimary(res.data));
applyHidden(readHidden(res.data) ?? []);
} catch (err) {
if (cancelled) return;
// ⚠ One quiet line, deliberately. A private chat opened while a
// public model is bound refuses this read with a ~900-character
// paragraph addressed to an AI agent, and printing it here made a
// correct outcome — the chat opened and rendered in full — look like a
// crash. The person's answer is the composer's pinned-model note; see
// `selectionWarning.ts` for the full reasoning.
// ⚠ One quiet line, deliberately: the gate's refusal is ~900
// characters addressed to an AI agent, and its first sentence is all a
// person reading the console needs (`selectionWarning.ts`).
//
// ⚠ Until 2026-09-11 this comment called that refusal "a correct
// outcome" for "a private chat opened while a public model is bound".
// Neither half held. This read carried no proof, so the daemon refused
// it for EVERY private chat, whatever model was bound — measured with a
// chat on its own private model — and what followed was not correct:
// the chip showed this renderer's cache instead of the daemon's
// selection, and the next toggle wrote that stale set back over it. A
// refusal still lands here for a caller that genuinely has neither the
// proof nor a private model (a daemon started without a user-action
// key, a browser tab not running a private model), and only there is
// it the right answer.
console.warn('Knowledge selection not hydrated:', briefSelectionFailure(err));
setPrimaryKbIdState(local);
setHiddenKbIdsState(localHidden);
Expand Down
13 changes: 8 additions & 5 deletions ui/desktop/src/components/knowledge/selectionWarning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest';
import { briefSelectionFailure } from './selectionWarning';

/**
* F14. Opening a private chat while a public model is bound refuses
* `GET /knowledge/active` with a paragraph addressed to an AI agent. The
* refusal is correct and stays exactly as it is; what was wrong is that it was
* printed in full in the devtools console of a desktop app, where the chat had
* opened and rendered correctly and the only reader is a person.
* F14. A request naming a private chat on `GET /knowledge/active` with neither
* the user's proof nor a private model is refused with a paragraph addressed to
* an AI agent. The refusal stays exactly as it is; what was wrong is that it was
* printed in full in a devtools console, whose only reader is a person.
*
* (F14 took the desktop app's refusal for a correct one, on a chat bound to a
* public model. It was not: the desktop's read carried no proof, and was refused
* for every private chat — see `KnowledgeContext.tsx`.)
*/
const AGENT_REFUSAL =
'That chat is private, or there is no chat with that id. This request was made on a public ' +
Expand Down
34 changes: 18 additions & 16 deletions ui/desktop/src/components/knowledge/selectionWarning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,25 @@ const MAX_REASON_CHARS = 160;
* A failed knowledge-selection read, reduced to one line for the console.
*
* ⚠ **The paragraph this trims is deliberate, correct, and written for somebody
* else.** Opening a private chat while a public model is bound refuses
* `GET /knowledge/active` with ~900 characters addressed to an AI AGENT — "Do
* not retry as you are… If this task genuinely needs that chat, stop and ask
* the user to open it for you." That text is privacy-critical, pinned by
* repo-grep tests in `crates/biorouter-server/src/routes/session_reach.rs`, and
* nothing here changes it or should.
* else.** A request naming a private chat on `GET /knowledge/active` with
* neither the user's proof nor a private model behind it is refused with ~900
* characters addressed to an AI AGENT — "Do not retry as you are… If this task
* genuinely needs that chat, stop and ask the user to open it for you." That
* text is privacy-critical, pinned by repo-grep tests in
* `crates/biorouter-server/src/routes/session_reach.rs`, and nothing here
* changes it or should. In a console its only reader is a person, who has no
* task, is not an agent and cannot open the chat "for" anybody, so the console
* keeps a one-line record: the read did not settle, and which one.
*
* What was wrong is where it landed: in the devtools console of a **desktop**
* app, where the chat had opened correctly and rendered in full, and where the
* only reader is a person who has no task, is not an agent, and cannot open the
* chat "for" anybody. A normal, correct outcome printed as a wall of red-flag
* prose reads as a crash.
*
* So the console keeps a one-line record — the read did not settle, and which
* one — and the user-facing answer lives where a person will actually see it:
* the composer's own note about the model this chat is pinned to
* (`privacy/PinnedModelNote`).
* ⚠ **Until 2026-09-11 this said the refusal was "a normal, correct outcome"
* for a private chat opened in the desktop app while a public model was bound.
* It was neither.** The desktop's reads carried no proof, so the daemon refused
* every private chat whatever model was bound (measured with a chat on its own
* private model), and the renderer went on to show — and write back — the
* selection it had cached. The reads carry `userActionHeaders()` now, as the
* write always did (`KnowledgeContext.tsx`). The refusal still reaches a
* console from a caller that genuinely has neither: a daemon started without a
* user-action key, or a browser tab not running a private model.
*
* Trimming to the first sentence rather than to a fixed prefix is what keeps
* this useful for the failures that are NOT the refusal: "Failed to fetch" and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Button } from '../ui/button';
import { WorkflowFormFields } from './shared/WorkflowFormFields';
import { WorkflowFormData } from './shared/workflowFormSchema';
import { createWorkflow, getActive, getSessionExtensions, listBases } from '../../api/sdk.gen';
import { userActionHeaders } from '../../utils/userAction';
import { WorkflowParameter } from './shared/workflowFormSchema';
import { toastError } from '../../toasts';
import { saveWorkflow } from '../../workflow/workflow_management';
Expand Down Expand Up @@ -107,7 +108,12 @@ export default function CreateWorkflowFromSessionModal({

Promise.all([
listBases({ throwOnError: false }),
getActive({ query: { session_id: sessionId }, throwOnError: false }),
// Issue #56 Task 58: this modal opens from the chat it names, and a GET
// naming a PRIVATE chat needs the user's proof. Refused, the capture
// took every base, with whichever came first as its default.
userActionHeaders().then((headers) =>
getActive({ query: { session_id: sessionId }, headers, throwOnError: false })
),
]).then(([basesRes, activeRes]) => {
if (cancelled) return;
const bases: Manifest[] = basesRes.data ?? [];
Expand Down
Loading
Loading