Merge branch 'stack/agent-profiles-8-authoring' into stack/agent-profiles-9-rename
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user