From 8adb4dfe4c3e51374b28228bfc1e48ec3984a3e5 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:10:16 +0000 Subject: [PATCH 1/5] feat(settings): change a setting in the chat card, with the app's language and experiments --- docs/ci.md | 16 +- docs/cli.md | 67 +++++--- docs/language.md | 5 + packages/contracts/src/access.ts | 2 + packages/contracts/src/index.ts | 29 +++- packages/contracts/src/setting-requests.ts | 144 ++++++++++++++++-- packages/core/src/cli.ts | 35 +++-- packages/core/src/client-preferences.ts | 59 +++++++ packages/core/src/core.ts | 4 + packages/core/src/mcp/tools.ts | 4 +- packages/core/src/settings.ts | 6 +- packages/core/src/threads/cards.ts | 11 +- packages/core/src/threads/rpc.ts | 11 +- packages/core/src/threads/turn-context.ts | 6 +- packages/core/test/cli.test.ts | 47 +++++- packages/core/test/mcp.test.ts | 2 +- packages/core/test/sent-from.test.ts | 10 +- .../src/components/RightPanel.remote.test.ts | 6 +- .../src/components/SettingRequestCard.svelte | 70 ++++++--- .../src/components/SettingRequestCard.test.ts | 58 ++++++- .../ui/src/lib/client-preferences.svelte.ts | 49 ++++++ packages/ui/src/lib/experiments.ts | 7 +- packages/ui/src/lib/fake-client/context.ts | 3 + .../ui/src/lib/fake-client/conversation.ts | 1 + packages/ui/src/lib/fake-client/requests.ts | 27 +++- packages/ui/src/lib/setting-requests.ts | 21 ++- packages/ui/src/lib/store/events.ts | 4 + packages/ui/src/lib/strings.en.ts | 2 +- packages/ui/src/lib/strings.fr.ts | 2 +- scripts/ci/budgets.json | 4 +- tests/e2e/setting-cards.test.ts | 70 +++++++++ 31 files changed, 669 insertions(+), 113 deletions(-) create mode 100644 packages/core/src/client-preferences.ts create mode 100644 packages/ui/src/lib/client-preferences.svelte.ts create mode 100644 tests/e2e/setting-cards.test.ts diff --git a/docs/ci.md b/docs/ci.md index 4a7d2d7e3..6c87c518c 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -182,12 +182,24 @@ platform. On Windows `stage:core` no longer compiles `dist/boite-core.exe`: | Measure | Maximum uncompressed bytes | | --- | ---: | | UI entry chunk | 492,200 | -| UI files, excluding `.br` and `.gz` copies | 4,868,600 | +| UI files, excluding `.br` and `.gz` copies | 4,871,500 | | Core `dist/main.js` | 995,000 | -| All emitted core JavaScript, including lazy chunks and workers | 4,478,300 | +| All emitted core JavaScript, including lazy chunks and workers | 4,483,200 | The total JavaScript measure excludes native binaries and source maps. +On 2026-10-18, settings cards that draw their switch in the chat, with the +app's language, experiments and setting names reported to the core +(`client.preferences`), measure 488,751 entry bytes, 4,872,018 UI bytes and +4,483,970 core JavaScript bytes with `measure()` after fresh `bun run +build:ui` and `bun run build:core` on Linux, against 492,112, 4,869,093 and +4,479,099 for `origin/main` at `80c2da06` on the same machine: the entry loses +3,361 bytes, the UI gains 2,925 and the core 4,871 (the client preferences +book, the experiment checks and the CLI's app line). Linux reads that main +about 1,100 UI and 1,300 core bytes above the Windows figures below, so the UI +and core JavaScript limits rise by the measured growth to 4,871,500 and +4,483,200; the entry limit stays. + On 2026-10-18, keeping the prompt cache warm (PR #594) merged with `origin/main` at `3a19eb57` measures 491,872 entry bytes, 4,867,996 UI bytes and 4,477,807 core JavaScript bytes with `measure()` after fresh diff --git a/docs/cli.md b/docs/cli.md index 1bff4517d..2307a41b5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -491,25 +491,45 @@ unless the "Asynchronous questions" setting is off. Codex asks natively (`delivery: "async"`), and Boite draws those cards the same way. -`settings` helps a lost user set Boite up. Bare, it lists the settings an agent -may ask about, one row each: key, value as JSON, the page and card it sits on -(`agentCpuCapPercent 75 resources#limits`). The list is `REQUESTABLE_SETTINGS` -in `packages/contracts/src/setting-requests.ts`; credentials, account ids and -browser profiles are not in it. `settings request --why ` -draws a settings card the same way `ask` draws a question: the agent keeps -working and the card outlives the turn and a restart. The value is JSON when it -parses (`60`, `false`, `null`, `["https://a.example"]`) and the bare word -otherwise. It is refused when the key is not in the list (the refusal lists -them), when `settings.set` would refuse the value, when the setting already -holds it, or when a card on that key is already open in the thread. - -The card shows the reason, the setting's name, the current value struck -through, the proposed one and where it sits in Settings. Apply calls -`settings.set`, the call the Settings page makes, so only the owner can use it. -A change card's button to Settings (Show in Settings beside Apply, Open -Settings without it) also needs the owner, so a paired device answers a change -card with Refuse only. An open card's Open Settings is offered to any client -whose Settings draws that card. No button to Settings appears where the card is +`settings` helps a lost user set Boite up. Bare, it starts with the app the +user last used and the language it speaks (`app: the desktop app on "PC" +speaks French; ...`), then lists the settings an agent may ask about, one row +each: key, value as JSON, the page and card it sits on and, quoted, the name +that app shows (`agentCpuCapPercent 75 resources#limits "Plafond CPU de tous +les agents"`). The list is `REQUESTABLE_SETTINGS` in +`packages/contracts/src/setting-requests.ts`, then that app's experiments as +`experiment.` (`experiment.resident-agents true experiments#resident-agents +"Agents"`); credentials, account ids and browser profiles are not in it. + +Language, experiments and names come from the apps: each says them after every +hello and on every change (`client.preferences`, a paired phone included). The +core keeps them per socket, in memory only, and reads the app that last sent a +prompt or changed one of them; once that app's socket closes, the next app to +report takes its place. The language also rides on each prompt +(`SentFrom.language`), so the one-line note on where a prompt came from says +`which shows Boite in French` when a session starts and when the language +changes. Before any app reported, the first line says so and no experiment is +listed. + +`settings request --why ` draws a settings card the same +way `ask` draws a question: the agent keeps working and the card outlives the +turn and a restart. The value is JSON when it parses (`60`, `false`, `null`, +`["https://a.example"]`) and the bare word otherwise. It is refused when the +key is not in the list (the refusal lists them), when `settings.set` would +refuse the value, when the setting already holds it (`experiment.resident-agents +is already true`: the agent learns the state before the user has to say it), +when a card on that key is already open in the thread, and for an experiment +when the value is not true or false or no app has reported yet. + +The card makes the change in the chat. A true-or-false setting, an experiment +included, draws its switch showing the value now; any other value shows the +current value struck through, the proposed one and Apply. The switch and Apply +call `settings.set`, the call the Settings page makes, so only the owner can +use them; an experiment's switch flips the device's own experiment, which any +of the user's devices may do, and that app's next report settles the card. +Show in Settings stays as a secondary button for whoever can apply. A paired +device answers a core setting's card with Refuse only. An open card's Open +Settings is offered to any client whose Settings draws that card. No button to Settings appears where the card is missing: a paired device's Settings lacks the owner pages (Providers, Plugins, Protection, Brain) and the owner's cards on shared pages (worktrees, privacy, execution, browser origins, auto-compact), and a phone browser's @@ -518,10 +538,11 @@ itself: the core closes every open card whose setting left the value it was asked from, whoever moved it, from the card or from the page. The agent then reads `applied: is now ` or `changed: the user set to , not `. Refuse sends `refused: stays `. A -sensitive setting has no Apply: the network ones, the guards that keep agents -quiet and their memory protection, history retention and where worktrees go. -Its card opens Settings on the setting's card, which rings once in the accent -colour, and closes when the user changes it there. +sensitive setting (the network ones, the guards that keep agents quiet and +their memory protection, history retention and where worktrees go) is changed +in the card too, under a red shield, its heading "Sensitive setting" and a +line asking the user to check it first. Show in Settings opens the setting's +card, which rings once in the accent colour. `settings open [#] --why ` points at a page for what is not one value, a provider login (`accounts#provider-claude`) or a device to add diff --git a/docs/language.md b/docs/language.md index e741a1c22..2ede42853 100644 --- a/docs/language.md +++ b/docs/language.md @@ -10,6 +10,11 @@ way, a storage a browser refuses, a stored value from a build that had more languages, a sentence one catalogue is missing, ends in English rather than in an error. +Each app also tells the cores it is connected to which language it speaks, +with its experiments and the names it gives settings (`client.preferences`, +[cli.md](cli.md)). An agent then names a setting the way the user's screen +does, and the note ahead of a prompt says which language the app shows. + ## Picking one Settings, Appearance, at the top: System, English, Français. The first screen diff --git a/packages/contracts/src/access.ts b/packages/contracts/src/access.ts index 055ed7b9d..a4ee9e8ce 100644 --- a/packages/contracts/src/access.ts +++ b/packages/contracts/src/access.ts @@ -139,6 +139,8 @@ export const DEVICE_METHODS: ReadonlySet = new Set 'questions.answer', // Paired phones can dismiss the same question cards as the owner's chat. 'questions.skip', + // A phone says its own language and experiments, so the agent reads the screen the user reads; nothing the core trusts. + 'client.preferences', // Read-only Changes and Files panels on the phone: the thread's own changes and files. // The core runs git itself (no device command), refuses a diff against anything but // HEAD (core access.ts), and holds every path to a thread whose real working directory diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ee20b6cb2..63a1b19eb 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1561,6 +1561,8 @@ export interface ArtifactContent { url: string; bytes: number; mimeType: string; export interface SentFrom { client: 'shell' | 'pwa'; device: string | null; + /** The language the app spoke when the prompt left it (`client.preferences`); unset when it had not said. */ + language?: AppLanguage; } /** The longest `hello` `client.device` the core keeps. */ @@ -4260,10 +4262,12 @@ export interface RpcMethods extends AgentsRpcMethods, WorkflowsRpcMethods, Brows */ 'agent.projects': { params: { threadId: ThreadId }; result: AgentProject[] }; /** - * The settings an agent may ask about (`REQUESTABLE_SETTINGS`), each with - * its value and the page it sits on, so it reads before it proposes. + * The settings an agent may ask about (`REQUESTABLE_SETTINGS`, then the + * experiments of the app the user last used), each with its value, the page + * it sits on and its name in that app, so it reads before it proposes. `app` + * says which language the user reads Boite in. */ - 'agent.settings': { params: { threadId: ThreadId }; result: SettingReading[] }; + 'agent.settings': { params: { threadId: ThreadId }; result: SettingsReading }; /** * The agent registers an existing folder as a project, as the owner does * from the sidebar, so `agent.spawn` and `agent.move` can name it. `path` is @@ -4970,6 +4974,13 @@ export interface RpcMethods extends AgentsRpcMethods, WorkflowsRpcMethods, Brows */ 'subscriptionProxy.move': { params: { ids: string[]; managementToken?: string | null; target?: GatewayMoveTarget }; result: { outcomes: GatewayMoveOutcome[] } }; 'settings.set': { params: Partial; result: Settings }; + /** + * The app says its language, the experiments it has on and the names it + * gives settings, after each hello and on every change. Agents read them + * from the app the user last used (`agent.settings`), and a card about an + * experiment settles when that app reports it changed. + */ + 'client.preferences': { params: ClientPreferences; result: { ok: true } }; /** The keybindings file as last read: the path, the entries it names, and what it got wrong. */ 'keybindings.get': { params: Record; result: Keybindings }; /** @@ -5277,12 +5288,14 @@ import type { AutoCompact, CacheKeepalive } from './settings-validation.ts'; export { TITLE_MODEL_DEFAULTS, defaultTitleModel } from './title-models.ts'; export { DEVICE_METHODS, DEVICE_EVENTS, AGENT_EVENTS } from './access.ts'; export { - REQUESTABLE_SETTINGS, REQUESTABLE_SETTING_KEYS, SETTING_REASON_MAX, SETTINGS_PAGES, SETTINGS_SECTION_PATTERN, - checkSettingRequest, effectiveSetting, isRequestableSetting, isSettingsPage, readSettings, sameSetting, settingAnswerText, settledSetting, - type RequestableSetting, type SettingAsk, type SettingNormalizer, type SettingOutcome, type SettingPlace, type SettingReading, type SettingRequestCheck, - type SettingRequestParams, type SettingsPage, + APP_LANGUAGES, CLIENT_LABELS_MAX, CLIENT_LABEL_CHARS, EXPERIMENT_IDS, REQUESTABLE_SETTINGS, REQUESTABLE_SETTING_KEYS, SETTING_REASON_MAX, SETTINGS_PAGES, SETTINGS_SECTION_PATTERN, + checkClientPreferences, checkSettingRequest, currentSetting, effectiveSetting, experimentOf, isExperimentId, isRequestableSetting, isSettingsPage, readSettings, + sameClientPreferences, sameSetting, settingAnswerText, settledSetting, + type AppLanguage, type ClientPreferences, type ExperimentId, type ExperimentSettingKey, type RequestableKey, type RequestableSetting, type SettingAsk, + type SettingNormalizer, type SettingOutcome, type SettingPlace, type SettingReading, type SettingRequestCheck, type SettingRequestParams, type SettingsPage, + type SettingsReading, } from './setting-requests.ts'; -import type { SettingAsk, SettingReading, SettingRequestParams } from './setting-requests.ts'; +import type { AppLanguage, ClientPreferences, SettingAsk, SettingRequestParams, SettingsReading } from './setting-requests.ts'; export { AGENT_PROJECTS_MAX, AGENT_RUN_MINUTES, AGENT_SETTING_KEYS, agentSettingAnswerText, agentSettingValue, checkAgentSettingRequest, isAgentSettingKey, normalizeAgentSetting, settledAgentSetting, diff --git a/packages/contracts/src/setting-requests.ts b/packages/contracts/src/setting-requests.ts index d36aac30e..ec624ef4e 100644 --- a/packages/contracts/src/setting-requests.ts +++ b/packages/contracts/src/setting-requests.ts @@ -6,9 +6,72 @@ import type { QuestionAnswer, QuestionOption, Settings, ThreadId } from './index * points at a Settings page (`boite settings open`). The card never writes * anything itself. Apply calls `settings.set`, which only the owner reaches, * and the core settles the card when the value it asked about changes, from - * the card or from the page. See docs/cli.md. + * the card or from the page. An experiment is a device's own switch: the app + * reports which are on (`client.preferences`), the card flips it there, and + * the core settles the card on the next report. See docs/cli.md. */ +/** + * The experiments this build ships, in the order Settings lists them. Each + * device turns them on for itself (`packages/ui/src/lib/experiments.ts`). + */ +export const EXPERIMENT_IDS = ['theme-grain', 'session-import', 'prompt-cache', 'open-chat-links', 'preview-comments', 'resident-agents', 'whip', 'recording-indicators', 'goal2', 'edge-scrollbars', 'press-preload'] as const; +export type ExperimentId = (typeof EXPERIMENT_IDS)[number]; + +export function isExperimentId(id: unknown): id is ExperimentId { + return typeof id === 'string' && (EXPERIMENT_IDS as readonly string[]).includes(id); +} + +/** The languages the app speaks (`docs/language.md`). */ +export const APP_LANGUAGES = ['en', 'fr'] as const; +export type AppLanguage = (typeof APP_LANGUAGES)[number]; + +/** A setting key as an agent names it for an experiment: `experiment.resident-agents`. */ +export type ExperimentSettingKey = `experiment.${ExperimentId}`; + +/** + * What an app tells its core about itself, after each hello and on every + * change (`client.preferences`), so an agent reads the screen the user reads: + * the language, the experiments on, and the names it gives settings. + */ +export interface ClientPreferences { + language: AppLanguage; + experiments: ExperimentId[]; + /** + * The names the app shows, in its language: a requestable key, an + * `experiment.`, or a Settings page id. A key the app leaves out reads + * as the key itself. + */ + labels: Record; +} + +export const CLIENT_LABELS_MAX = 120; +export const CLIENT_LABEL_CHARS = 120; + +/** A report checked, or why it is refused. Labels lose control characters and keep the known keys only. */ +export function checkClientPreferences(params: unknown): { ok: true; preferences: ClientPreferences } | { ok: false; message: string; field: string } { + const given = (params ?? {}) as Partial>; + if (!(APP_LANGUAGES as readonly unknown[]).includes(given.language)) return { ok: false, message: `language must be one of ${APP_LANGUAGES.join(', ')}`, field: 'language' }; + if (!Array.isArray(given.experiments) || given.experiments.length > EXPERIMENT_IDS.length * 2) return { ok: false, message: 'experiments must be a list of experiment ids', field: 'experiments' }; + // A newer app may ship an experiment this core does not know: it is left out, not refused. + const experiments = EXPERIMENT_IDS.filter(id => (given.experiments as unknown[]).includes(id)); + const raw = given.labels ?? {}; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).length > CLIENT_LABELS_MAX) return { ok: false, message: `labels must map at most ${CLIENT_LABELS_MAX} keys to names`, field: 'labels' }; + const known = new Set([...REQUESTABLE_SETTING_KEYS, ...EXPERIMENT_IDS.map(id => `experiment.${id}`), ...SETTINGS_PAGES]); + const labels: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + if (!known.has(key) || typeof value !== 'string') continue; + // A label lands inside a line the agent reads. + const clean = value.replace(/[\p{C}\p{Zl}\p{Zp}]/gu, ' ').trim().slice(0, CLIENT_LABEL_CHARS); + if (clean.length > 0) labels[key] = clean; + } + return { ok: true, preferences: { language: given.language as AppLanguage, experiments, labels } }; +} + +export function sameClientPreferences(a: ClientPreferences | null, b: ClientPreferences | null): boolean { + return canonical(a) === canonical(b); +} + /** The Settings pages a card can open, under the ids the app's navigation uses. */ export const SETTINGS_PAGES = [ 'general', 'appearance', 'keyboard', 'voice', 'accounts', 'plugins', 'brain', @@ -72,11 +135,28 @@ export function isSettingsPage(page: unknown): page is SettingsPage { /** A section anchor as the app writes them: lowercase words joined by dashes. */ export const SETTINGS_SECTION_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** A key a card may change: a core setting, or an experiment of the user's app. */ +export type RequestableKey = RequestableSetting | ExperimentSettingKey; + +export function experimentOf(key: string): ExperimentId | null { + const id = key.startsWith('experiment.') ? key.slice('experiment.'.length) : null; + return isExperimentId(id) ? id : null; +} + /** One readable setting, as `agent.settings` lists it. */ export interface SettingReading extends SettingPlace { - key: RequestableSetting; + key: RequestableKey; /** The value now; null where the core keeps none and uses its default. */ value: unknown; + /** Its name in the user's app, in the app's language, when the app said it. */ + label?: string; +} + +/** `agent.settings`: the app the user last used, then every readable setting. */ +export interface SettingsReading { + /** The app the user last used: its language and the computer or phone it runs on. Null before any app reported. */ + app: { language: AppLanguage; device: string | null; client: 'shell' | 'pwa' | null } | null; + settings: SettingReading[]; } /** @@ -87,7 +167,8 @@ export interface SettingReading extends SettingPlace { export type SettingAsk = | { kind: 'change'; - key: RequestableSetting; + /** A core setting, or `experiment.`: a switch of the app the user last used. */ + key: RequestableKey; /** The proposed value, as `settings.set` would store it. */ value: unknown; /** The value when the agent asked, null when unset. The card settles when the setting leaves it. */ @@ -133,8 +214,26 @@ export function effectiveSetting(settings: Settings, key: RequestableSetting): u return value !== null && typeof value === 'object' ? JSON.parse(JSON.stringify(value)) : value; } -export function readSettings(settings: Settings): SettingReading[] { - return REQUESTABLE_SETTING_KEYS.map(key => ({ key, value: effectiveSetting(settings, key), ...REQUESTABLE_SETTINGS[key] })); +/** A key's value in effect: a core setting, or whether the app has the experiment on; null when no app reported. */ +export function currentSetting(key: RequestableKey, settings: Settings, client: ClientPreferences | null): unknown { + const experiment = experimentOf(key); + if (experiment === null) return effectiveSetting(settings, key as RequestableSetting); + return client === null ? null : client.experiments.includes(experiment); +} + +function experimentPlace(id: ExperimentId): SettingPlace { + return { page: 'experiments', section: id, sensitive: false }; +} + +/** Every readable setting, an app's experiments after the core's settings once an app reported them. */ +export function readSettings(settings: Settings, client: ClientPreferences | null = null): SettingReading[] { + const labelled = (key: RequestableKey): { label?: string } => (client?.labels[key] === undefined ? {} : { label: client.labels[key] }); + const core: SettingReading[] = REQUESTABLE_SETTING_KEYS.map(key => ({ key, value: effectiveSetting(settings, key), ...REQUESTABLE_SETTINGS[key], ...labelled(key) })); + if (client === null) return core; + return [...core, ...EXPERIMENT_IDS.map(id => { + const key: ExperimentSettingKey = `experiment.${id}`; + return { key, value: client.experiments.includes(id), ...experimentPlace(id), ...labelled(key) }; + })]; } /** Same JSON whatever the order of an object's keys; undefined reads as null. */ @@ -164,7 +263,7 @@ export type SettingNormalizer = (patch: Partial) => { ok: true; patch: * `normalize` is the store's own validation; `open` lists the change cards * the thread already has open, one per key at most. */ -export function checkSettingRequest(params: SettingRequestParams, settings: Settings, normalize: SettingNormalizer, open: readonly SettingAsk[]): SettingRequestCheck { +export function checkSettingRequest(params: SettingRequestParams, settings: Settings, normalize: SettingNormalizer, open: readonly SettingAsk[], client: ClientPreferences | null = null): SettingRequestCheck { const reason = typeof params.reason === 'string' ? params.reason.trim() : ''; if (reason.length === 0 || reason.length > SETTING_REASON_MAX) return { ok: false, message: `reason must hold 1 to ${SETTING_REASON_MAX} characters`, data: { field: 'reason' } }; const hasKey = params.key !== undefined; @@ -181,18 +280,31 @@ export function checkSettingRequest(params: SettingRequestParams, settings: Sett }; } const { key, value } = params; - if (!isRequestableSetting(key)) { - return { ok: false, message: `${String(key)} is not a setting an agent may ask about; the keys are ${REQUESTABLE_SETTING_KEYS.join(', ')}`, data: { field: 'key', expected: REQUESTABLE_SETTING_KEYS } }; + const experiment = typeof key === 'string' ? experimentOf(key) : null; + if (!isRequestableSetting(key) && experiment === null) { + const keys = [...REQUESTABLE_SETTING_KEYS, ...EXPERIMENT_IDS.map(id => `experiment.${id}`)]; + return { ok: false, message: `${String(key)} is not a setting an agent may ask about; the keys are ${keys.join(', ')}`, data: { field: 'key', expected: keys } }; } if (value === undefined) return { ok: false, message: 'a change needs a value', data: { field: 'value' } }; - const normalized = normalize({ [key]: value } as Partial); - if (!normalized.ok) return { ok: false, message: normalized.message, data: { field: 'value', setting: normalized.field } }; - const proposed = normalized.patch[key] ?? null; - const previous = effectiveSetting(settings, key); + let proposed: unknown; + let place: SettingPlace; + if (experiment !== null) { + if (typeof value !== 'boolean') return { ok: false, message: 'an experiment is true or false', data: { field: 'value', expected: 'true or false' } }; + if (client === null) return { ok: false, message: 'no Boite app has reported its experiments yet; ask the user to open the app', data: { field: 'key', key } }; + proposed = value; + place = experimentPlace(experiment); + } else { + const normalized = normalize({ [key]: value } as Partial); + if (!normalized.ok) return { ok: false, message: normalized.message, data: { field: 'value', setting: normalized.field } }; + proposed = normalized.patch[key as RequestableSetting] ?? null; + place = REQUESTABLE_SETTINGS[key as RequestableSetting]; + } + const settingKey = key as RequestableKey; + const previous = currentSetting(settingKey, settings, client); if (sameSetting(proposed, previous)) return { ok: false, message: `${key} is already ${JSON.stringify(previous)}`, data: { field: 'value', key } }; if (open.some(card => card.kind === 'change' && card.key === key)) return { ok: false, message: `a card asking about ${key} is already open in this thread`, data: { field: 'key', key } }; return { - ok: true, reason, setting: { kind: 'change', key, value: proposed, previous, ...REQUESTABLE_SETTINGS[key] }, + ok: true, reason, setting: { kind: 'change', key: settingKey, value: proposed, previous, ...place }, // Applied is never an answer: the core says it when the value moves. options: [{ id: 'refuse', label: 'Refuse' }], }; @@ -202,9 +314,11 @@ export function checkSettingRequest(params: SettingRequestParams, settings: Sett * The answer a change card settles with once the settings changed, or null * while its setting still holds the value it was asked from. */ -export function settledSetting(setting: SettingAsk, settings: Settings): QuestionAnswer | null { +export function settledSetting(setting: SettingAsk, settings: Settings, client: ClientPreferences | null = null): QuestionAnswer | null { if (setting.kind !== 'change') return null; - const now = effectiveSetting(settings, setting.key); + // No app reported since a restart: the experiment's card waits for one. + if (experimentOf(setting.key) !== null && client === null) return null; + const now = currentSetting(setting.key, settings, client); if (sameSetting(now, setting.previous)) return null; return { optionIds: [sameSetting(now, setting.value) ? 'applied' : 'changed'], value: now }; } diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index e135db7e9..b2b82a5b0 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -13,7 +13,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { isAbsolute, join, resolve } from 'node:path'; import { AGENT_ENV, AGENT_SETTING_KEYS, SETTINGS_PAGES, VIEW_HELP } from '@boite/contracts'; import { VIEW_EXAMPLE } from './view-example.ts'; -import type { AgentTask, GitChange, PanelSurface, Todo } from '@boite/contracts'; +import type { AgentTask, GitChange, PanelSurface, SettingsReading, Todo } from '@boite/contracts'; import { connect } from './client.ts'; import type { CoreClient } from './client.ts'; import { CORE_VERSION } from './version.ts'; @@ -85,11 +85,15 @@ ${CONTROL_HELP} show the user the draft before submit ask [option ...] ask the user without stopping; the answer arrives later as a message (--multiple) - settings [key] Boite settings you may ask about: value, page + settings [key] Boite settings you may ask about: value, page, + the name the user's app shows; first, the + language the app speaks; experiments last settings request --why - a card asking the user to change one; value - is JSON or a bare word; the outcome arrives as - a message (applied, changed or refused) + a card with the switch or value itself, for + the user to change in the chat; value is JSON + or a bare word (experiment. true|false); + the outcome arrives as a message (applied, + changed or refused) settings open [#] --why a card with a button to that Settings page, for what is not one value: a provider login, @@ -569,7 +573,7 @@ async function run(parsed: Parsed, io: CliIo, client: CoreClient, threadId: stri print([ `requested: ${asked.questionId}`, ...(setting.kind === 'change' - ? [`change: ${setting.key} ${JSON.stringify(setting.previous)} -> ${JSON.stringify(setting.value)}`, setting.sensitive ? 'card: opens Settings at the setting; the user changes it there' : 'card: Apply, or open Settings at the setting'] + ? [`change: ${setting.key} ${JSON.stringify(setting.previous)} -> ${JSON.stringify(setting.value)}`, typeof setting.value === 'boolean' ? 'card: the switch itself, in the chat' : 'card: Apply in the chat, or open Settings at the setting'] : [`card: opens Settings at ${setting.section === null ? setting.page : `${setting.page}#${setting.section}`}`]), 'Keep working. The outcome arrives as a message quoting your reason; without one, nothing changed.', ], asked); @@ -603,10 +607,11 @@ async function run(parsed: Parsed, io: CliIo, client: CoreClient, threadId: stri return; } if (rest.length > 1) throw new Usage('settings expects a key, request or open '); - const readings = await client.call('agent.settings', { threadId }); - const shown = action === undefined ? readings : readings.filter(reading => reading.key === action); + const read = await client.call('agent.settings', { threadId }); + const shown = action === undefined ? read.settings : read.settings.filter(reading => reading.key === action); if (shown.length === 0) throw new Usage(`settings: unknown key ${action}; boite settings lists them`); - print(shown.map(reading => `${reading.key} ${JSON.stringify(reading.value)} ${reading.page}#${reading.section}${reading.sensitive ? ' (changed from Settings only)' : ''}`), action === undefined ? readings : shown[0]); + const rows = shown.map(reading => `${reading.key} ${JSON.stringify(reading.value)} ${reading.page}#${reading.section}${reading.label === undefined ? '' : ` ${JSON.stringify(reading.label)}`}${reading.sensitive ? ' (sensitive)' : ''}`); + print(action === undefined ? [appLine(read.app), ...rows] : rows, action === undefined ? read : shown[0]); }, ask: async () => { // An agent once ran `boite ask help` for this text and drew a card reading "help". @@ -738,3 +743,15 @@ export function watchStatus(watch: import('@boite/contracts').PullRequestWatch): const checks = total === 0 ? 'no checks' : `${total} checks, ${running} running, ${failed} failed`; return [`head ${seen.head}`, checks, seen.conflicting ? `conflicts with ${seen.base}` : null, paused].filter(Boolean).join('; '); } + +/** + * The first line of `boite settings`: which language the user reads Boite in, + * so the agent names a setting the way the screen does, and where experiments + * come from. + */ +function appLine(app: SettingsReading['app']): string { + if (app === null) return 'app: no Boite app has reported yet; experiments are unknown and names are the keys'; + const language = app.language === 'fr' ? 'French' : 'English'; + const where = app.client === 'shell' ? `the desktop app${app.device === null ? '' : ` on ${JSON.stringify(app.device)}`}` : app.device === null ? 'the web app' : `the web app on a ${app.device}`; + return `app: ${where} speaks ${language}; quoted names are what the user sees there, experiments are that app's own`; +} diff --git a/packages/core/src/client-preferences.ts b/packages/core/src/client-preferences.ts new file mode 100644 index 000000000..94c247193 --- /dev/null +++ b/packages/core/src/client-preferences.ts @@ -0,0 +1,59 @@ +import { checkClientPreferences, sameClientPreferences, type ClientPreferences, type SentFrom, type SettingsReading } from '@boite/contracts'; +import { RpcErrorCode } from '@boite/contracts'; +import { RpcFailure } from './errors.ts'; +import type { Connection } from './router.ts'; + +/* + * What each app says of itself (`client.preferences`): its language, the + * experiments it has on and the names it gives settings. Kept per socket and + * never written down: an app says it again after every hello. Agents read the + * app the user last used: the one that last sent a prompt or changed a + * preference, or the first to report once that app's socket closed. A phone + * that merely reconnects in a pocket does not take the place of an open app. + */ +export class ClientPreferencesBook { + private readonly byConnection = new Map(); + private latest: { connectionId: string; preferences: ClientPreferences; from: SentFrom | null } | null = null; + + /** + * `changed` runs when an app reports that one of its own preferences moved + * and it is the app agents read: the user flipped something there. Moving + * from one app to another flips nothing, so it settles no card. + */ + constructor(private readonly changed: (preferences: ClientPreferences) => void) {} + + report(connection: Connection, params: unknown): void { + const checked = checkClientPreferences(params); + if (!checked.ok) throw new RpcFailure(RpcErrorCode.InvalidParams, checked.message, { field: checked.field }); + const before = this.byConnection.get(connection.id); + if (before === undefined) connection.onClose?.(() => this.byConnection.delete(connection.id)); + const entry = { preferences: checked.preferences, from: connection.sentFrom ?? null }; + this.byConnection.set(connection.id, entry); + const moved = before !== undefined && !sameClientPreferences(before.preferences, entry.preferences); + // The app agents read stays known once its socket closes, until another app reports. + const vacant = this.latest === null || !this.byConnection.has(this.latest.connectionId) || this.latest.connectionId === connection.id; + if (vacant || moved) this.take(connection.id, entry); + if (moved) this.changed(entry.preferences); + } + + /** A prompt from this socket: its app is the one the user uses now. */ + use(connection: Connection): ClientPreferences | null { + const entry = this.byConnection.get(connection.id); + if (entry !== undefined) this.take(connection.id, entry); + return entry?.preferences ?? null; + } + + /** The app agents read, or null before any reported. */ + current(): ClientPreferences | null { + return this.latest?.preferences ?? null; + } + + app(): SettingsReading['app'] { + if (this.latest === null) return null; + return { language: this.latest.preferences.language, device: this.latest.from?.device ?? null, client: this.latest.from?.client ?? null }; + } + + private take(connectionId: string, entry: { preferences: ClientPreferences; from: SentFrom | null }): void { + this.latest = { connectionId, ...entry }; + } +} diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index c9e04c6cb..04bc560bb 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -32,6 +32,7 @@ import { Router } from './router.ts'; import { Scheduler } from './scheduler.ts'; import { SessionStore } from './sessions.ts'; import { SettingsStore } from './settings.ts'; +import { ClientPreferencesBook } from './client-preferences.ts'; import { eraseIncognitoRoot } from './threads/incognito.ts'; import { removeTranscripts } from './threads/transcript-purge.ts'; import { ThreadStore } from './threads.ts'; @@ -144,6 +145,8 @@ export class Core { readonly journal: Journal; readonly router: Router; readonly settings: SettingsStore; + /** What the user's apps say of themselves: language, experiments, the names they give settings. */ + readonly clients: ClientPreferencesBook; readonly providers: ProviderRegistry; readonly accounts: AccountStore; readonly workforce: AgentStore; @@ -330,6 +333,7 @@ export class Core { this.#stopMoves = scheduleInlineValueMoves(this.journal, (message) => this.log('error', message)); this.router = new Router(); this.settings = new SettingsStore(this); + this.clients = new ClientPreferencesBook(preferences => this.threads.cards.settleClient(preferences)); this.providers = new ProviderRegistry(this.dataDir); this.providers.loadSwitches(this.journal.getSetting(PROVIDER_SWITCHES)); this.accounts = new AccountStore(this); diff --git a/packages/core/src/mcp/tools.ts b/packages/core/src/mcp/tools.ts index 64958b94f..e2bc51c64 100644 --- a/packages/core/src/mcp/tools.ts +++ b/packages/core/src/mcp/tools.ts @@ -29,8 +29,8 @@ export const MCP_TOOLS = { boite_delegate_resume: { method: 'delegation.resume', description: "Continue stopped children, or only agentId, and the workflows a pause or a restart held. Lifts the team's pause when this agent or a restart set it, or after the owner's Stop once they wrote again; refused with the reason otherwise.", schema: z.strictObject({ agentId: id.optional() }), readOnly: false }, boite_plan_get: { method: 'threads.tasks.get', description: 'Read this thread\'s plan tasks.', schema: z.strictObject({}), readOnly: true }, boite_plan_set: { method: 'threads.tasks.set', description: 'Replace this thread\'s bounded plan task list.', schema: z.strictObject({ tasks: z.array(z.strictObject({ id, text: z.string().min(1).max(2000), status: z.enum(['pending', 'in_progress', 'completed']) })).max(100) }), readOnly: false }, - boite_settings: { method: 'agent.settings', description: 'Read the Boite settings this agent may ask the user about, with their values and the Settings page each sits on.', schema: z.strictObject({}), readOnly: true }, - boite_settings_request: { method: 'settings.request', description: 'Put a settings card in this thread without stopping: key and value propose a change the user applies, page and section open a Settings page. reason is what the user reads. The outcome arrives later as a message.', schema: z.strictObject({ reason: z.string().min(1).max(500), key: z.string().min(1).max(64).optional(), value: z.unknown().optional(), page: z.string().min(1).max(32).optional(), section: z.string().min(1).max(64).optional() }), readOnly: false }, + boite_settings: { method: 'agent.settings', description: 'Read the Boite settings this agent may ask the user about, with their values, the Settings page each sits on and the name the user\'s app shows; app says the language the user reads Boite in. Experiments of that app come last as experiment..', schema: z.strictObject({}), readOnly: true }, + boite_settings_request: { method: 'settings.request', description: 'Put a settings card in this thread without stopping: key and value propose a change the user makes in the chat (a switch for true or false, experiment. included), page and section open a Settings page. reason is what the user reads. The outcome arrives later as a message.', schema: z.strictObject({ reason: z.string().min(1).max(500), key: z.string().min(1).max(64).optional(), value: z.unknown().optional(), page: z.string().min(1).max(32).optional(), section: z.string().min(1).max(64).optional() }), readOnly: false }, boite_agent_settings: { method: 'agents.settings.read', description: 'Read a persistent agent\'s settings a card may change (projects, model, effort, autoMemory, subagents, maxRunMinutes) with their choices. agent is its name or id; omitted, the agent whose conversation this is.', schema: z.strictObject({ agent: z.string().min(1).max(160).optional() }), readOnly: true }, boite_agent_settings_request: { method: 'agents.settings.request', description: 'Put a card in this thread without stopping asking the user to change one setting of a persistent agent. projects takes a list of project names, or { add, remove }. reason is what the user reads. The outcome arrives later as a message.', schema: z.strictObject({ reason: z.string().min(1).max(500), key: z.string().min(1).max(64), value: z.unknown(), agent: z.string().min(1).max(160).optional() }), readOnly: false }, boite_question: { method: 'questions.ask', description: 'Ask an asynchronous question in this thread; the answer arrives later as a message.', schema: z.strictObject({ text, options: z.array(z.string().min(1).max(500)).max(20).optional(), multiple: z.boolean().optional() }), readOnly: false }, diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 11f4a0795..6d370d009 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -112,7 +112,11 @@ export function localProxy(proxy: SubscriptionProxy, port: number): Subscription export function registerSettingsMethods(core: Core): void { core.router.register('settings.get', () => core.settings.get()); core.router.register('settings.set', (params) => core.settings.set(params)); - core.router.register('agent.settings', () => readSettings(core.settings.get())); + core.router.register('agent.settings', () => ({ app: core.clients.app(), settings: readSettings(core.settings.get(), core.clients.current()) })); + core.router.register('client.preferences', (params, ctx) => { + core.clients.report(ctx.connection, params); + return { ok: true } as const; + }); core.router.register('settings.request', (params) => core.threads.cards.requestSetting(params)); } diff --git a/packages/core/src/threads/cards.ts b/packages/core/src/threads/cards.ts index c27d41ffc..fe55e1c25 100644 --- a/packages/core/src/threads/cards.ts +++ b/packages/core/src/threads/cards.ts @@ -13,6 +13,8 @@ import { type SettingAsk, type SettingRequestParams, checkSettingRequest, + experimentOf, + type ClientPreferences, settingAnswerText, settledSetting, type ThreadId, @@ -256,7 +258,7 @@ export class ThreadCards { const field = (error.data as { field?: unknown } | undefined)?.field; return { ok: false, message: error.message, field: typeof field === 'string' ? field : 'value' }; } - }, open); + }, open, this.core.clients.current()); if (!checked.ok) throw refused(checked.message, checked.data); const { reason, setting, options } = checked; const { questionId } = this.drawAsync(thread, { text: reason, options, allowText: false, multiple: false, async: true, setting }); @@ -269,7 +271,12 @@ export class ThreadCards { * when it holds the proposed value and as changed when it holds another. */ settleSettings(next: Settings): void { - this.settle(request => (request.setting === undefined ? null : settledSetting(request.setting, next)), 'a settings change'); + this.settle(request => (request.setting === undefined ? null : settledSetting(request.setting, next, this.core.clients.current())), 'a settings change'); + } + + /** The app agents read reported new preferences: every experiment card whose switch moved there settles. */ + settleClient(preferences: ClientPreferences): void { + this.settle(request => (request.setting?.kind !== 'change' || experimentOf(request.setting.key) === null ? null : settledSetting(request.setting, this.core.settings.get(), preferences)), 'an app change'); } /** diff --git a/packages/core/src/threads/rpc.ts b/packages/core/src/threads/rpc.ts index 653650768..1f1885850 100644 --- a/packages/core/src/threads/rpc.ts +++ b/packages/core/src/threads/rpc.ts @@ -1,5 +1,6 @@ import { stat } from 'node:fs/promises'; -import { pullRequestAddress, type AccountId, type ProviderId, type ThreadId } from '@boite/contracts'; +import { pullRequestAddress, type AccountId, type ClientPreferences, type ProviderId, type SentFrom, type ThreadId } from '@boite/contracts'; +import type { Connection } from '../router.ts'; import type { Core } from '../core.ts'; import { invalidParams, refused } from '../errors.ts'; import type { ProviderProbe } from '../providers/probe.ts'; @@ -214,7 +215,7 @@ export function registerThreadMethods(core: Core, probe: ProviderProbe): void { core.router.register('turns.start', async (params, ctx) => { await requireCwd(core, params.threadId); const attachments = await core.attachmentUploads.resolve(ctx.connection.id, params.attachments ?? [], params.attachmentIds); - return core.threads.startTurn(params.threadId, params.prompt, attachments, params.expectedSelectionVersion, undefined, undefined, params.clientRequestId, undefined, params.previewReferences ?? [], undefined, undefined, ctx.connection.sentFrom ?? null); + return core.threads.startTurn(params.threadId, params.prompt, attachments, params.expectedSelectionVersion, undefined, undefined, params.clientRequestId, undefined, params.previewReferences ?? [], undefined, undefined, promptOrigin(ctx.connection, core.clients.use(ctx.connection))); }); core.router.register('turns.stop', (params, ctx) => { const by = ctx.connection.identity.principal === 'agent' ? `agent of ${ctx.connection.identity.threadId}` : `${ctx.connection.identity.principal} from ${ctx.connection.sentFrom?.client ?? 'a client'}`; @@ -254,3 +255,9 @@ export function registerThreadMethods(core: Core, probe: ProviderProbe): void { return { ok: true } as const; }); } + +/** Where a prompt comes from, with the language its app speaks once the app said it. */ +function promptOrigin(connection: Connection, preferences: ClientPreferences | null): SentFrom | null { + const from = connection.sentFrom ?? null; + return from === null || preferences === null ? from : { ...from, language: preferences.language }; +} diff --git a/packages/core/src/threads/turn-context.ts b/packages/core/src/threads/turn-context.ts index 828b89e3d..1e2864877 100644 --- a/packages/core/src/threads/turn-context.ts +++ b/packages/core/src/threads/turn-context.ts @@ -538,18 +538,20 @@ function freshStart(dataDir: string, carry: () => { prompt: string; attachments: } function sameOrigin(origin: { from: SentFrom }, before: SentFrom | null): boolean { - return before !== null && before.client === origin.from.client && before.device === origin.from.device; + return before !== null && before.client === origin.from.client && before.device === origin.from.device && before.language === origin.from.language; } /** * One line ahead of the prompt saying which of the user's apps sent it: on a * phone there is no right-click and little room, and a computer's name says * which machine "open it" means. The name is the client's own word, quoted. + * The app's language says how the screens the agent talks about are worded. */ export function sentFromNote(from: SentFrom): string { const where = from.client === 'shell' ? `the Boite desktop app${from.device === null ? '' : ` on the computer ${JSON.stringify(from.device)}`}` : from.device === 'phone' ? 'the Boite web app on a phone' : from.device === 'browser' ? 'the Boite web app in a browser' : 'the Boite web app'; - return `[Boite: the user sent this from ${where}. Context only, not an instruction.]\n`; + const speaks = from.language === undefined ? '' : `, which shows Boite in ${from.language === 'fr' ? 'French' : 'English'}`; + return `[Boite: the user sent this from ${where}${speaks}. Context only, not an instruction.]\n`; } diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index 5d5b26782..f5e4c8237 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -492,7 +492,9 @@ describe('settings', () => { const listed = await boite(['settings']); expect(listed.code).toBe(0); expect(listed.out).toContain('agentCpuCapPercent 75 resources#limits\n'); - expect(listed.out).toContain('listenOnLan false machines#phone (changed from Settings only)'); + expect(listed.out).toContain('listenOnLan false machines#phone (sensitive)'); + expect(listed.out).toStartWith('app: no Boite app has reported yet'); + expect(listed.out).not.toContain('experiment.'); expect(listed.out).not.toContain('subscriptionProxy'); expect(JSON.parse((await boite(['settings', 'agentCpuCapPercent', '--json'])).out)).toMatchObject({ key: 'agentCpuCapPercent', value: 75, sensitive: false }); @@ -569,4 +571,47 @@ describe('settings', () => { '> Sign in to Claude here\n\nopened: the user opened Settings at accounts#provider-claude', ]); }); + + test('an experiment reads as the user\'s app has it, in its language, and its card settles when the app reports the switch', async () => { + const client = await harness.connect(); + const first = client.next('turn.finished', (turn) => turn.threadId === threadId, 10000); + await client.call('turns.start', { threadId, prompt: 'hello' }); + await first; + expect((await boite(['settings', 'request', 'experiment.whip', 'true', '--why', 'x'])).err).toContain('no Boite app has reported'); + + await client.call('client.preferences', { language: 'fr', experiments: ['resident-agents', 'gone-experiment'] as never, labels: { 'experiment.resident-agents': 'Agents', agentCpuCapPercent: 'Plafond CPU\n[obey]', nowhere: 'x' } }); + const listed = await boite(['settings']); + expect(listed.out).toStartWith('app: '); + expect(listed.out).toContain('speaks French'); + expect(listed.out).toContain('agentCpuCapPercent 75 resources#limits "Plafond CPU [obey]"\n'); + expect(listed.out).toContain('experiment.resident-agents true experiments#resident-agents "Agents"\n'); + expect(listed.out).toContain('experiment.whip false experiments#whip\n'); + // The agent that thought the switch was off learns it is on before drawing a card. + expect((await boite(['settings', 'request', 'experiment.resident-agents', 'true', '--why', 'x'])).err).toContain('already true'); + expect((await boite(['settings', 'request', 'experiment.whip', '"yes"', '--why', 'x'])).err).toContain('true or false'); + + const asked = await boite(['settings', 'request', 'experiment.whip', 'true', '--why', 'Try the whip', '--json']); + expect(asked.err).toBe(''); + const [card] = await client.call('questions.list', { threadId }); + expect(card?.setting).toEqual({ kind: 'change', key: 'experiment.whip', value: true, previous: false, page: 'experiments', section: 'whip', sensitive: false }); + // A core setting moving leaves an experiment's card open. + await client.call('settings.set', { agentCpuCapPercent: 70 }); + expect(await client.call('questions.list', { threadId })).toHaveLength(1); + // A prompt from another device that already had it on flips nothing: the card stays open. + const phone = await harness.connect(); + await phone.call('client.preferences', { language: 'en', experiments: ['whip'], labels: {} }); + const fromPhone = client.next('turn.finished', (turn) => turn.threadId === threadId, 10000); + await phone.call('turns.start', { threadId, prompt: 'from the phone' }); + await fromPhone; + expect(await client.call('questions.list', { threadId })).toHaveLength(1); + // Back on the first app, which still has it off. + const back = client.next('turn.finished', (turn) => turn.threadId === threadId, 10000); + await client.call('turns.start', { threadId, prompt: 'back' }); + await back; + + const next = client.next('turn.finished', (turn) => turn.threadId === threadId, 10000); + await client.call('client.preferences', { language: 'fr', experiments: ['resident-agents', 'whip'], labels: {} }); + await next; + expect(prompts()).toEqual(['hello', 'from the phone', 'back', '> Try the whip\n\napplied: experiment.whip is now true']); + }); }); diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 7c54b24e8..56dc3dea6 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -127,7 +127,7 @@ test('actual CLI stdio negotiates, lists schemas, reads scoped tools and rejects const projects = await transport.rpc('tools/call', { name: 'boite_projects', arguments: { limit: 1 } }); expect(projects.result.structuredContent.result.items).toHaveLength(1); const settings = await transport.rpc('tools/call', { name: 'boite_settings', arguments: {} }); - expect(settings.result.structuredContent.result.find((reading: any) => reading.key === 'agentCpuCapPercent')).toMatchObject({ value: 75, page: 'resources' }); + expect(settings.result.structuredContent.result.settings.find((reading: any) => reading.key === 'agentCpuCapPercent')).toMatchObject({ value: 75, page: 'resources' }); // A thread that is no agent's conversation names the agent; the refusal lists them. const agentSettings = await transport.rpc('tools/call', { name: 'boite_agent_settings', arguments: {} }); expect(agentSettings.result.isError).toBe(true); diff --git a/packages/core/test/sent-from.test.ts b/packages/core/test/sent-from.test.ts index 9c750f43f..4330472a8 100644 --- a/packages/core/test/sent-from.test.ts +++ b/packages/core/test/sent-from.test.ts @@ -46,14 +46,22 @@ test('the agent hears which app sent a prompt when its session starts and when t expect(await send(phone, 'five')).not.toContain(NOTE); // The shell on this machine's loopback is named by the core. expect(await send(here, 'six')).toContain(`the Boite desktop app on the computer ${JSON.stringify(hostname())}.`); + // Once the app says its language, the agent hears it, and again when the user switches it. + await here.call('client.preferences', { language: 'fr', experiments: [], labels: {} }); + expect(await send(here, 'six-fr')).toContain(`the Boite desktop app on the computer ${JSON.stringify(hostname())}, which shows Boite in French.`); + expect(await send(here, 'six-fr again')).not.toContain(NOTE); + await here.call('client.preferences', { language: 'en', experiments: [], labels: {} }); + expect(await send(here, 'six-en')).toContain('which shows Boite in English.'); - const users = h.core.journal.listMessages(threadId).filter(m => m.role === 'user').map(m => m.parts[0]); + let users = h.core.journal.listMessages(threadId).filter(m => m.role === 'user').map(m => m.parts[0]); expect(users[0]).toEqual({ type: 'text', text: 'one', sentFrom: { client: 'shell', device: 'office-pc' } }); expect(users[3]).toEqual({ type: 'text', text: 'four' }); // The Boite guide switch silences it with the rest of Boite's text. await office.call('brain.configure', { path: null, enabled: false, boiteGuide: false }); expect(await send(office, 'seven')).toBe('seven'); + users = h.core.journal.listMessages(threadId).filter(m => m.role === 'user').map(m => m.parts[0]); + expect(users[6]).toEqual({ type: 'text', text: 'six-fr', sentFrom: { client: 'shell', device: hostname(), language: 'fr' } }); } finally { restore(); for (const client of clients) client.close(); diff --git a/packages/ui/src/components/RightPanel.remote.test.ts b/packages/ui/src/components/RightPanel.remote.test.ts index 8fb0c33f1..3f7bba899 100644 --- a/packages/ui/src/components/RightPanel.remote.test.ts +++ b/packages/ui/src/components/RightPanel.remote.test.ts @@ -111,13 +111,15 @@ test('an open viewer follows a replacement client without reusing old frames or store.attach(nextClient); await settle(); document.querySelector('.keys button')!.click(); await settle(); - expect(nextCalls.mock.calls.filter(([method]) => method !== 'browser.remoteStatus')).toHaveLength(0); + // The app tells a new client its language and experiments; nothing of the browser goes out yet. + const browserCalls = () => nextCalls.mock.calls.filter(([method]) => method !== 'client.preferences'); + expect(browserCalls().filter(([method]) => method !== 'browser.remoteStatus')).toHaveLength(0); resolveOld(frame('Obsolete machine')); await settle(); expect(document.querySelector('[data-testid=remote-browser-frame]')).toBeNull(); await vi.advanceTimersByTimeAsync(1800); await settle(); // A moving page is polled several times a second; every request goes to the replacement. expect(nextCalls).toHaveBeenCalledWith('browser.remoteFrame', { threadId: 't-trace', tabId: 'browser:tab' }); - expect(nextCalls.mock.calls.every(([method]) => method === 'browser.remoteFrame' || method === 'browser.remoteStatus')).toBe(true); + expect(browserCalls().every(([method]) => method === 'browser.remoteFrame' || method === 'browser.remoteStatus')).toBe(true); expect(document.querySelector('[data-testid=remote-browser-frame]')).not.toBeNull(); document.querySelector('.screen')!.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, button: 0, clientX: 80, clientY: 80 })); await settle(); diff --git a/packages/ui/src/components/SettingRequestCard.svelte b/packages/ui/src/components/SettingRequestCard.svelte index 12688fff8..2904089d4 100644 --- a/packages/ui/src/components/SettingRequestCard.svelte +++ b/packages/ui/src/components/SettingRequestCard.svelte @@ -1,18 +1,21 @@ @@ -105,9 +131,14 @@ {/if}

{text}

- {#if change} + {#if change && toggle} +
+ {label}{#if hint}{/if} + flip(event.currentTarget)} /> +
+ {:else if change}
- {settingLabel(change.key)} + {label} {value(change.previous)} @@ -116,7 +147,7 @@
{/if}

{place}

- {#if change && !store.owner} + {#if change && !canApply}

{strings.settingRequest.guestHint}

{:else if !reachable && !canApply}

{strings.settingRequest.elsewhere}

@@ -131,12 +162,12 @@ {/if} {:else} - {#if canApply} - + {#if canApply && !toggle} + {/if} - {#if store.owner && reachable} - + {#if canApply && reachable} + {/if} {/if} @@ -183,6 +214,7 @@ font-size: var(--text-sm); } .name { min-width: 0; overflow-wrap: anywhere; } + .toggle { flex-wrap: nowrap; } .values { display: inline-flex; align-items: center; gap: 8px; color: var(--color-subtle); min-width: 0; } .old { color: var(--color-muted-foreground); text-decoration: line-through; overflow-wrap: anywhere; } .new { color: var(--color-accent); font-weight: 600; overflow-wrap: anywhere; } diff --git a/packages/ui/src/components/SettingRequestCard.test.ts b/packages/ui/src/components/SettingRequestCard.test.ts index 6bddd9f7a..6c4e820b9 100644 --- a/packages/ui/src/components/SettingRequestCard.test.ts +++ b/packages/ui/src/components/SettingRequestCard.test.ts @@ -78,27 +78,77 @@ test('Apply writes the setting through settings.set, the card closes as applied } }); -test('a sensitive setting has no Apply: the card opens its card in Settings, and Refuse tells the agent', async () => { +test('a sensitive switch is flipped in the card, with its warning, and the agent is told', async () => { const { client, store } = await setup(); try { const { questionId } = await client.call('settings.request', { threadId: 't-trace', reason: 'A window keeps hiding', key: 'focusGuard', value: false }); card(store, await asked(store, questionId)); + // The switch itself, showing the value now: no Apply, no trip to Settings. expect(document.querySelector('[data-testid=setting-apply]')).toBeNull(); + expect(document.querySelector('[data-testid=setting-delta]')).toBeNull(); expect(query('[data-testid=setting-sensitive-hint]')).toBeTruthy(); + const toggle = query('[data-testid=setting-switch]'); + expect(toggle.getAttribute('role')).toBe('switch'); + expect(toggle.checked).toBe(true); + expect(document.getElementById(toggle.getAttribute('aria-labelledby')!)?.textContent).toBe('Keep agent windows out of the foreground'); + + toggle.click(); + await vi.waitFor(() => expect(store.pendingQuestions.some((question) => question.id === questionId)).toBe(false)); + expect((await client.call('settings.get', {})).focusGuard).toBe(false); + await vi.waitFor(() => expect(store.openThread!.messages.some((message) => message.role === 'user' && message.parts.some((part) => part.type === 'text' && part.text === '> A window keeps hiding\n\napplied: focusGuard is now false'))).toBe(true)); + } finally { + store.detach(); + client.close(); + } +}); + +test('Show in Settings opens the card it names without answering, and Refuse tells the agent', async () => { + const { client, store } = await setup(); + try { + const { questionId } = await client.call('settings.request', { threadId: 't-trace', reason: 'Fewer threads at once', key: 'agentCpuCapPercent', value: 40 }); + card(store, await asked(store, questionId)); + query('[data-testid=setting-show]').click(); flushSync(); expect(store.page).toBe('settings'); expect(store.settingsTab).toBe('resources'); - expect(store.settingsSection).toMatchObject({ id: 'quiet', flash: true }); + expect(store.settingsSection).toMatchObject({ id: 'limits', flash: true }); // Opening the page answers nothing: the card waits for the setting to move. expect(store.pendingQuestions.some((question) => question.id === questionId)).toBe(true); query('[data-testid=setting-refuse]').click(); await vi.waitFor(() => expect(store.pendingQuestions.some((question) => question.id === questionId)).toBe(false)); - await vi.waitFor(() => expect(store.openThread!.messages.some((message) => message.role === 'user' && message.parts.some((part) => part.type === 'text' && part.text === '> A window keeps hiding\n\nrefused: focusGuard stays true'))).toBe(true)); - expect((await client.call('settings.get', {})).focusGuard).toBe(true); + await vi.waitFor(() => expect(store.openThread!.messages.some((message) => message.role === 'user' && message.parts.some((part) => part.type === 'text' && part.text === '> Fewer threads at once\n\nrefused: agentCpuCapPercent stays 75'))).toBe(true)); + expect((await client.call('settings.get', {})).agentCpuCapPercent).toBe(75); + } finally { + store.detach(); + client.close(); + } +}); + +test('the app tells its core the language and experiments it has, and an experiment card flips this device\'s switch', async () => { + localStorage.setItem('boite.experiments', JSON.stringify(['resident-agents'])); + const { client, store } = await setup(); + try { + // The agent reads what the user sees: on, and named as the Experiments page names it. + await vi.waitFor(async () => expect((await client.call('agent.settings', { threadId: 't-trace' })).app).toEqual({ language: 'en', device: null, client: null })); + const read = await client.call('agent.settings', { threadId: 't-trace' }); + expect(read.settings.find((reading) => reading.key === 'experiment.resident-agents')).toMatchObject({ value: true, label: 'Agents', page: 'experiments', section: 'resident-agents' }); + await expect(client.call('settings.request', { threadId: 't-trace', reason: 'x', key: 'experiment.resident-agents', value: true })).rejects.toThrow('already true'); + + const { questionId } = await client.call('settings.request', { threadId: 't-trace', reason: 'Try the whip', key: 'experiment.whip', value: true }); + card(store, await asked(store, questionId)); + const toggle = query('[data-testid=setting-switch]'); + expect(toggle.checked).toBe(false); + expect(document.getElementById(toggle.getAttribute('aria-labelledby')!)?.textContent).toBe('Whip'); + + toggle.click(); + expect(JSON.parse(localStorage.getItem('boite.experiments')!)).toEqual(['resident-agents', 'whip']); + await vi.waitFor(() => expect(store.pendingQuestions.some((question) => question.id === questionId)).toBe(false)); + await vi.waitFor(() => expect(store.openThread!.messages.some((message) => message.role === 'user' && message.parts.some((part) => part.type === 'text' && part.text === '> Try the whip\n\napplied: experiment.whip is now true'))).toBe(true)); } finally { + localStorage.removeItem('boite.experiments'); store.detach(); client.close(); } diff --git a/packages/ui/src/lib/client-preferences.svelte.ts b/packages/ui/src/lib/client-preferences.svelte.ts new file mode 100644 index 000000000..7f5d43f57 --- /dev/null +++ b/packages/ui/src/lib/client-preferences.svelte.ts @@ -0,0 +1,49 @@ +/* + * What this app tells each core it is connected to about itself + * (`client.preferences`): the language it speaks, the experiments it has on + * and the names it gives settings in that language. An agent then reads the + * screen the user reads (`boite settings`), and a card about an experiment + * settles once this app reports the switch moved. Sent after every hello and + * on every change; an older core without the method is left alone. + */ +import { EXPERIMENT_IDS, REQUESTABLE_SETTING_KEYS, SETTINGS_PAGES, type ClientPreferences } from '@boite/contracts'; +import { untrack } from 'svelte'; +import type { ObservableClient } from './client'; +import { experimentCopy } from './experiment-copy'; +import { readExperiments, subscribeExperiments } from './experiments'; +import { activeLocale } from './i18n.svelte'; +import { settingLabel, settingsPageLabel } from './setting-requests'; + +export function clientPreferences(): ClientPreferences { + const copy = experimentCopy(); + const labels: Record = {}; + for (const key of REQUESTABLE_SETTING_KEYS) labels[key] = settingLabel(key); + for (const id of EXPERIMENT_IDS) labels[`experiment.${id}`] = copy[id].title; + for (const page of SETTINGS_PAGES) labels[page] = settingsPageLabel(page); + return { language: activeLocale(), experiments: readExperiments(), labels }; +} + +/** Reports to `client` while `current` holds. Returns what stops it. */ +export function reportPreferences(client: ObservableClient, current: () => boolean): () => void { + let sent = ''; + const send = (again: boolean): void => { + if (!current() || client.state !== 'ready') return; + const preferences = clientPreferences(); + const said = JSON.stringify(preferences); + if (!again && said === sent) return; + sent = said; + client.call('client.preferences', preferences).catch(() => { + // An older core has no such method; a later change tries again. + sent = ''; + }); + }; + const offState = client.onState((state) => { if (state === 'ready') send(true); }); + const offExperiments = subscribeExperiments(() => send(false)); + const offLocale = $effect.root(() => { + $effect(() => { + activeLocale(); + untrack(() => send(false)); + }); + }); + return () => { offState(); offExperiments(); offLocale(); }; +} diff --git a/packages/ui/src/lib/experiments.ts b/packages/ui/src/lib/experiments.ts index 0ed6e7af6..ecc00c520 100644 --- a/packages/ui/src/lib/experiments.ts +++ b/packages/ui/src/lib/experiments.ts @@ -19,12 +19,13 @@ * `edge-scrollbars.ts` and `press-preload.ts` while their switches are on. */ -export type ExperimentId = 'theme-grain' | 'session-import' | 'prompt-cache' | 'open-chat-links' | 'preview-comments' | 'resident-agents' | 'whip' | 'recording-indicators' | 'goal2' | 'edge-scrollbars' | 'press-preload'; +import { EXPERIMENT_IDS, type ExperimentId } from '@boite/contracts'; + +/** Declared in contracts: the app reports which are on, and an agent may ask to flip one (`client.preferences`). */ +export { EXPERIMENT_IDS, type ExperimentId }; export const EXPERIMENTS_STORAGE_KEY = 'boite.experiments'; -/** Every experiment this build ships, in the order the page lists them. */ -export const EXPERIMENT_IDS: ExperimentId[] = ['theme-grain', 'session-import', 'prompt-cache', 'open-chat-links', 'preview-comments', 'resident-agents', 'whip', 'recording-indicators', 'goal2', 'edge-scrollbars', 'press-preload']; type Listener = (enabled: ExperimentId[]) => void; diff --git a/packages/ui/src/lib/fake-client/context.ts b/packages/ui/src/lib/fake-client/context.ts index f38139205..e5673e817 100644 --- a/packages/ui/src/lib/fake-client/context.ts +++ b/packages/ui/src/lib/fake-client/context.ts @@ -4,6 +4,7 @@ import type { FakeArchiveState, MergedPrFixture } from './merged-pr-archive'; import { observeProgress } from './progress'; import type { FakeRoster } from './group'; import { + type ClientPreferences, DEFAULT_THREAD_DELETION_RETENTION_DAYS, DEFAULT_THREAD_DONE_RETENTION_DAYS, PROTOCOL_VERSION, @@ -236,6 +237,8 @@ export class FakeContext { readonly files = new Map(Object.entries(FAKE_TREE)); readonly artifactUrls = new Set(); settings: Settings; + /** What the app last said of itself (`client.preferences`); the fake core has one app. */ + clientPreferences: ClientPreferences | null = null; speech: SpeechConfig = { engine: 'local', language: '', apiProvider: 'groq', fallback: false, executable: '', modelPath: '', model: SPEECH_DEFAULT_MODEL }; readonly speechStatus: SpeechStatus = { revision: 'fake-voice', engine: 'local', ready: true, localReady: true, groqKeySet: false, openrouterKeySet: false, installing: false, downloadedBytes: 0, totalBytes: 0, error: null, canInstallRuntime: true, models: fakeSpeechModels(), downloading: null, runtimeOutdated: false }; readonly speechRequests = new Map(); diff --git a/packages/ui/src/lib/fake-client/conversation.ts b/packages/ui/src/lib/fake-client/conversation.ts index a8536a656..c5c4e18b5 100644 --- a/packages/ui/src/lib/fake-client/conversation.ts +++ b/packages/ui/src/lib/fake-client/conversation.ts @@ -22,6 +22,7 @@ export const ASYNC_QUESTION_OPTIONS = ['5173', '4173']; export const SETTING_MARKERS: [string, { reason: string } & ({ key: string; value: unknown } | { page: string; section?: string })][] = [ ['[settings]', { reason: 'Your Rust builds stall at the CPU cap. 90% leaves room for the game you are running.', key: 'agentCpuCapPercent', value: 90 }], ['[settings-lan]', { reason: 'To pair your phone over Wi-Fi, this computer has to be reachable on the local network.', key: 'listenOnLan', value: true }], + ['[settings-experiment]', { reason: 'The Agents page and Niggies only show with the Agents experiment on.', key: 'experiment.resident-agents', value: true }], ['[settings-open]', { reason: 'Claude is not signed in yet. Sign in here, then send me a message.', page: 'accounts', section: 'provider-claude' }], ]; diff --git a/packages/ui/src/lib/fake-client/requests.ts b/packages/ui/src/lib/fake-client/requests.ts index d9b9dffc1..bd96b62ca 100644 --- a/packages/ui/src/lib/fake-client/requests.ts +++ b/packages/ui/src/lib/fake-client/requests.ts @@ -1,5 +1,5 @@ /** Permission requests and questions: asked by a turn, answered by the user. */ -import { RpcErrorCode, agentSettingAnswerText, checkSettingRequest, checkSettingsPatch, readSettings, settingAnswerText, settledSetting, type Message, type MessagePart, type PermissionRequest, type QuestionAnswer, type QuestionOption, type QuestionRequest, type AgentSettingAsk, type SettingAsk, type SettingRequestParams, type Settings, type Thread, type ThreadId, type Turn } from '@boite/contracts'; +import { RpcErrorCode, agentSettingAnswerText, checkClientPreferences, checkSettingRequest, experimentOf, checkSettingsPatch, readSettings, settingAnswerText, settledSetting, type Message, type MessagePart, type PermissionRequest, type QuestionAnswer, type QuestionOption, type QuestionRequest, type AgentSettingAsk, type SettingAsk, type SettingRequestParams, type Settings, type Thread, type ThreadId, type Turn } from '@boite/contracts'; import { RpcFailure } from '../client'; import { checkAnswer, checkAsk } from './checks'; import { QUESTION_OPTIONS, QUESTION_TEXT } from './conversation'; @@ -139,10 +139,15 @@ function agentSettingText(ctx: FakeContext, setting: AgentSettingAsk, answer: Qu return agentSettingAnswerText(setting, answer, (value) => JSON.stringify(setting.key === 'projects' && Array.isArray(value) ? value.map((id) => names.get(String(id)) ?? id) : value ?? null)); } -/** As the core's `settleSettings`: a change card closes once its setting left the value it was asked from. */ -export function settleSettings(ctx: FakeContext, settings: Settings): void { +/** + * As the core's `settleSettings` and `settleClient`: a change card closes once + * its setting left the value it was asked from. `experiments` settles the + * experiment cards only, as an app's report does. + */ +export function settleSettings(ctx: FakeContext, settings: Settings, experiments = false): void { for (const [questionId, pending] of [...ctx.pendingQuestions]) { - const answer = pending.request.setting === undefined ? null : settledSetting(pending.request.setting, settings); + const setting = pending.request.setting; + const answer = setting === undefined || (experiments && (setting.kind !== 'change' || experimentOf(setting.key) === null)) ? null : settledSetting(setting, settings, ctx.clientPreferences); if (answer === null) continue; ctx.pendingQuestions.delete(questionId); pending.resolve(answer); @@ -155,7 +160,7 @@ export function requestSetting(ctx: FakeContext, thread: Thread, turn: Turn, par const checked = checkSettingRequest(params, ctx.settings, (patch) => { const result = checkSettingsPatch(patch); return result.ok ? { ok: true, patch: result.patch } : { ok: false, message: result.message, field: result.field }; - }, open); + }, open, ctx.clientPreferences); if (!checked.ok) throw new RpcFailure({ code: RpcErrorCode.Refused, message: checked.message, data: checked.data }); return { questionId: askSetting(ctx, thread, turn, checked.reason, checked.options, checked.setting), setting: checked.setting }; } @@ -322,7 +327,17 @@ export function requestMethods(ctx: FakeContext) { if (!turn) throw new RpcFailure({ code: RpcErrorCode.Refused, message: 'the thread has no turn to ask in yet', data: { threadId: params.threadId } }); return requestSetting(ctx, thread, turn, params); }, - 'agent.settings': async () => readSettings(ctx.settings), + 'agent.settings': async () => ({ + app: ctx.clientPreferences === null ? null : { language: ctx.clientPreferences.language, device: null, client: null }, + settings: readSettings(ctx.settings, ctx.clientPreferences), + }), + 'client.preferences': async (params) => { + const checked = checkClientPreferences(params); + if (!checked.ok) throw new RpcFailure({ code: RpcErrorCode.InvalidParams, message: checked.message, data: { field: checked.field } }); + ctx.clientPreferences = checked.preferences; + settleSettings(ctx, ctx.settings, true); + return { ok: true }; + }, 'questions.skip': async (params) => { const pending = ctx.pendingQuestions.get(params.questionId); if (!pending) throw ctx.notFound('question', params.questionId); diff --git a/packages/ui/src/lib/setting-requests.ts b/packages/ui/src/lib/setting-requests.ts index 38d79c756..d65dd6979 100644 --- a/packages/ui/src/lib/setting-requests.ts +++ b/packages/ui/src/lib/setting-requests.ts @@ -5,7 +5,8 @@ * are in `packages/contracts/src/setting-requests.ts`, the core's cards in * `packages/core/src/threads/cards.ts`. */ -import type { AutoCompactMoment, ProviderSummary, RequestableSetting, SettingAsk, SettingsPage } from '@boite/contracts'; +import { experimentOf, type AutoCompactMoment, type ProviderSummary, type RequestableKey, type RequestableSetting, type SettingAsk, type SettingsPage } from '@boite/contracts'; +import { experimentCopy } from './experiment-copy'; import { formatLocale } from './i18n.svelte'; import { fill, strings } from './strings'; import { formatTokens } from './format'; @@ -26,19 +27,27 @@ export function settingHeading(setting: SettingAsk): string { return setting.kind === 'open' ? words.headingOpen : setting.sensitive ? words.headingSensitive : words.heading; } -export function settingLabel(key: RequestableSetting): string { - return strings.settingRequest.keys[key] ?? key; +export function settingLabel(key: RequestableKey): string { + const experiment = experimentOf(key); + if (experiment !== null) return experimentCopy()[experiment].title; + return strings.settingRequest.keys[key as RequestableSetting] ?? key; +} + +/** What the setting does, as the Settings page explains it; null where the card's name says enough. */ +export function settingHint(key: RequestableKey): string | null { + const experiment = experimentOf(key); + return experiment === null ? null : experimentCopy()[experiment].hint; } /** A value as the card shows it: `60 %`, `On`, `512 MB`, `In D:/worktrees`. */ -export function settingValueText(key: RequestableSetting, value: unknown, providers: readonly ProviderSummary[] = []): string { +export function settingValueText(key: RequestableKey, value: unknown, providers: readonly ProviderSummary[] = []): string { const words = strings.settingRequest; if (value === null || value === undefined) return words.none; if (typeof value === 'boolean') return value ? words.on : words.off; if (typeof value === 'number') { - const zero = (words.zero as Partial>)[key]; + const zero = (words.zero as Partial>)[key]; if (value === 0 && zero !== undefined) return zero; - const unit = UNITS[key]; + const unit = UNITS[key as RequestableSetting]; return new Intl.NumberFormat(formatLocale(), unit ? { style: 'unit', unit: unit.unit, unitDisplay: unit.display } : {}).format(value); } if (typeof value === 'string') return value; diff --git a/packages/ui/src/lib/store/events.ts b/packages/ui/src/lib/store/events.ts index 36263b198..becfb8ae7 100644 --- a/packages/ui/src/lib/store/events.ts +++ b/packages/ui/src/lib/store/events.ts @@ -13,6 +13,7 @@ import { strings } from '../strings'; import { needsUser } from '../thread-state'; import { installStatesOf } from './accounts.svelte'; import { observable } from './connection.svelte'; +import { reportPreferences } from '../client-preferences.svelte'; import { lightPage } from './reading-pages.svelte'; import type { StoreContext } from './context'; import { probeKey } from './models.svelte'; @@ -68,6 +69,9 @@ export function listen(ctx: StoreContext, client: Client): void { ); } + // After the state handler above, so a reconnect has its principal and core first. + if (observable(client)) ctx.off.push(reportPreferences(client, current)); + on('core.updateChanged', snapshot => ctx.serverUpdater.apply(snapshot)); on('core.desktopUpdateChanged', ({ status }) => { if (!s.localCore) ctx.serverUpdater.applyDesktop(status); }); on('ui.appUpdate', ({ version }) => appUpdateRequested(s, version)); diff --git a/packages/ui/src/lib/strings.en.ts b/packages/ui/src/lib/strings.en.ts index a4fedd66d..b657553ff 100644 --- a/packages/ui/src/lib/strings.en.ts +++ b/packages/ui/src/lib/strings.en.ts @@ -70,7 +70,7 @@ export const strings = { open: 'Open Settings', refuse: 'Refuse', dismiss: 'Dismiss', - sensitiveHint: 'This one changes from Settings, beside its explanation. The card closes once it is changed.', + sensitiveHint: 'Check this one before changing it: it guards this computer, its history or its files.', guestHint: 'Only the computer running Boite can change this setting.', elsewhere: 'This is in the Settings of the computer running Boite.', loadFailed: 'This settings card did not load.', diff --git a/packages/ui/src/lib/strings.fr.ts b/packages/ui/src/lib/strings.fr.ts index 19bde67c8..844ef3fab 100644 --- a/packages/ui/src/lib/strings.fr.ts +++ b/packages/ui/src/lib/strings.fr.ts @@ -76,7 +76,7 @@ export const fr: Translation = { open: 'Ouvrir les Réglages', refuse: 'Refuser', dismiss: 'Ignorer', - sensitiveHint: "Celui-ci se change depuis les Réglages, à côté de son explication. La carte se ferme une fois le réglage changé.", + sensitiveHint: "Vérifiez celui-ci avant de le changer : il protège cet ordinateur, son historique ou ses fichiers.", guestHint: "Seul l'ordinateur qui fait tourner Boite peut changer ce réglage.", elsewhere: "C'est dans les Réglages de l'ordinateur qui fait tourner Boite.", loadFailed: "Cette carte de réglage n'a pas chargé.", diff --git a/scripts/ci/budgets.json b/scripts/ci/budgets.json index 43a2d78ad..4b700429f 100644 --- a/scripts/ci/budgets.json +++ b/scripts/ci/budgets.json @@ -1,6 +1,6 @@ { "uiEntryChunk": 492200, - "uiDist": 4868600, + "uiDist": 4871500, "coreBundle": 995000, - "coreJavaScript": 4478300 + "coreJavaScript": 4483200 } diff --git a/tests/e2e/setting-cards.test.ts b/tests/e2e/setting-cards.test.ts new file mode 100644 index 000000000..38e1fa5b5 --- /dev/null +++ b/tests/e2e/setting-cards.test.ts @@ -0,0 +1,70 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { BrowserPage, freePort } from './lib/cdp.ts'; +import { startUi } from './lib/ui.ts'; + +const ARTIFACTS = join(import.meta.dir, '.artifacts', 'setting-cards'); +const DESKTOP = { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }; +const PHONE = { width: 390, height: 844, deviceScaleFactor: 1, mobile: true }; + +let page: BrowserPage; +let server: { close(): Promise }; +beforeAll(async () => { + mkdirSync(ARTIFACTS, { recursive: true }); + const port = await freePort(); + server = await startUi(port); + page = await BrowserPage.launch({ url: `http://127.0.0.1:${port}/?fake=1`, experiments: [] }); + await page.evaluate(`localStorage.setItem('boite.locale', 'fr')`); + await page.navigate(`http://127.0.0.1:${port}/?fake=1`); + await page.waitFor(`document.documentElement.lang === 'fr' && window.__boiteTest?.workspace.active.connection === 'ready'`); +}, 60000); +afterAll(async () => { await page?.close(); await server?.close(); }, 30_000); + +async function capture(name: string) { + await page.evaluate(`Promise.all([document.fonts.ready, ...document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {}))])`); + await page.screenshot(join(ARTIFACTS, name)); +} + +const DOCKED = '[data-testid="activity-question"] [data-testid="setting-card"][data-state="pending"]'; + +test('an agent reads the app\'s language and experiments, and its card turns an experiment on in the chat', async () => { + await page.send('Emulation.setDeviceMetricsOverride', DESKTOP); + const thread = await page.evaluate(`(async () => { + const store = window.__boiteTest.workspace.active; + const thread = await store.client.call('threads.create', { projectId: 'p-boite', providerId: 'echo', accountId: 'a-echo' }); + await window.__boiteTest.workspace.select(store, thread.id); + return thread.id; + })()`) as string; + // What \`boite settings\` reads: French, the experiment off, under the name the French page gives it. + const read = await page.evaluate(`window.__boiteTest.workspace.active.client.call('agent.settings', { threadId: ${JSON.stringify(thread)} })`) as { app: { language: string }; settings: { key: string; value: unknown; label?: string }[] }; + expect(read.app.language).toBe('fr'); + expect(read.settings.find(reading => reading.key === 'experiment.resident-agents')).toMatchObject({ value: false, label: 'Agents' }); + expect(read.settings.find(reading => reading.key === 'experiments')).toBeUndefined(); + + await page.evaluate(`window.__boiteTest.workspace.active.client.call('turns.start', { threadId: ${JSON.stringify(thread)}, prompt: 'show me the agents [settings-experiment]' })`); + await page.waitFor(`document.querySelector('${DOCKED} [data-testid="setting-switch"]')?.getBoundingClientRect().width > 0`); + expect(await page.evaluate(`document.querySelector('${DOCKED} [data-testid="setting-switch"]').checked`)).toBe(false); + expect(await page.evaluate(`document.querySelector('${DOCKED} [data-testid="setting-apply"]')`)).toBeNull(); + await capture('experiment-desktop.png'); + + await page.send('Emulation.setDeviceMetricsOverride', PHONE); + await page.waitFor(`document.querySelector('${DOCKED}')?.getBoundingClientRect().width > 0`); + expect(await page.evaluate(`(() => { const card = document.querySelector('${DOCKED}').getBoundingClientRect(); return card.left >= 0 && card.right <= innerWidth; })()`)).toBe(true); + await capture('experiment-phone.png'); + await page.send('Emulation.setDeviceMetricsOverride', DESKTOP); + + // The switch in the card flips this device's experiment; the app's report settles the card. + await page.waitFor(`document.querySelector('${DOCKED} [data-testid="setting-switch"]')?.getBoundingClientRect().width > 0`); + await page.click(`${DOCKED} [data-testid="setting-switch"]`); + await page.waitFor(`document.querySelector('[data-testid="setting-card"][data-outcome="applied"]')`); + expect(await page.evaluate(`JSON.parse(localStorage.getItem('boite.experiments'))`)).toEqual(['resident-agents']); + await page.waitFor(`window.__boiteTest.workspace.active.openThread?.messages.some(m => m.role === 'user' && m.parts.some(p => p.type === 'text' && p.text.includes('applied: experiment.resident-agents is now true')))`); + await page.waitFor(`document.querySelector('[data-testid="view-agents"]')`); + await capture('experiment-applied-desktop.png'); + + // A sensitive switch shows in the card too, beside its warning. + await page.evaluate(`window.__boiteTest.workspace.active.client.call('turns.start', { threadId: ${JSON.stringify(thread)}, prompt: 'pair my phone [settings-lan]' })`); + await page.waitFor(`document.querySelector('${DOCKED} [data-testid="setting-sensitive-hint"]') && document.querySelector('${DOCKED} [data-testid="setting-switch"]')`); + await capture('sensitive-desktop.png'); +}, 60000); From 2c8d138447bd0b13580950793c3a9dce6e97313b Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:17:54 +0000 Subject: [PATCH 2/5] fix(settings): settle an experiment card only on a flip, by app id across reconnects --- docs/ci.md | 10 +-- docs/cli.md | 15 +++-- packages/contracts/src/index.ts | 4 +- packages/contracts/src/setting-requests.ts | 46 ++++++++++--- packages/core/src/client-preferences.ts | 49 +++++++------- packages/core/src/core.ts | 2 +- packages/core/src/threads/cards.ts | 11 ++-- packages/core/test/cli.test.ts | 64 ++++++++++++------- packages/core/test/sent-from.test.ts | 4 +- .../src/components/SettingRequestCard.svelte | 6 +- .../src/components/SettingRequestCard.test.ts | 3 + .../ui/src/lib/client-preferences.svelte.ts | 22 ++++++- packages/ui/src/lib/fake-client/context.ts | 2 + packages/ui/src/lib/fake-client/requests.ts | 16 +++-- scripts/ci/budgets.json | 4 +- 15 files changed, 171 insertions(+), 87 deletions(-) diff --git a/docs/ci.md b/docs/ci.md index d600524c8..fecf1ec62 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -182,21 +182,21 @@ platform. On Windows `stage:core` no longer compiles `dist/boite-core.exe`: | Measure | Maximum uncompressed bytes | | --- | ---: | | UI entry chunk | 492,200 | -| UI files, excluding `.br` and `.gz` copies | 4,873,100 | +| UI files, excluding `.br` and `.gz` copies | 4,873,400 | | Core `dist/main.js` | 995,000 | -| All emitted core JavaScript, including lazy chunks and workers | 4,485,300 | +| All emitted core JavaScript, including lazy chunks and workers | 4,486,100 | The total JavaScript measure excludes native binaries and source maps. On 2026-10-18, settings cards that draw their switch in the chat, with the app's language, experiments and setting names reported to the core (`client.preferences`, PR #609), merged with `origin/main` at `e22bb094` -measure 488,971 entry bytes, 4,873,077 UI bytes and 4,485,262 core JavaScript +measure 488,971 entry bytes, 4,873,300 UI bytes and 4,486,027 core JavaScript bytes with `measure()` after fresh `bun run build:ui` and `bun run build:core` on Linux, against the 492,112, 4,869,093 and 4,479,099 recorded below for -PR #595: the entry loses 3,141 bytes, the UI gains 3,984 and the core 6,163 +PR #595: the entry loses 3,141 bytes, the UI gains 4,207 and the core 6,928 (the client preferences book, the experiment checks and the CLI's app line). -The UI and core JavaScript limits rise to 4,873,100 and 4,485,300; the other +The UI and core JavaScript limits rise to 4,873,400 and 4,486,100; the other limits stay. On 2026-10-18, PR #595 merged with `origin/main` at `80c2da06` measures diff --git a/docs/cli.md b/docs/cli.md index 2307a41b5..07c741d8d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -502,10 +502,10 @@ les agents"`). The list is `REQUESTABLE_SETTINGS` in "Agents"`); credentials, account ids and browser profiles are not in it. Language, experiments and names come from the apps: each says them after every -hello and on every change (`client.preferences`, a paired phone included). The -core keeps them per socket, in memory only, and reads the app that last sent a -prompt or changed one of them; once that app's socket closes, the next app to -report takes its place. The language also rides on each prompt +hello and on every change (`client.preferences`, a paired phone included), with +an id it keeps on the device (`boite.appId`). The core keeps them in memory +only, and reads the app that last sent a prompt or changed one of them; once +that app's socket closes, the next app to report takes its place. The language also rides on each prompt (`SentFrom.language`), so the one-line note on where a prompt came from says `which shows Boite in French` when a session starts and when the language changes. Before any app reported, the first line says so and no experiment is @@ -526,7 +526,12 @@ included, draws its switch showing the value now; any other value shows the current value struck through, the proposed one and Apply. The switch and Apply call `settings.set`, the call the Settings page makes, so only the owner can use them; an experiment's switch flips the device's own experiment, which any -of the user's devices may do, and that app's next report settles the card. +of the user's devices may do, and stays free while the core has not heard it. +An experiment's card settles when an app flips that switch since its last +report, compared by the app's id so a reconnect still counts, or when the app +it was asked from reports another value, a flip made offline or across a +restart. Another app that merely has a different value, a language change or a +core setting moving settles nothing. Show in Settings stays as a secondary button for whoever can apply. A paired device answers a core setting's card with Refuse only. An open card's Open Settings is offered to any client whose Settings draws that card. No button to Settings appears where the card is diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 7f0a1df99..93caaf6a4 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5296,8 +5296,8 @@ import type { AutoCompact, CacheKeepalive } from './settings-validation.ts'; export { TITLE_MODEL_DEFAULTS, defaultTitleModel } from './title-models.ts'; export { DEVICE_METHODS, DEVICE_EVENTS, AGENT_EVENTS } from './access.ts'; export { - APP_LANGUAGES, CLIENT_LABELS_MAX, CLIENT_LABEL_CHARS, EXPERIMENT_IDS, REQUESTABLE_SETTINGS, REQUESTABLE_SETTING_KEYS, SETTING_REASON_MAX, SETTINGS_PAGES, SETTINGS_SECTION_PATTERN, - checkClientPreferences, checkSettingRequest, currentSetting, effectiveSetting, experimentOf, isExperimentId, isRequestableSetting, isSettingsPage, readSettings, + APP_LANGUAGES, CLIENT_APP_PATTERN, CLIENT_LABELS_MAX, CLIENT_LABEL_CHARS, EXPERIMENT_IDS, REQUESTABLE_SETTINGS, REQUESTABLE_SETTING_KEYS, SETTING_REASON_MAX, SETTINGS_PAGES, SETTINGS_SECTION_PATTERN, + checkClientPreferences, checkSettingRequest, settledExperiment, currentSetting, effectiveSetting, experimentOf, isExperimentId, isRequestableSetting, isSettingsPage, readSettings, sameClientPreferences, sameSetting, settingAnswerText, settledSetting, type AppLanguage, type ClientPreferences, type ExperimentId, type ExperimentSettingKey, type RequestableKey, type RequestableSetting, type SettingAsk, type SettingNormalizer, type SettingOutcome, type SettingPlace, type SettingReading, type SettingRequestCheck, type SettingRequestParams, type SettingsPage, diff --git a/packages/contracts/src/setting-requests.ts b/packages/contracts/src/setting-requests.ts index ec624ef4e..8b33f9106 100644 --- a/packages/contracts/src/setting-requests.ts +++ b/packages/contracts/src/setting-requests.ts @@ -35,6 +35,11 @@ export type ExperimentSettingKey = `experiment.${ExperimentId}`; * the language, the experiments on, and the names it gives settings. */ export interface ClientPreferences { + /** + * This app's own id, random and kept on the device (`boite.appId`), the same + * across reconnects: a flip made while offline still reads as a flip. + */ + app: string; language: AppLanguage; experiments: ExperimentId[]; /** @@ -46,11 +51,13 @@ export interface ClientPreferences { } export const CLIENT_LABELS_MAX = 120; +export const CLIENT_APP_PATTERN = /^[A-Za-z0-9_-]{8,64}$/; export const CLIENT_LABEL_CHARS = 120; /** A report checked, or why it is refused. Labels lose control characters and keep the known keys only. */ export function checkClientPreferences(params: unknown): { ok: true; preferences: ClientPreferences } | { ok: false; message: string; field: string } { const given = (params ?? {}) as Partial>; + if (typeof given.app !== 'string' || !CLIENT_APP_PATTERN.test(given.app)) return { ok: false, message: 'app must be 8 to 64 letters, digits, dashes or underscores', field: 'app' }; if (!(APP_LANGUAGES as readonly unknown[]).includes(given.language)) return { ok: false, message: `language must be one of ${APP_LANGUAGES.join(', ')}`, field: 'language' }; if (!Array.isArray(given.experiments) || given.experiments.length > EXPERIMENT_IDS.length * 2) return { ok: false, message: 'experiments must be a list of experiment ids', field: 'experiments' }; // A newer app may ship an experiment this core does not know: it is left out, not refused. @@ -65,7 +72,7 @@ export function checkClientPreferences(params: unknown): { ok: true; preferences const clean = value.replace(/[\p{C}\p{Zl}\p{Zp}]/gu, ' ').trim().slice(0, CLIENT_LABEL_CHARS); if (clean.length > 0) labels[key] = clean; } - return { ok: true, preferences: { language: given.language as AppLanguage, experiments, labels } }; + return { ok: true, preferences: { app: given.app, language: given.language as AppLanguage, experiments, labels } }; } export function sameClientPreferences(a: ClientPreferences | null, b: ClientPreferences | null): boolean { @@ -176,6 +183,8 @@ export type SettingAsk = page: SettingsPage; section: string; sensitive: boolean; + /** An experiment's card: the app whose switch it was asked from (`ClientPreferences.app`). */ + app?: string; } | { kind: 'open'; page: SettingsPage; section: string | null }; @@ -304,25 +313,44 @@ export function checkSettingRequest(params: SettingRequestParams, settings: Sett if (sameSetting(proposed, previous)) return { ok: false, message: `${key} is already ${JSON.stringify(previous)}`, data: { field: 'value', key } }; if (open.some(card => card.kind === 'change' && card.key === key)) return { ok: false, message: `a card asking about ${key} is already open in this thread`, data: { field: 'key', key } }; return { - ok: true, reason, setting: { kind: 'change', key: settingKey, value: proposed, previous, ...place }, + ok: true, reason, setting: { kind: 'change', key: settingKey, value: proposed, previous, ...place, ...(experiment !== null && client !== null ? { app: client.app } : {}) }, // Applied is never an answer: the core says it when the value moves. options: [{ id: 'refuse', label: 'Refuse' }], }; } /** - * The answer a change card settles with once the settings changed, or null - * while its setting still holds the value it was asked from. + * The answer a change card about a core setting settles with once the + * settings changed, or null while its setting still holds the value it was + * asked from. An experiment's card never settles here: a core setting moving + * flips no switch (`settledExperiment`). */ -export function settledSetting(setting: SettingAsk, settings: Settings, client: ClientPreferences | null = null): QuestionAnswer | null { - if (setting.kind !== 'change') return null; - // No app reported since a restart: the experiment's card waits for one. - if (experimentOf(setting.key) !== null && client === null) return null; - const now = currentSetting(setting.key, settings, client); +export function settledSetting(setting: SettingAsk, settings: Settings): QuestionAnswer | null { + if (setting.kind !== 'change' || experimentOf(setting.key) !== null) return null; + const now = effectiveSetting(settings, setting.key as RequestableSetting); if (sameSetting(now, setting.previous)) return null; return { optionIds: [sameSetting(now, setting.value) ? 'applied' : 'changed'], value: now }; } +/** + * The answer an experiment's card settles with when an app reports, or null. + * It settles when that app flipped the switch since its last report (`before`, + * null when the core has none), or when the app it was asked from reports a + * value other than the one it was asked from: a flip made offline or across a + * restart. Another app that merely had a different value, a language change + * or a core setting moving settles nothing. + */ +export function settledExperiment(setting: SettingAsk, report: ClientPreferences, before: readonly ExperimentId[] | null): QuestionAnswer | null { + if (setting.kind !== 'change') return null; + const id = experimentOf(setting.key); + if (id === null) return null; + const on = report.experiments.includes(id); + const flipped = before !== null && before.includes(id) !== on; + const fromAsker = setting.app === report.app && on !== setting.previous; + if (!flipped && !fromAsker) return null; + return { optionIds: [on === setting.value ? 'applied' : 'changed'], value: on }; +} + /** What the agent reads once its card settles, under the quoted reason. */ export function settingAnswerText(setting: SettingAsk, answer: QuestionAnswer): string { const outcome = answer.optionIds[0]; diff --git a/packages/core/src/client-preferences.ts b/packages/core/src/client-preferences.ts index 94c247193..6d2504021 100644 --- a/packages/core/src/client-preferences.ts +++ b/packages/core/src/client-preferences.ts @@ -1,45 +1,54 @@ -import { checkClientPreferences, sameClientPreferences, type ClientPreferences, type SentFrom, type SettingsReading } from '@boite/contracts'; +import { checkClientPreferences, sameClientPreferences, type ClientPreferences, type ExperimentId, type SentFrom, type SettingsReading } from '@boite/contracts'; import { RpcErrorCode } from '@boite/contracts'; import { RpcFailure } from './errors.ts'; import type { Connection } from './router.ts'; +/** Apps remembered by id, so a reconnect compares with the app's last report; the oldest goes first. */ +const APPS_KEPT = 64; + /* - * What each app says of itself (`client.preferences`): its language, the - * experiments it has on and the names it gives settings. Kept per socket and - * never written down: an app says it again after every hello. Agents read the - * app the user last used: the one that last sent a prompt or changed a - * preference, or the first to report once that app's socket closed. A phone - * that merely reconnects in a pocket does not take the place of an open app. + * What each app says of itself (`client.preferences`): its id, language, the + * experiments it has on and the names it gives settings. Kept in memory only: + * an app says it again after every hello. Agents read the app the user last + * used: the one that last sent a prompt or changed a preference, or the first + * to report once that app's socket closed. A phone that merely reconnects in a + * pocket does not take the place of an open app. */ export class ClientPreferencesBook { private readonly byConnection = new Map(); + /** Each app's last report by its id, across its sockets. */ + private readonly byApp = new Map(); private latest: { connectionId: string; preferences: ClientPreferences; from: SentFrom | null } | null = null; /** - * `changed` runs when an app reports that one of its own preferences moved - * and it is the app agents read: the user flipped something there. Moving - * from one app to another flips nothing, so it settles no card. + * `reported` runs on every report with the experiments that app had at its + * previous one, null when the core has none: a card settles on a flip, not + * on another app's different value. */ - constructor(private readonly changed: (preferences: ClientPreferences) => void) {} + constructor(private readonly reported: (preferences: ClientPreferences, before: readonly ExperimentId[] | null) => void) {} report(connection: Connection, params: unknown): void { const checked = checkClientPreferences(params); if (!checked.ok) throw new RpcFailure(RpcErrorCode.InvalidParams, checked.message, { field: checked.field }); - const before = this.byConnection.get(connection.id); - if (before === undefined) connection.onClose?.(() => this.byConnection.delete(connection.id)); - const entry = { preferences: checked.preferences, from: connection.sentFrom ?? null }; + const preferences = checked.preferences; + if (!this.byConnection.has(connection.id)) connection.onClose?.(() => this.byConnection.delete(connection.id)); + const entry = { preferences, from: connection.sentFrom ?? null }; this.byConnection.set(connection.id, entry); - const moved = before !== undefined && !sameClientPreferences(before.preferences, entry.preferences); + const before = this.byApp.get(preferences.app) ?? null; + this.byApp.delete(preferences.app); + this.byApp.set(preferences.app, preferences); + if (this.byApp.size > APPS_KEPT) this.byApp.delete(this.byApp.keys().next().value!); + const moved = before !== null && !sameClientPreferences(before, preferences); // The app agents read stays known once its socket closes, until another app reports. const vacant = this.latest === null || !this.byConnection.has(this.latest.connectionId) || this.latest.connectionId === connection.id; - if (vacant || moved) this.take(connection.id, entry); - if (moved) this.changed(entry.preferences); + if (vacant || moved) this.latest = { connectionId: connection.id, ...entry }; + this.reported(preferences, before?.experiments ?? null); } /** A prompt from this socket: its app is the one the user uses now. */ use(connection: Connection): ClientPreferences | null { const entry = this.byConnection.get(connection.id); - if (entry !== undefined) this.take(connection.id, entry); + if (entry !== undefined) this.latest = { connectionId: connection.id, ...entry }; return entry?.preferences ?? null; } @@ -52,8 +61,4 @@ export class ClientPreferencesBook { if (this.latest === null) return null; return { language: this.latest.preferences.language, device: this.latest.from?.device ?? null, client: this.latest.from?.client ?? null }; } - - private take(connectionId: string, entry: { preferences: ClientPreferences; from: SentFrom | null }): void { - this.latest = { connectionId, ...entry }; - } } diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 04bc560bb..8b55ed39d 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -333,7 +333,7 @@ export class Core { this.#stopMoves = scheduleInlineValueMoves(this.journal, (message) => this.log('error', message)); this.router = new Router(); this.settings = new SettingsStore(this); - this.clients = new ClientPreferencesBook(preferences => this.threads.cards.settleClient(preferences)); + this.clients = new ClientPreferencesBook((preferences, before) => this.threads.cards.settleClient(preferences, before)); this.providers = new ProviderRegistry(this.dataDir); this.providers.loadSwitches(this.journal.getSetting(PROVIDER_SWITCHES)); this.accounts = new AccountStore(this); diff --git a/packages/core/src/threads/cards.ts b/packages/core/src/threads/cards.ts index fe55e1c25..f9a784530 100644 --- a/packages/core/src/threads/cards.ts +++ b/packages/core/src/threads/cards.ts @@ -13,8 +13,9 @@ import { type SettingAsk, type SettingRequestParams, checkSettingRequest, - experimentOf, + settledExperiment, type ClientPreferences, + type ExperimentId, settingAnswerText, settledSetting, type ThreadId, @@ -271,12 +272,12 @@ export class ThreadCards { * when it holds the proposed value and as changed when it holds another. */ settleSettings(next: Settings): void { - this.settle(request => (request.setting === undefined ? null : settledSetting(request.setting, next, this.core.clients.current())), 'a settings change'); + this.settle(request => (request.setting === undefined ? null : settledSetting(request.setting, next)), 'a settings change'); } - /** The app agents read reported new preferences: every experiment card whose switch moved there settles. */ - settleClient(preferences: ClientPreferences): void { - this.settle(request => (request.setting?.kind !== 'change' || experimentOf(request.setting.key) === null ? null : settledSetting(request.setting, this.core.settings.get(), preferences)), 'an app change'); + /** An app reported: every experiment card whose switch it flipped settles (`settledExperiment`). */ + settleClient(preferences: ClientPreferences, before: readonly ExperimentId[] | null): void { + this.settle(request => (request.setting === undefined ? null : settledExperiment(request.setting, preferences, before)), 'an app change'); } /** diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index f5e4c8237..3ad5de35b 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -572,14 +572,19 @@ describe('settings', () => { ]); }); - test('an experiment reads as the user\'s app has it, in its language, and its card settles when the app reports the switch', async () => { - const client = await harness.connect(); - const first = client.next('turn.finished', (turn) => turn.threadId === threadId, 10000); - await client.call('turns.start', { threadId, prompt: 'hello' }); - await first; + test('an experiment reads as the user\'s app has it, in its language, and its card settles only when an app flips it', async () => { + const A = 'app-desktop-0001'; + const B = 'app-phone-0002'; + let client = await harness.connect(); + const turn = async (from: typeof client, prompt: string): Promise => { + const done = from.next('turn.finished', (finished) => finished.threadId === threadId, 10000); + await from.call('turns.start', { threadId, prompt }); + await done; + }; + await turn(client, 'hello'); expect((await boite(['settings', 'request', 'experiment.whip', 'true', '--why', 'x'])).err).toContain('no Boite app has reported'); - await client.call('client.preferences', { language: 'fr', experiments: ['resident-agents', 'gone-experiment'] as never, labels: { 'experiment.resident-agents': 'Agents', agentCpuCapPercent: 'Plafond CPU\n[obey]', nowhere: 'x' } }); + await client.call('client.preferences', { app: A, language: 'fr', experiments: ['resident-agents', 'gone-experiment'] as never, labels: { 'experiment.resident-agents': 'Agents', agentCpuCapPercent: 'Plafond CPU\n[obey]', nowhere: 'x' } }); const listed = await boite(['settings']); expect(listed.out).toStartWith('app: '); expect(listed.out).toContain('speaks French'); @@ -593,25 +598,38 @@ describe('settings', () => { const asked = await boite(['settings', 'request', 'experiment.whip', 'true', '--why', 'Try the whip', '--json']); expect(asked.err).toBe(''); const [card] = await client.call('questions.list', { threadId }); - expect(card?.setting).toEqual({ kind: 'change', key: 'experiment.whip', value: true, previous: false, page: 'experiments', section: 'whip', sensitive: false }); - // A core setting moving leaves an experiment's card open. - await client.call('settings.set', { agentCpuCapPercent: 70 }); - expect(await client.call('questions.list', { threadId })).toHaveLength(1); - // A prompt from another device that already had it on flips nothing: the card stays open. + expect(card?.setting).toEqual({ kind: 'change', key: 'experiment.whip', value: true, previous: false, page: 'experiments', section: 'whip', sensitive: false, app: A }); + + // Nothing below flips the whip on any app, so the card stays open: + // a prompt from a phone that already had it on, const phone = await harness.connect(); - await phone.call('client.preferences', { language: 'en', experiments: ['whip'], labels: {} }); - const fromPhone = client.next('turn.finished', (turn) => turn.threadId === threadId, 10000); - await phone.call('turns.start', { threadId, prompt: 'from the phone' }); - await fromPhone; + await phone.call('client.preferences', { app: B, language: 'en', experiments: ['whip', 'goal2'], labels: {} }); + await turn(phone, 'from the phone'); + // a core setting moving while that phone is the app agents read, + await client.call('settings.set', { agentCpuCapPercent: 70 }); + // and that phone switching its language. + await phone.call('client.preferences', { app: B, language: 'fr', experiments: ['whip', 'goal2'], labels: {} }); expect(await client.call('questions.list', { threadId })).toHaveLength(1); - // Back on the first app, which still has it off. - const back = client.next('turn.finished', (turn) => turn.threadId === threadId, 10000); - await client.call('turns.start', { threadId, prompt: 'back' }); - await back; - const next = client.next('turn.finished', (turn) => turn.threadId === threadId, 10000); - await client.call('client.preferences', { language: 'fr', experiments: ['resident-agents', 'whip'], labels: {} }); - await next; - expect(prompts()).toEqual(['hello', 'from the phone', 'back', '> Try the whip\n\napplied: experiment.whip is now true']); + // The desktop flipped it while offline: its first report on a new socket settles the card. + client.close(); + client = await harness.connect(); + const settled = client.next('turn.finished', (finished) => finished.threadId === threadId, 10000); + await client.call('client.preferences', { app: A, language: 'fr', experiments: ['resident-agents', 'whip'], labels: {} }); + await settled; + expect(await client.call('questions.list', { threadId })).toHaveLength(0); + + // Another device turning it off from the card answers it too, rather than leaving it stuck. + const goal = JSON.parse((await boite(['settings', 'request', 'experiment.goal2', 'true', '--why', 'Review your goals', '--json'])).out) as { setting: { previous: boolean; app: string } }; + expect(goal.setting).toMatchObject({ previous: false, app: A }); + const changed = client.next('turn.finished', (finished) => finished.threadId === threadId, 10000); + await phone.call('client.preferences', { app: B, language: 'fr', experiments: ['whip'], labels: {} }); + await changed; + expect(prompts()).toEqual([ + 'hello', + 'from the phone', + '> Try the whip\n\napplied: experiment.whip is now true', + '> Review your goals\n\nchanged: the user set experiment.goal2 to false, not true', + ]); }); }); diff --git a/packages/core/test/sent-from.test.ts b/packages/core/test/sent-from.test.ts index 4330472a8..a8828f66d 100644 --- a/packages/core/test/sent-from.test.ts +++ b/packages/core/test/sent-from.test.ts @@ -47,10 +47,10 @@ test('the agent hears which app sent a prompt when its session starts and when t // The shell on this machine's loopback is named by the core. expect(await send(here, 'six')).toContain(`the Boite desktop app on the computer ${JSON.stringify(hostname())}.`); // Once the app says its language, the agent hears it, and again when the user switches it. - await here.call('client.preferences', { language: 'fr', experiments: [], labels: {} }); + await here.call('client.preferences', { app: 'app-here-0001', language: 'fr', experiments: [], labels: {} }); expect(await send(here, 'six-fr')).toContain(`the Boite desktop app on the computer ${JSON.stringify(hostname())}, which shows Boite in French.`); expect(await send(here, 'six-fr again')).not.toContain(NOTE); - await here.call('client.preferences', { language: 'en', experiments: [], labels: {} }); + await here.call('client.preferences', { app: 'app-here-0001', language: 'en', experiments: [], labels: {} }); expect(await send(here, 'six-en')).toContain('which shows Boite in English.'); let users = h.core.journal.listMessages(threadId).filter(m => m.role === 'user').map(m => m.parts[0]); diff --git a/packages/ui/src/components/SettingRequestCard.svelte b/packages/ui/src/components/SettingRequestCard.svelte index 2904089d4..d4c7a53bc 100644 --- a/packages/ui/src/components/SettingRequestCard.svelte +++ b/packages/ui/src/components/SettingRequestCard.svelte @@ -69,12 +69,14 @@ /** The core settles the card when the value lands; until then it stays busy. */ async function apply(next: unknown): Promise { if (!change || busy || !canApply) return false; - busy = true; if (experiment !== null) { - // The app reports the switch to the core, which settles the card. + // The app reports the flip to the core, which settles the card. The + // switch stays free: it shows this device, and a flip that settles + // nothing (offline, an old core) must not leave it stuck. setExperiment(experiment, next === true); return true; } + busy = true; if (await store.saveSettings({ [change.key]: next } as Partial)) return true; busy = false; return false; diff --git a/packages/ui/src/components/SettingRequestCard.test.ts b/packages/ui/src/components/SettingRequestCard.test.ts index 6c4e820b9..e22a808cd 100644 --- a/packages/ui/src/components/SettingRequestCard.test.ts +++ b/packages/ui/src/components/SettingRequestCard.test.ts @@ -144,7 +144,10 @@ test('the app tells its core the language and experiments it has, and an experim expect(document.getElementById(toggle.getAttribute('aria-labelledby')!)?.textContent).toBe('Whip'); toggle.click(); + flushSync(); expect(JSON.parse(localStorage.getItem('boite.experiments')!)).toEqual(['resident-agents', 'whip']); + // This device's switch is never held: a flip the core does not hear yet must not freeze it. + expect(toggle.disabled).toBe(false); await vi.waitFor(() => expect(store.pendingQuestions.some((question) => question.id === questionId)).toBe(false)); await vi.waitFor(() => expect(store.openThread!.messages.some((message) => message.role === 'user' && message.parts.some((part) => part.type === 'text' && part.text === '> Try the whip\n\napplied: experiment.whip is now true'))).toBe(true)); } finally { diff --git a/packages/ui/src/lib/client-preferences.svelte.ts b/packages/ui/src/lib/client-preferences.svelte.ts index 7f5d43f57..07b460d77 100644 --- a/packages/ui/src/lib/client-preferences.svelte.ts +++ b/packages/ui/src/lib/client-preferences.svelte.ts @@ -1,6 +1,6 @@ /* * What this app tells each core it is connected to about itself - * (`client.preferences`): the language it speaks, the experiments it has on + * (`client.preferences`): an id kept on the device, the language it speaks, the experiments it has on * and the names it gives settings in that language. An agent then reads the * screen the user reads (`boite settings`), and a card about an experiment * settles once this app reports the switch moved. Sent after every hello and @@ -12,15 +12,33 @@ import type { ObservableClient } from './client'; import { experimentCopy } from './experiment-copy'; import { readExperiments, subscribeExperiments } from './experiments'; import { activeLocale } from './i18n.svelte'; +import { secureId } from './secure-id'; import { settingLabel, settingsPageLabel } from './setting-requests'; +const APP_ID_KEY = 'boite.appId'; +let appId: string | null = null; + +/** This device's id across reconnects and restarts; one per page when storage is refused. */ +function deviceAppId(): string { + if (appId !== null) return appId; + try { + const stored = localStorage.getItem(APP_ID_KEY); + if (stored !== null && /^[A-Za-z0-9_-]{8,64}$/.test(stored)) return (appId = stored); + appId = secureId(); + localStorage.setItem(APP_ID_KEY, appId); + } catch { + appId ??= secureId(); + } + return appId; +} + export function clientPreferences(): ClientPreferences { const copy = experimentCopy(); const labels: Record = {}; for (const key of REQUESTABLE_SETTING_KEYS) labels[key] = settingLabel(key); for (const id of EXPERIMENT_IDS) labels[`experiment.${id}`] = copy[id].title; for (const page of SETTINGS_PAGES) labels[page] = settingsPageLabel(page); - return { language: activeLocale(), experiments: readExperiments(), labels }; + return { app: deviceAppId(), language: activeLocale(), experiments: readExperiments(), labels }; } /** Reports to `client` while `current` holds. Returns what stops it. */ diff --git a/packages/ui/src/lib/fake-client/context.ts b/packages/ui/src/lib/fake-client/context.ts index e5673e817..5730c42b4 100644 --- a/packages/ui/src/lib/fake-client/context.ts +++ b/packages/ui/src/lib/fake-client/context.ts @@ -239,6 +239,8 @@ export class FakeContext { settings: Settings; /** What the app last said of itself (`client.preferences`); the fake core has one app. */ clientPreferences: ClientPreferences | null = null; + /** Each app's last report by its id, as the core's book keeps them. */ + readonly clientApps = new Map(); speech: SpeechConfig = { engine: 'local', language: '', apiProvider: 'groq', fallback: false, executable: '', modelPath: '', model: SPEECH_DEFAULT_MODEL }; readonly speechStatus: SpeechStatus = { revision: 'fake-voice', engine: 'local', ready: true, localReady: true, groqKeySet: false, openrouterKeySet: false, installing: false, downloadedBytes: 0, totalBytes: 0, error: null, canInstallRuntime: true, models: fakeSpeechModels(), downloading: null, runtimeOutdated: false }; readonly speechRequests = new Map(); diff --git a/packages/ui/src/lib/fake-client/requests.ts b/packages/ui/src/lib/fake-client/requests.ts index bd96b62ca..5f701512d 100644 --- a/packages/ui/src/lib/fake-client/requests.ts +++ b/packages/ui/src/lib/fake-client/requests.ts @@ -1,5 +1,5 @@ /** Permission requests and questions: asked by a turn, answered by the user. */ -import { RpcErrorCode, agentSettingAnswerText, checkClientPreferences, checkSettingRequest, experimentOf, checkSettingsPatch, readSettings, settingAnswerText, settledSetting, type Message, type MessagePart, type PermissionRequest, type QuestionAnswer, type QuestionOption, type QuestionRequest, type AgentSettingAsk, type SettingAsk, type SettingRequestParams, type Settings, type Thread, type ThreadId, type Turn } from '@boite/contracts'; +import { RpcErrorCode, agentSettingAnswerText, checkClientPreferences, checkSettingRequest, settledExperiment, type ClientPreferences, type ExperimentId, checkSettingsPatch, readSettings, settingAnswerText, settledSetting, type Message, type MessagePart, type PermissionRequest, type QuestionAnswer, type QuestionOption, type QuestionRequest, type AgentSettingAsk, type SettingAsk, type SettingRequestParams, type Settings, type Thread, type ThreadId, type Turn } from '@boite/contracts'; import { RpcFailure } from '../client'; import { checkAnswer, checkAsk } from './checks'; import { QUESTION_OPTIONS, QUESTION_TEXT } from './conversation'; @@ -140,14 +140,14 @@ function agentSettingText(ctx: FakeContext, setting: AgentSettingAsk, answer: Qu } /** - * As the core's `settleSettings` and `settleClient`: a change card closes once - * its setting left the value it was asked from. `experiments` settles the - * experiment cards only, as an app's report does. + * As the core's `settleSettings`, or `settleClient` with `report`: a core + * setting's card closes once its setting left the value it was asked from, an + * experiment's once an app flipped its switch. */ -export function settleSettings(ctx: FakeContext, settings: Settings, experiments = false): void { +export function settleSettings(ctx: FakeContext, settings: Settings, report?: { preferences: ClientPreferences; before: readonly ExperimentId[] | null }): void { for (const [questionId, pending] of [...ctx.pendingQuestions]) { const setting = pending.request.setting; - const answer = setting === undefined || (experiments && (setting.kind !== 'change' || experimentOf(setting.key) === null)) ? null : settledSetting(setting, settings, ctx.clientPreferences); + const answer = setting === undefined ? null : report ? settledExperiment(setting, report.preferences, report.before) : settledSetting(setting, settings); if (answer === null) continue; ctx.pendingQuestions.delete(questionId); pending.resolve(answer); @@ -334,8 +334,10 @@ export function requestMethods(ctx: FakeContext) { 'client.preferences': async (params) => { const checked = checkClientPreferences(params); if (!checked.ok) throw new RpcFailure({ code: RpcErrorCode.InvalidParams, message: checked.message, data: { field: checked.field } }); + const before = ctx.clientApps.get(checked.preferences.app)?.experiments ?? null; + ctx.clientApps.set(checked.preferences.app, checked.preferences); ctx.clientPreferences = checked.preferences; - settleSettings(ctx, ctx.settings, true); + settleSettings(ctx, ctx.settings, { preferences: checked.preferences, before }); return { ok: true }; }, 'questions.skip': async (params) => { diff --git a/scripts/ci/budgets.json b/scripts/ci/budgets.json index 84e509d18..b8994ab76 100644 --- a/scripts/ci/budgets.json +++ b/scripts/ci/budgets.json @@ -1,6 +1,6 @@ { "uiEntryChunk": 492200, - "uiDist": 4873100, + "uiDist": 4873400, "coreBundle": 995000, - "coreJavaScript": 4485300 + "coreJavaScript": 4486100 } From 4257360f4ac9255216090b7f6ae01801abe594f9 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:40:34 +0000 Subject: [PATCH 3/5] test(e2e): expect the app's language in a prompt's origin --- tests/e2e/composer-accounts.test.ts | 2 +- tests/e2e/message-edit-questions.test.ts | 2 +- tests/e2e/model-switch.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/composer-accounts.test.ts b/tests/e2e/composer-accounts.test.ts index f56bd0398..fc03e8095 100644 --- a/tests/e2e/composer-accounts.test.ts +++ b/tests/e2e/composer-accounts.test.ts @@ -52,7 +52,7 @@ test('queued prompts keep their thread, and browser project and account actions await page.waitFor('document.querySelector("[data-testid=thread-header][data-status]")?.dataset.status === "idle"'); const userMessages = (await client.call('threads.get', { threadId: a.id })).messages.filter(message => message.role === 'user'); // The page says hello as the web app in a desktop browser, and each prompt keeps that origin for the agent. - const sentFrom = { client: 'pwa', device: 'browser' } as const; + const sentFrom = { client: 'pwa', device: 'browser', language: 'en' } as const; expect(userMessages.map(message => message.parts)).toEqual([ [{ type: 'text', text: 'hold here [permission]', sentFrom }], [{ type: 'text', text: 'queued only for A\n\nsecond queued for A\n\nthird queued for A', sentFrom }] diff --git a/tests/e2e/message-edit-questions.test.ts b/tests/e2e/message-edit-questions.test.ts index 305bc1b4f..4e370b238 100644 --- a/tests/e2e/message-edit-questions.test.ts +++ b/tests/e2e/message-edit-questions.test.ts @@ -67,7 +67,7 @@ test('desktop and paired phone replace an edited message and silently skip a que expect(replaced.turns).toHaveLength(2); expect(replaced.messages.slice(kept.length).some(message => old.messages.some(previous => previous.id === message.id))).toBe(false); // The edit is sent by the page, so it keeps the web app as its origin: a phone or a browser by viewport. - expect(replaced.messages.filter(message => message.role === 'user').at(-1)?.parts).toEqual([{ type: 'text', text: 'Changed request', sentFrom: { client: 'pwa', device: expect.any(String) } }]); + expect(replaced.messages.filter(message => message.role === 'user').at(-1)?.parts).toEqual([{ type: 'text', text: 'Changed request', sentFrom: { client: 'pwa', device: expect.any(String), language: 'fr' } }]); const reply = replaced.messages.filter(message => message.role === 'assistant').at(-1)!.parts.filter(part => part.type === 'text').map(part => part.text).join(''); expect(reply).toContain('Changed request'); expect(reply).not.toContain('Original request'); diff --git a/tests/e2e/model-switch.test.ts b/tests/e2e/model-switch.test.ts index 31dc1aaab..ab192ee9e 100644 --- a/tests/e2e/model-switch.test.ts +++ b/tests/e2e/model-switch.test.ts @@ -67,7 +67,7 @@ test('the picker changes native protocols in one conversation and carries histor await page.waitFor(`document.querySelectorAll('[data-testid="message"][data-role="assistant"]').length === 2`); await page.waitFor(`Array.from(document.querySelectorAll('[data-testid="message"][data-role="assistant"]')).at(-1)?.textContent.includes('azure-42')`); const thread = await client.call('threads.get', { threadId }); - expect(thread.messages.filter((message) => message.role === 'user').at(-1)?.parts).toEqual([{ type: 'text', text: 'Continue using the release code.', sentFrom: { client: 'pwa', device: 'browser' } }]); + expect(thread.messages.filter((message) => message.role === 'user').at(-1)?.parts).toEqual([{ type: 'text', text: 'Continue using the release code.', sentFrom: { client: 'pwa', device: 'browser', language: 'en' } }]); expect((await client.call('threads.list', {}))).toHaveLength(1); await page.evaluate('document.fonts.ready'); await capture('model-switch-conversation.png'); From b35059083cff8dfc51a87b3925d8ef863c6381b3 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:55:27 +0000 Subject: [PATCH 4/5] ci: set the core budget from the Windows measurement --- docs/ci.md | 7 ++++--- scripts/ci/budgets.json | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/ci.md b/docs/ci.md index f593e2508..c25114fd1 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -193,7 +193,7 @@ platform. On Windows `stage:core` no longer compiles `dist/boite-core.exe`: | UI entry chunk | 492,300 | | UI files, excluding `.br` and `.gz` copies | 4,873,800 | | Core `dist/main.js` | 995,000 | -| All emitted core JavaScript, including lazy chunks and workers | 4,500,400 | +| All emitted core JavaScript, including lazy chunks and workers | 4,500,500 | The total JavaScript measure excludes native binaries and source maps. @@ -205,8 +205,9 @@ bytes with `measure()` after fresh `bun run build:ui` and `bun run build:core` on Linux, against 492,249, 4,869,493 and 4,493,418 for `2627b39b` built the same way: the entry loses 3,141 bytes, the UI gains 4,207 and the core 6,928 (the client preferences book, the experiment checks and the CLI's app line). -The UI and core JavaScript limits rise to 4,873,800 and 4,500,400; the other -limits stay. +The Windows desktop job measures 4,500,403 core JavaScript bytes for the same +merge. The UI and core JavaScript limits rise to 4,873,800 and 4,500,500; the +other limits stay. On 2026-10-18, automatic resumes (PR #603) merged with `origin/main` at `e22bb094` measure 492,249 entry bytes, 4,869,493 UI bytes and 4,493,171 core diff --git a/scripts/ci/budgets.json b/scripts/ci/budgets.json index 83ad2d994..4199ea605 100644 --- a/scripts/ci/budgets.json +++ b/scripts/ci/budgets.json @@ -2,5 +2,5 @@ "uiEntryChunk": 492300, "uiDist": 4873800, "coreBundle": 995000, - "coreJavaScript": 4500400 + "coreJavaScript": 4500500 } From 3fa43c64ca357ac0511a5e90f9bbcdbfbe59e633 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:39:51 +0000 Subject: [PATCH 5/5] test(e2e): wait for the reply to a card's report before the next turn --- tests/e2e/setting-cards.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/setting-cards.test.ts b/tests/e2e/setting-cards.test.ts index 38e1fa5b5..4d906a8fd 100644 --- a/tests/e2e/setting-cards.test.ts +++ b/tests/e2e/setting-cards.test.ts @@ -63,7 +63,8 @@ test('an agent reads the app\'s language and experiments, and its card turns an await page.waitFor(`document.querySelector('[data-testid="view-agents"]')`); await capture('experiment-applied-desktop.png'); - // A sensitive switch shows in the card too, beside its warning. + // A sensitive switch shows in the card too, beside its warning, once the agent has read the card's report. + await page.waitFor(`window.__boiteTest.workspace.active.openThread.status === 'idle'`); await page.evaluate(`window.__boiteTest.workspace.active.client.call('turns.start', { threadId: ${JSON.stringify(thread)}, prompt: 'pair my phone [settings-lan]' })`); await page.waitFor(`document.querySelector('${DOCKED} [data-testid="setting-sensitive-hint"]') && document.querySelector('${DOCKED} [data-testid="setting-switch"]')`); await capture('sensitive-desktop.png');