feat(web): choose the agent preset on the new-session screen
The composer seat spent nearly all its life disabled: a session's composition is fixed once a turn has run. Move the choice to the new-session screen beside the workspace picker, where it still works, and let the session header report what a running session runs. The hero pick is staged rather than applied — that screen precedes the session it belongs to. It lands when a session becomes current and is still blank, which covers both the session a workspace connect creates and the blank one it reuses; riding `sessions.create` would miss the second. It is spent on first use, matching the workspace picker. Fix the durability the header field claimed but never had: `agentPreset` was declared on `SessionHeader` and dropped by the JSONL header line, the SQLite sessions row, the derived query index, and the cold list projection, so every resumed session came back composed from nothing. Add the web e2e lane that would have caught it — the one lane that mounts the shipped roster, which needed `cordis:group` in the scaffold's Loader builtins, as `mountRootInclude` already registers.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Registration: the General row, the settings section, and the per-session
|
||||
* composer seat all come from one apply, and each defers until the slot it
|
||||
* fills has been declared. A pushed settings change refreshes the surfaces
|
||||
* Registration: the General row, the settings section, the new-session chip,
|
||||
* and the header label all come from one apply, and each defers until the slot
|
||||
* it fills has been declared. A pushed settings change refreshes the surfaces
|
||||
* that are already showing, so a default set from one converges the other.
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,8 @@ import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
|
||||
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx'
|
||||
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowInjected } from '../src/client/AgentPresetRow.tsx'
|
||||
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
@@ -44,7 +46,10 @@ async function bench() {
|
||||
}),
|
||||
write: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
|
||||
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
|
||||
select: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
calls.push(`select:${payload.agentPreset}`)
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
update: (payload: { patch: unknown }) => { calls.push(`settings:${JSON.stringify(payload.patch)}`); return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) },
|
||||
@@ -65,14 +70,36 @@ function declareRoot(slots: SlotsService): () => void {
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
/** The composer's own declaration, which the seat registration waits for. */
|
||||
function declareComposer(slots: SlotsService): () => void {
|
||||
/** The conversation's own declarations, which the chip and label wait for. */
|
||||
function declareConversation(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'conversation',
|
||||
children: { 'conversation.input.agentPreset': { kind: 'single', scope: 'session' } },
|
||||
children: {
|
||||
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
/** A sessions double whose list can be moved and whose changes are pushed. */
|
||||
function sessionsDouble(state: {
|
||||
current?: string
|
||||
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
||||
}) {
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
list: {
|
||||
getSnapshot: () => state,
|
||||
subscribe: (fn: () => void) => {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
},
|
||||
},
|
||||
/** Push a list change the way the runtime's store does. */
|
||||
notify: () => { for (const fn of listeners) fn() },
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-agent-preset apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
@@ -190,54 +217,121 @@ describe('ui-agent-preset apply', () => {
|
||||
expect(calls.length - before).toBe(1)
|
||||
})
|
||||
|
||||
it('gives each session its own seat controller and drops its registrations on disposal', async () => {
|
||||
it('registers the new-session chip and the header label, and drops both on disposal', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareComposer(slots)
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: { s1: { blank: true, agentPreset: 'standard' } } }) } } as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply })
|
||||
await fiber.await()
|
||||
|
||||
const seat = slots.entries('conversation.input.agentPreset')[0]!
|
||||
expect(seat.component).toBe(AgentPresetSeat)
|
||||
const make = seat.inject as unknown as (id: string) => AgentPresetSeatInjected
|
||||
// Same session, same controller; a different session gets its own, because
|
||||
// "may it still switch" is a per-session fact.
|
||||
expect(make('s1').hooks.agentPresetSeat).toBe(make('s1').hooks.agentPresetSeat)
|
||||
expect(make('s2').hooks.agentPresetSeat).not.toBe(make('s1').hooks.agentPresetSeat)
|
||||
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
||||
expect(chip.component).toBe(AgentPresetSeat)
|
||||
const label = slots.entries('conversation.session.header.actions')[0]!
|
||||
expect(label.component).toBe(AgentPresetLabel)
|
||||
expect(label.options).toMatchObject({ id: 'agent-preset', order: 20 })
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.input.agentPreset')).toHaveLength(0)
|
||||
expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0)
|
||||
expect(slots.entries('conversation.session.header.actions')).toHaveLength(0)
|
||||
expect(slots.entries('settings.section')).toHaveLength(0)
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('reads a seat\'s session state through the live session list', async () => {
|
||||
it('applies the staged choice to the blank session the flow lands on', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state: {
|
||||
current?: string
|
||||
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
||||
} = { byId: {} }
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
await chip.load()
|
||||
// Picked on the hero screen, where there is no session yet.
|
||||
await chip.select('minimal')
|
||||
expect(calls).not.toContain('select:minimal')
|
||||
|
||||
state.current = 's1'
|
||||
state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'standard' }
|
||||
sessions.notify()
|
||||
|
||||
// Connecting a workspace produced the session; the stage reaches it there.
|
||||
await vi.waitFor(() => { expect(calls).toContain('select:minimal') })
|
||||
})
|
||||
|
||||
it('applies the stage to a session that records no preset of its own', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const sessions = sessionsDouble({
|
||||
current: 's1',
|
||||
byId: { s1: { id: 's1', blank: true } },
|
||||
})
|
||||
ctx.provide('sessions', sessions as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
await chip.load()
|
||||
await chip.select('minimal')
|
||||
|
||||
// A session created before the deployment composed presets records none;
|
||||
// reading that as "already runs it" would drop the pick on the floor.
|
||||
expect(calls).toContain('select:minimal')
|
||||
})
|
||||
|
||||
it('forgets the stage once it has been spent', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state = {
|
||||
current: 's1',
|
||||
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
|
||||
}
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
await chip.load()
|
||||
await chip.select('minimal')
|
||||
const spent = calls.filter(call => call === 'select:minimal').length
|
||||
sessions.notify()
|
||||
sessions.notify()
|
||||
|
||||
// Every later list movement would re-apply a stage that was not cleared,
|
||||
// switching sessions the user never picked for.
|
||||
await Promise.resolve()
|
||||
expect(calls.filter(call => call === 'select:minimal')).toHaveLength(spent)
|
||||
})
|
||||
|
||||
it('gives the header label the same roster the General row reads', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
declareComposer(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const byId: Record<string, { blank: boolean; agentPreset?: string }> = {}
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId }) } } as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
const make = slots.entries('conversation.input.agentPreset')[0]!
|
||||
.inject as unknown as (id: string) => AgentPresetSeatInjected
|
||||
const label = (slots.entries('conversation.session.header.actions')[0]!
|
||||
.inject as unknown as () => AgentPresetLabelInjected)()
|
||||
const row = (slots.entries('settings.general.item')[0]!
|
||||
.inject as unknown as () => AgentPresetRowInjected)()
|
||||
|
||||
const seat = make('s1')
|
||||
await seat.load()
|
||||
const unknownSession = seat.hooks.agentPresetSeat.getSnapshot().switchable
|
||||
// A session created before presets existed records none; the seat then
|
||||
// shows the roster default rather than an empty control.
|
||||
byId['s1'] = { blank: true }
|
||||
await seat.load()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
|
||||
byId['s1'] = { blank: true, agentPreset: 'standard' }
|
||||
await seat.load()
|
||||
await label.load()
|
||||
|
||||
// A session the list has not caught up to offers no switch rather than
|
||||
// guessing that it is blank.
|
||||
expect(unknownSession).toBe(false)
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ switchable: true, current: 'standard' })
|
||||
await make('s1').select('standard')
|
||||
// One roster behind both: the label resolves a name the settings row's own
|
||||
// load already fetched, rather than issuing a second read per session.
|
||||
expect(label.hooks.agentPresets).toBe(row.hooks.agentPreset)
|
||||
expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The two picker surfaces: the General-settings row that names the default for
|
||||
* later sessions, and the composer seat that names this one's — the seat stops
|
||||
* being a control once the conversation starts, because the history from there
|
||||
* on was produced under the preset's tools.
|
||||
* The three conversation-adjacent surfaces: the General-settings row naming the
|
||||
* default for later sessions, the new-session chip naming the next one's, and
|
||||
* the session header's read-only label. The split is the host's rule — a
|
||||
* session's history is produced under its preset's tools, so the choice is
|
||||
* only ever offered before one starts.
|
||||
*/
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx'
|
||||
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx'
|
||||
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
|
||||
@@ -30,8 +33,10 @@ const ROW_READY: AgentPresetSettingsState = {
|
||||
|
||||
const SEAT_READY: AgentPresetSeatState = {
|
||||
current: 'standard',
|
||||
options: [{ id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }],
|
||||
switchable: true,
|
||||
options: [
|
||||
{ id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' },
|
||||
{ id: 'mine', trust: 'user' },
|
||||
],
|
||||
busy: false,
|
||||
error: null,
|
||||
}
|
||||
@@ -47,18 +52,37 @@ function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
|
||||
return actions
|
||||
}
|
||||
|
||||
function renderSeat(state: Partial<AgentPresetSeatState> = {}, locked = false) {
|
||||
function renderSeat(state: Partial<AgentPresetSeatState> = {}) {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
|
||||
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
|
||||
render(<AgentPresetSeat {...({
|
||||
...actions,
|
||||
locked,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
function renderLabel(
|
||||
summary: { blank: boolean; agentPreset?: string } | undefined,
|
||||
roster: Partial<AgentPresetSettingsState> = {},
|
||||
) {
|
||||
// The chip and the label read the same roster, metadata included.
|
||||
const store = createSnapshotStore<AgentPresetSettingsState>({
|
||||
...ROW_READY, options: SEAT_READY.options, ...roster,
|
||||
})
|
||||
const sessions = createSnapshotStore({ byId: summary === undefined ? {} : { s1: summary } })
|
||||
const load = vi.fn(() => Promise.resolve())
|
||||
const view = render(<AgentPresetLabel {...({
|
||||
load,
|
||||
sessionId: 's1',
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useAgentPresets: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetLabelProps)} />)
|
||||
return { load, view }
|
||||
}
|
||||
|
||||
describe('the General-settings row', () => {
|
||||
it('reads the roster once and shows the current default', async () => {
|
||||
const actions = renderRow()
|
||||
@@ -140,85 +164,65 @@ describe('the General-settings row', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the composer seat', () => {
|
||||
it('reads the roster once and offers the session\'s preset', async () => {
|
||||
describe('the new-session chip', () => {
|
||||
it('reads the roster once and shows the staged preset by name', async () => {
|
||||
const actions = renderSeat()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.getByRole('button').textContent).toContain('standard')
|
||||
expect(screen.getByRole('button').textContent).toContain('标准模式')
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint)
|
||||
})
|
||||
|
||||
it('switches the session and closes the menu', () => {
|
||||
it('offers each preset with what it is for', () => {
|
||||
renderSeat()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// The id alone never said what a preset does; the description is the
|
||||
// whole reason a preset can publish metadata at all.
|
||||
expect(screen.getByText('完整的编码 agent。')).toBeTruthy()
|
||||
// A preset that published none still reads as a row, with its id standing
|
||||
// in for the name.
|
||||
expect(screen.getByText(en.noDescription)).toBeTruthy()
|
||||
expect(screen.getByText('mine')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the id when the staged preset published no name', () => {
|
||||
renderSeat({ current: 'mine' })
|
||||
|
||||
expect(screen.getByRole('button').textContent).toContain('mine')
|
||||
})
|
||||
|
||||
it('stages the picked preset and closes the menu', () => {
|
||||
const actions = renderSeat()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
|
||||
fireEvent.click(screen.getByText('mine'))
|
||||
|
||||
expect(actions.select).toHaveBeenCalledWith('mine')
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('becomes a plain label once the conversation has started', () => {
|
||||
renderSeat({ switchable: false })
|
||||
|
||||
// Not a disabled menu: that would suggest the preset could still change.
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByTitle(en.lockedHint).textContent).toBe('standard')
|
||||
})
|
||||
|
||||
it('closes an open menu the moment the session stops being blank', () => {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>(SEAT_READY)
|
||||
render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
act(() => { store.set({ ...SEAT_READY, switchable: false }) })
|
||||
|
||||
expect(screen.getByTitle(en.lockedHint)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables the trigger while the composer or a switch is busy', () => {
|
||||
it('disables the trigger while a switch is in flight', () => {
|
||||
renderSeat({ busy: true })
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
cleanup()
|
||||
|
||||
renderSeat({}, true)
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows a failed switch on the trigger', () => {
|
||||
it('shows a refused switch on the trigger', () => {
|
||||
renderSeat({ error: 'session has already started' })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started')
|
||||
})
|
||||
|
||||
it('renders nothing before the roster arrives or when there is none', () => {
|
||||
const empty = render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, options: [] })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const empty = renderSeat({ options: [] })
|
||||
expect(empty).toBeTruthy()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const unresolved = render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, current: '' })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
expect(unresolved.container.firstChild).toBeNull()
|
||||
renderSeat({ current: '' })
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('closes on an outside dismissal', () => {
|
||||
@@ -230,3 +234,42 @@ describe('the composer seat', () => {
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the session-header label', () => {
|
||||
it('names the preset the session runs, and never offers a switch', async () => {
|
||||
const { load } = renderLabel({ blank: false, agentPreset: 'standard' })
|
||||
|
||||
await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) })
|
||||
// A control here would promise a switch the host refuses outright.
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByTitle('完整的编码 agent。').textContent).toBe('标准模式')
|
||||
})
|
||||
|
||||
it('falls back to the id, and to the generic hint, when metadata is absent', () => {
|
||||
renderLabel({ blank: true, agentPreset: 'mine' })
|
||||
|
||||
expect(screen.getByTitle(en.headerHint).textContent).toBe('mine')
|
||||
})
|
||||
|
||||
it('shows the id until the roster resolves it', () => {
|
||||
renderLabel({ blank: false, agentPreset: 'standard' }, { options: [] })
|
||||
|
||||
// The session's own summary is the authority on which preset it runs; the
|
||||
// roster only supplies the display name, and its arrival is a later frame.
|
||||
expect(screen.getByTitle(en.headerHint).textContent).toBe('standard')
|
||||
})
|
||||
|
||||
it('renders nothing, and reads no roster, when the session records no preset', async () => {
|
||||
const absent = renderLabel({ blank: true })
|
||||
expect(absent.view.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
// A session the list has not caught up to is the same answer: a deployment
|
||||
// that composes no presets must not pay for a roster read per header.
|
||||
const unknown = renderLabel(undefined)
|
||||
expect(unknown.view.container.firstChild).toBeNull()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(absent.load).not.toHaveBeenCalled()
|
||||
expect(unknown.load).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
|
||||
} from '../src/client/settings-store.ts'
|
||||
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
|
||||
import type { SeatSessionSummary } from '../src/client/seat-store.ts'
|
||||
|
||||
interface Recorded { ns: string; patch: unknown }
|
||||
|
||||
@@ -60,6 +61,20 @@ describe('the agent-preset settings controller', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('carries the display metadata a preset published', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
|
||||
] as never))
|
||||
|
||||
await controller.load()
|
||||
|
||||
// Surfaces beyond this row read the same options; the id alone never said
|
||||
// what a preset does.
|
||||
expect(controller.store.getSnapshot().options).toEqual([
|
||||
{ id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an empty roster as unavailable, not as an error', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([]))
|
||||
|
||||
@@ -121,100 +136,6 @@ describe('the agent-preset settings controller', () => {
|
||||
expect(state.status).toBe('error')
|
||||
expect(state.error).toBe('host down')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the composer seat controller', () => {
|
||||
/** A seat over a fixed session summary. */
|
||||
function seat(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
summary: { blank: boolean; agentPreset?: string } | undefined,
|
||||
options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {},
|
||||
): AgentPresetSeatController {
|
||||
const api = {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
if (options.throwOn === 'list') return Promise.reject(new Error('socket closed'))
|
||||
return Promise.resolve(options.failList === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } })
|
||||
},
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
|
||||
options.writes?.push({ ns: 'select', patch: payload.agentPreset })
|
||||
return Promise.resolve(options.failSelect === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } })
|
||||
},
|
||||
},
|
||||
} as unknown as IApiClient
|
||||
return new AgentPresetSeatController(api, 's1' as never, () => summary)
|
||||
}
|
||||
|
||||
const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'minimal', trust: 'system', isDefault: false },
|
||||
]
|
||||
|
||||
it('shows what the session runs, not the deployment default', async () => {
|
||||
const controller = seat(ROSTER, { blank: true, agentPreset: 'minimal' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
// A resumed session runs what it was created with; showing `standard`
|
||||
// because it is the current default would be a lie about this session.
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
expect(controller.store.getSnapshot().switchable).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the roster default when the session records none', async () => {
|
||||
const controller = seat(ROSTER, { blank: true })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().current).toBe('standard')
|
||||
})
|
||||
|
||||
it('is not switchable once the conversation has started', async () => {
|
||||
const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().switchable).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to switch a session that already started', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// The host enforces the same rule; the seat simply never asks.
|
||||
expect(writes).toEqual([])
|
||||
expect(controller.store.getSnapshot().current).toBe('standard')
|
||||
})
|
||||
|
||||
it('switches a blank session and keeps the host\'s answer', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('restores the previous value when the host rejects the switch', async () => {
|
||||
const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { failSelect: 'already started' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.current).toBe('standard')
|
||||
expect(state.error).toBe('already started')
|
||||
})
|
||||
|
||||
it('shows the first preset when the roster marks none default', async () => {
|
||||
// Settings can name a preset that was since deleted; the picker still has
|
||||
@@ -269,42 +190,204 @@ describe('the composer seat controller', () => {
|
||||
// showing "mine" would be claiming a default that does not exist.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' })
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a refused roster read without emptying the seat', async () => {
|
||||
const controller = seat(ROSTER, { blank: true }, { failList: 'host down' })
|
||||
describe('the new-session chip controller', () => {
|
||||
/** A chip over a current session the test can move. */
|
||||
function chip(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
current: { id: string; blank: boolean; agentPreset?: string } | undefined,
|
||||
options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {},
|
||||
): AgentPresetSeatController {
|
||||
const api = {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
if (options.throwOn === 'list') return Promise.reject(new Error('socket closed'))
|
||||
return Promise.resolve(options.failList === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } })
|
||||
},
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
|
||||
options.writes?.push({ ns: 'select', patch: payload.agentPreset })
|
||||
return Promise.resolve(options.failSelect === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } })
|
||||
},
|
||||
},
|
||||
} as unknown as IApiClient
|
||||
return new AgentPresetSeatController(api, () => current as SeatSessionSummary | undefined)
|
||||
}
|
||||
|
||||
const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'minimal', trust: 'system', isDefault: false },
|
||||
]
|
||||
|
||||
it('opens on the deployment default', async () => {
|
||||
const controller = chip(ROSTER, undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
// The chip names the session about to start, and nothing about it is
|
||||
// decided yet — the default is the honest opening value.
|
||||
expect(controller.store.getSnapshot().current).toBe('standard')
|
||||
expect(controller.store.getSnapshot().options).toEqual([
|
||||
{ id: 'standard', trust: 'system' },
|
||||
{ id: 'minimal', trust: 'system' },
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the first preset when the roster marks none default', async () => {
|
||||
const controller = chip([{ id: 'minimal', trust: 'system', isDefault: false }], undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
// Settings can name a preset that was since deleted; the chip still has
|
||||
// to open on something rather than render nothing.
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('carries the display metadata into the menu rows', async () => {
|
||||
const controller = chip([
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
|
||||
] as never, undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().options).toEqual([
|
||||
{ id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' },
|
||||
])
|
||||
})
|
||||
|
||||
it('opens on nothing when the deployment composes no presets', async () => {
|
||||
const controller = chip([], undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
// An empty roster is a valid deployment: every session shares the host
|
||||
// composition, and the chip renders nothing rather than an empty control.
|
||||
expect(controller.store.getSnapshot().current).toBe('')
|
||||
})
|
||||
|
||||
it('stages a pick made before any session exists', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, undefined, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// Nothing to switch yet: the new-session screen precedes the session.
|
||||
expect(writes).toEqual([])
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('applies the stage to the blank session the flow lands on', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const current = { id: 's1', blank: true, agentPreset: 'standard' }
|
||||
const controller = chip(ROSTER, current, { writes })
|
||||
await controller.load()
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('spends the stage exactly once', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
await controller.select('minimal')
|
||||
|
||||
await controller.apply()
|
||||
await controller.apply()
|
||||
|
||||
// Every later list movement calls apply(); an unspent stage would keep
|
||||
// switching sessions the user never picked for.
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
|
||||
})
|
||||
|
||||
it('drops the stage against a session that already started', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: false, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// The host enforces the same rule; the chip simply never asks.
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the stage when the session already runs it', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'minimal' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to the default when the host refuses the switch', async () => {
|
||||
const controller = chip(
|
||||
ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { failSelect: 'already started' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// Showing `minimal` after a refusal would claim a composition the session
|
||||
// never got.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', error: 'already started' })
|
||||
})
|
||||
|
||||
it('falls back to the default when the switch never reaches the host', async () => {
|
||||
const controller = chip(
|
||||
ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { throwOn: 'select' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(controller.store.getSnapshot())
|
||||
.toMatchObject({ current: 'standard', busy: false, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('ignores a pick while a switch is in flight', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
const first = controller.select('minimal')
|
||||
await controller.select('standard')
|
||||
await first
|
||||
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
|
||||
})
|
||||
|
||||
it('keeps a staged pick across a roster refresh', async () => {
|
||||
const controller = chip(ROSTER, undefined)
|
||||
await controller.load()
|
||||
await controller.select('minimal')
|
||||
|
||||
await controller.load()
|
||||
|
||||
// A settings push re-reads the roster; it must not silently discard what
|
||||
// the user picked for the session they are about to start.
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('reports a refused roster read without emptying the chip', async () => {
|
||||
const controller = chip(ROSTER, undefined, { failList: 'host down' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
// The seat keeps whatever it last showed rather than claiming this session
|
||||
// has no preset; the message says why it could not refresh.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] })
|
||||
})
|
||||
|
||||
it('reports a transport that rejects the roster read', async () => {
|
||||
const controller = seat(ROSTER, { blank: true }, { throwOn: 'list' })
|
||||
const controller = chip(ROSTER, undefined, { throwOn: 'list' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().error).toBe('socket closed')
|
||||
})
|
||||
|
||||
it('restores the previous preset when the switch never reaches the host', async () => {
|
||||
const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { throwOn: 'select' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// Showing `minimal` after a failed switch would claim a composition the
|
||||
// session never got.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', busy: false, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('reports no options when the session is unknown to the list yet', async () => {
|
||||
const controller = seat([], undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().options).toEqual([])
|
||||
expect(controller.store.getSnapshot().switchable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user