Merge branch 'stack/agent-profiles-8-authoring' into stack/agent-profiles-9-rename

This commit is contained in:
Yichen Jiang
2026-08-07 03:32:42 +08:00
3 changed files with 79 additions and 4 deletions

View File

@@ -51,7 +51,13 @@ export const inject = ['slots', 'locale', 'connection']
export function apply(ctx: ClientContext): void {
const { api } = ctx.get('connection') as ConnectionHandle
const controller = new AgentPresetSettingsController(api)
const section = new AgentPresetSectionController(api)
// One roster, four surfaces. The chip is registered in a later scope, so it
// subscribes here rather than being reached from this one.
const rosterReaders = new Set<() => void>()
const section = new AgentPresetSectionController(api, () => {
void controller.load()
for (const read of rosterReaders) read()
})
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
@@ -119,6 +125,12 @@ export function apply(ctx: ClientContext): void {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
void seat.load()
})
// Authoring writes a FILE, not a setting, so nothing on the wire
// announces it — without this the screen that starts the next session
// keeps offering the roster as it stood when the chip first loaded, and
// a preset authored to be used is missing from the one place it is used.
const readRoster = (): void => { void seat.load() }
rosterReaders.add(readRoster)
const chip = scope.slots.register({
name: 'conversation.hero.agentPreset',
locale: 'settings.agentPreset',
@@ -134,6 +146,7 @@ export function apply(ctx: ClientContext): void {
return () => {
stop()
settingsMoved()
rosterReaders.delete(readRoster)
chip()
label()
}

View File

@@ -107,7 +107,17 @@ export class AgentPresetSectionController {
/** Page snapshot the renderer subscribes to. */
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
constructor(private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>) {}
constructor(
private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>,
/**
* Called after this page changes the roster DIRECTORY, so the other
* surfaces reading the same roster re-read it. A settings field moving is
* already announced by the host through `settings/changed`; a file written
* or deleted here is not, and the new-session chip has no other way to
* learn a preset it should offer now exists.
*/
private readonly rosterChanged: () => void = () => {},
) {}
private set(patch: Partial<AgentPresetSectionState>): void {
this.store.set({ ...this.store.getSnapshot(), ...patch })
@@ -265,6 +275,7 @@ export class AgentPresetSectionController {
}
this.set({ draft: null })
await this.load()
this.rosterChanged()
} catch (error) {
this.patchDraft({ saving: false, error: messageOf(error) })
}
@@ -303,6 +314,7 @@ export class AgentPresetSectionController {
draft: draft?.id === pendingDelete && !draft.creating ? null : draft,
})
await this.load()
this.rosterChanged()
} catch (error) {
this.set({ deleting: false, pendingDelete: null, error: messageOf(error) })
}

View File

@@ -30,6 +30,21 @@ const ROSTER_ONE = {
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
}
/** The roster after this browser authored one preset of its own. */
const ROSTER_AUTHORED = {
rpcId: 'r',
result: {
ok: true as const,
value: {
presets: [
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'mine', trust: 'user', isDefault: false },
],
authorable: true,
},
},
}
/** The same roster with a second preset carrying the default. */
const ROSTER_MOVED = {
rpcId: 'r',
@@ -49,7 +64,7 @@ async function bench() {
const ctx = new Context()
// The host's answer, mutable so a spec can move the default the way the
// settings surface does and watch who re-reads it.
let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED = ROSTER_ONE
let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED | typeof ROSTER_AUTHORED = ROSTER_ONE
const moveDefault = (): void => { ROSTER = ROSTER_MOVED }
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
@@ -63,7 +78,13 @@ async function bench() {
rpcId: 'r',
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '', writable: false } },
}),
write: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
write: (payload: { agentPreset: string }) => {
calls.push(`write:${payload.agentPreset}`)
// The host's roster now contains it, which is the whole point of the
// write and what every surface must converge on.
ROSTER = ROSTER_AUTHORED
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
},
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
select: (payload: { agentPreset: string }) => {
calls.push(`select:${payload.agentPreset}`)
@@ -282,6 +303,35 @@ describe('ui-agent-preset apply', () => {
conversation()
})
it('offers a just-authored preset on the new-session chip', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
const chip = slots.entries('conversation.hero.agentPreset')[0]!
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
await seat.load()
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard'])
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
await section.createFrom()
section.setId('mine')
section.setName('我的模式')
await section.save()
// Authoring writes a file rather than a setting, so nothing on the wire
// announces it: a preset authored to be used must appear on the one screen
// that starts sessions, without a reload.
await vi.waitFor(() => {
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine'])
})
conversation()
})
it('applies the staged choice to the blank session the flow lands on', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)