Merge branch 'stack/agent-profiles-7-docs' into stack/agent-profiles-8-authoring

# Conflicts:
#	packages/client/ui-agent-preset/src/client/settings-store.ts
#	packages/client/ui-agent-preset/tests/settings-store.spec.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
This commit is contained in:
Yichen Jiang
2026-08-07 11:49:30 +08:00
7 changed files with 162 additions and 38 deletions

View File

@@ -65,6 +65,12 @@ export interface AgentPresetOption {
export interface AgentPresetSettingsState {
status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
error: string | null
/**
* Whether this browser may persist the choice at all. `settings.describe` is
* loopback-only and reports a read-only provider as `writable: false`; the
* row then shows the current default and disables the control rather than
* offering a write the gateway will refuse.
*/
writable: boolean
currentValue: string
options: readonly AgentPresetOption[]
@@ -73,6 +79,8 @@ export interface AgentPresetSettingsState {
const INITIAL: AgentPresetSettingsState = {
status: 'idle',
error: null,
// Assumed until `load()` asks; a row that has not read yet renders nothing
// interactive anyway (status 'idle').
writable: true,
currentValue: '',
options: [],
@@ -110,9 +118,15 @@ export class AgentPresetSettingsController {
this.set({ status: 'unavailable', options: [], currentValue: '' })
return
}
// The roster says what may be chosen; `settings.describe` says whether
// this browser may write the choice down. A non-loopback browser reaches
// neither method, so a refused describe leaves the row read-only rather
// than offering a control whose write answers `settings-not-exposed`.
const described = await this.api.settings.describe({})
this.set({
status: 'ready',
error: null,
writable: described.result.ok && described.result.value.writable,
options: presets.map(preset => ({
id: preset.id,
trust: preset.trust,

View File

@@ -92,6 +92,11 @@ async function bench() {
},
},
settings: {
// The row reads this to learn whether this browser may write at all.
describe: () => Promise.resolve({
rpcId: 'r',
result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
}),
update: (payload: { patch: unknown }) => { calls.push(`settings:${JSON.stringify(payload.patch)}`); return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) },
},
},

View File

@@ -18,7 +18,13 @@ interface Recorded { ns: string; patch: unknown }
/** A client whose roster and write outcome the test controls. */
function fakeApi(
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
options: { writes?: Recorded[]; failWrite?: string; failList?: string; failWriteWith?: Error } = {},
options: {
writes?: Recorded[]
failWrite?: string
failList?: string
failWriteWith?: Error
readOnly?: boolean
} = {},
): IApiClient {
return {
agentPresets: {
@@ -27,6 +33,15 @@ function fakeApi(
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }),
},
settings: {
// Loopback-only in production; a read-only provider answers writable:false
// and the row disables its control instead of offering a refused write.
describe: () => Promise.resolve({
rpcId: 'r',
result: {
ok: true as const,
value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] },
},
}),
update: (payload: { ns: string; patch: unknown }) => {
options.writes?.push({ ns: payload.ns, patch: payload.patch })
if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith)
@@ -44,6 +59,20 @@ function fakeApi(
}
describe('the agent-preset settings controller', () => {
it('disables the control when this browser may not write settings', async () => {
const controller = new AgentPresetSettingsController(fakeApi([
{ id: 'standard', trust: 'system', isDefault: true },
], { readOnly: true }))
await controller.load()
// `settings.describe` is loopback-only and reports a read-only provider;
// offering a control whose write answers `settings-not-exposed` would
// promise a switch the host refuses.
expect(controller.store.getSnapshot().writable).toBe(false)
expect(controller.store.getSnapshot().currentValue).toBe('standard')
})
it('derives options and the current default from one roster call', async () => {
const controller = new AgentPresetSettingsController(fakeApi([
{ id: 'standard', trust: 'system', isDefault: true },