fix(agent-presets): project selection and refresh client catalogs

This commit is contained in:
imccyu committed 2026-08-25 06:07:58 +08:00
1 parent 822d735356
commit b8dfa8b892
25 files changed
+333 -168

No files matched your search

+11 -2
View File
@@ -150,9 +150,18 @@ async function livePreset(baseUrl: string): Promise<string | undefined> {
}),
})
const body = await response.json() as {
result: { value?: { items: { sessionId: string; agentPreset?: string }[] } }
result: {
value?: {
items: {
sessionId: string
projections?: { values: { agentPreset?: string | null } }
}[]
}
}
}
return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset
const preset = body.result.value?.items.find(item => item.sessionId !== SEED_ID)
?.projections?.values.agentPreset
return typeof preset === 'string' ? preset : undefined
}
/** Every option label the trigger menu currently lists. */
@@ -53,6 +53,7 @@
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -66,6 +67,7 @@
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
@@ -14,6 +14,7 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-agent-presets/types'
import type { AgentPresetSettingsState } from './settings-store.ts'
import { presetDisplayText } from './locales.ts'
import css from './AgentPresetLabel.module.css'
@@ -42,7 +43,10 @@ export type AgentPresetLabelProps =
export function AgentPresetLabel({
sessionId, useSessions, useAgentPresets, load, t,
}: AgentPresetLabelProps) {
const preset = useSessions(state => state.byId[sessionId]?.agentPreset)
const preset = useSessions((state) => {
const value = state.byId[sessionId]?.projectionValues?.agentPreset
return typeof value === 'string' ? value : undefined
})
const options = useAgentPresets(state => state.options)
useEffect(() => {
@@ -34,7 +34,6 @@ import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
import { AgentPresetSection } from './AgentPresetSection.tsx'
import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
import { AgentPresetSeatController } from './seat-store.ts'
import type { SeatSessionSummary } from './seat-store.ts'
import { AgentPresetSectionController } from './section-store.ts'
import { en, zh } from './locales.ts'
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
@@ -43,7 +42,7 @@ export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPre
export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts'
export type { AgentPresetSeatState } from './seat-store.ts'
export {
draftBlocker, type AgentPresetSectionState, type CopyDraft, type PresetRow, type PresetView,
} from './section-store.ts'
@@ -106,18 +105,9 @@ export function apply(ctx: ClientContext): void {
// staged choice belongs to the flow rather than to any one session.
ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => {
const api = (scope.get('connection') as ConnectionHandle).api
const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => {
const seat = new AgentPresetSeatController(api, () => {
const state = scope.sessions.list.getSnapshot()
const summary = state.current === undefined ? undefined : state.byId[state.current]
return summary === undefined
? undefined
: {
id: summary.id,
blank: summary.blank,
...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset },
}
}, (sessionId, agentPreset) => {
scope.sessions.noteAgentPreset(sessionId as never, agentPreset)
return state.current === undefined ? undefined : state.byId[state.current]
})
const seatInjected = (): AgentPresetSeatInjected => ({
@@ -146,11 +136,6 @@ export function apply(ctx: ClientContext): void {
if (ns !== AGENT_PRESET_SETTINGS_NS) return
void seat.load()
})
// Every tab folds the committed preset into the shared session row; the
// initiating tab may already have applied the RPC echo, which is idempotent.
const presetSelected = scope.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
scope.sessions.noteAgentPreset(sessionId, agentPreset)
})
// 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
@@ -183,7 +168,6 @@ export function apply(ctx: ClientContext): void {
return () => {
stop()
settingsMoved()
presetSelected()
rosterReaders.delete(readRoster)
creatorDraft = undefined
chip()
@@ -11,8 +11,9 @@
*/
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-agent-presets/types'
import { messageOf, presetOptions } from './settings-store.ts'
import type { AgentPresetOption } from './settings-store.ts'
@@ -37,16 +38,6 @@ const INITIAL: AgentPresetSeatState = {
options: [], current: '', error: null, busy: false, introduce: false,
}
/** One session's identity and whether it has started. */
export interface SeatSessionSummary {
/** The session the chip would apply its staged choice to. */
id: SessionId
/** False once a turn has run — applying is refused from then on. */
blank: boolean
/** The preset the session already runs, when the summary reports one. */
agentPreset?: string
}
/** Stages the next session's preset and applies it when one appears. */
export class AgentPresetSeatController {
/** Chip snapshot the renderer subscribes to. */
@@ -64,13 +55,10 @@ export class AgentPresetSeatController {
constructor(
private readonly api: Pick<IApiClient, 'agentPresets'>,
/** The session the hero is about to hand over to, when there is one. */
private readonly currentSession: () => SeatSessionSummary | undefined,
/**
* Publish an applied switch into the session list, so the header label
* moves with the composition instead of waiting for the next full list
* refresh. Optional: a harness that renders no list omits it.
*/
private readonly onApplied?: (sessionId: string, agentPreset: string) => void,
private readonly currentSession: () => Pick<
SessionSummary,
'id' | 'blank' | 'projectionValues'
> | undefined,
) {}
private set(patch: Partial<AgentPresetSeatState>): void {
@@ -90,6 +78,7 @@ export class AgentPresetSeatController {
}
const { presets } = response.result.value
this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
const session = this.currentSession()
this.set({
options: presetOptions(presets),
// Staged pick first, then the composition the current session
@@ -98,7 +87,7 @@ export class AgentPresetSeatController {
// an applied stage was consumed — the chip mounts (and loads) only
// once the flow's session is current, so the reply can arrive after
// apply() already composed it.
current: this.staged ?? this.currentSession()?.agentPreset ?? this.fallback,
current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''),
error: null,
})
} catch (error) {
@@ -150,10 +139,15 @@ export class AgentPresetSeatController {
async apply(): Promise<void> {
const staged = this.staged
const session = this.currentSession()
if (staged === undefined || session === undefined) return
if (staged === undefined) {
const current = session === undefined ? this.fallback : presetOf(session) ?? ''
if (current !== this.store.getSnapshot().current) this.set({ current })
return
}
if (session === undefined) return
// A started session's history was produced under its own composition; the
// host refuses the swap, so the stage is no longer meaningful.
if (!session.blank || session.agentPreset === staged) {
if (!session.blank || presetOf(session) === staged) {
this.staged = undefined
return
}
@@ -162,15 +156,29 @@ export class AgentPresetSeatController {
const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged })
this.staged = undefined
if (!response.result.ok) {
this.set({ busy: false, error: response.result.error.message, current: this.fallback })
this.set({
busy: false,
error: response.result.error.message,
current: presetOf(session) ?? '',
})
return
}
// Consumed: the next new session opens on the deployment default again.
this.set({ busy: false, current: response.result.value.agentPreset })
this.onApplied?.(session.id, response.result.value.agentPreset)
} catch (error) {
this.staged = undefined
this.set({ busy: false, error: messageOf(error), current: this.fallback })
this.set({
busy: false,
error: messageOf(error),
current: presetOf(session) ?? '',
})
}
}
}
function presetOf(
session: Pick<SessionSummary, 'projectionValues'> | undefined,
): string | undefined {
const value = session?.projectionValues?.agentPreset
return typeof value === 'string' ? value : undefined
}
@@ -11,6 +11,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { SessionId } from '@deepseek-ai/dsh-session'
import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
@@ -21,6 +22,7 @@ import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSection.tsx'
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx'
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
// These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
// so browser-language detection never runs and a fresh LocaleRuntime opens on
@@ -155,7 +157,11 @@ function uiWorkspaceDouble() {
/** 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 }>
byId: Record<string, {
id: string
blank: boolean
projectionValues?: { agentPreset?: string | null }
}>
}) {
const listeners = new Set<() => void>()
return {
@@ -166,12 +172,6 @@ function sessionsDouble(state: {
return () => listeners.delete(fn)
},
},
noteAgentPreset: (sessionId: string, agentPreset: string) => {
const summary = state.byId[sessionId]
if (summary === undefined || summary.agentPreset === agentPreset) return
summary.agentPreset = agentPreset
for (const fn of listeners) fn()
},
/** Push a list change the way the runtime's store does. */
notify: () => { for (const fn of listeners) fn() },
}
@@ -354,24 +354,6 @@ describe('ui-agent-preset apply', () => {
conversation()
})
it('folds a remote preset commit into the shared session row', async () => {
const { ctx, slots, remote } = await bench()
declareRoot(slots)
declareConversation(slots)
ctx.provide('conversation', {} as never)
const state = {
current: 's1',
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
}
ctx.provide('sessions', sessionsDouble(state) as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
remote.emit('agent-preset/selected', ['s1', 'minimal'])
expect(state.byId.s1.agentPreset).toBe('minimal')
})
it('offers a just-authored preset on the new-session chip', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
@@ -409,7 +391,11 @@ describe('ui-agent-preset apply', () => {
ctx.provide('conversation', {} as never)
const state: {
current?: string
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
byId: Record<string, {
id: string
blank: boolean
projectionValues?: { agentPreset?: string | null }
}>
} = { byId: {} }
const sessions = sessionsDouble(state)
ctx.provide('sessions', sessions as never)
@@ -424,7 +410,9 @@ describe('ui-agent-preset apply', () => {
expect(calls).not.toContain('select:minimal')
state.current = 's1'
state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'standard' }
state.byId['s1'] = {
id: 's1', blank: true, projectionValues: { agentPreset: 'standard' },
}
sessions.notify()
// Connecting a workspace produced the session; the stage reaches it there.
@@ -461,7 +449,9 @@ describe('ui-agent-preset apply', () => {
ctx.provide('conversation', {} as never)
const state = {
current: 's1',
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
byId: {
s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } },
},
}
const sessions = sessionsDouble(state)
ctx.provide('sessions', sessions as never)
@@ -543,7 +533,11 @@ describe('ui-agent-preset apply', () => {
ctx.provide('conversation', {} as never)
const state: {
current?: string
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
byId: Record<string, {
id: string
blank: boolean
projectionValues?: { agentPreset?: string | null }
}>
} = { byId: {} }
const sessions = sessionsDouble(state)
ctx.provide('sessions', sessions as never)
@@ -562,7 +556,9 @@ describe('ui-agent-preset apply', () => {
// The chip mounts with the flow's session, so its roster load can land
// AFTER the stage was consumed; the session's own composition is what
// the display must keep — not the deployment default.
state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'cordis' }
state.byId['s1'] = {
id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' },
}
await seat.load()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
@@ -581,3 +577,48 @@ describe('ui-agent-preset apply', () => {
expect(section.startCreatorDraft).toBeUndefined()
})
})
describe('AgentPresetSeatController reconciliation', () => {
it('uses the deployment default without a Session and clears it for an uncomposed Session', async () => {
const state: { current?: { id: SessionId; blank: boolean } } = {}
const controller = new AgentPresetSeatController({
agentPresets: {
list: () => Promise.resolve(ROSTER_ONE),
},
} as never, () => state.current)
await controller.load()
await controller.apply()
expect(controller.store.getSnapshot().current).toBe('standard')
state.current = { id: SessionId('uncomposed'), blank: true }
await controller.apply()
expect(controller.store.getSnapshot().current).toBe('')
})
it.each([
{
name: 'RPC rejection',
select: () => Promise.resolve({
rpcId: 'r',
result: { ok: false as const, error: { code: 'failed', message: 'selection rejected', details: {} } },
}),
message: 'selection rejected',
},
{
name: 'transport failure',
select: () => Promise.reject(new Error('transport failed')),
message: 'transport failed',
},
])('restores an empty current value after $name for an uncomposed Session', async ({ select, message }) => {
const controller = new AgentPresetSeatController({
agentPresets: { select },
} as never, () => ({ id: SessionId('uncomposed'), blank: true }))
await controller.select('minimal')
expect(controller.store.getSnapshot()).toMatchObject({
busy: false, current: '', error: message,
})
})
})
@@ -71,7 +71,7 @@ function renderSeat(state: Partial<AgentPresetSeatState> = {}) {
}
function renderLabel(
summary: { blank: boolean; agentPreset?: string } | undefined,
summary: { blank: boolean; projectionValues?: { agentPreset?: string | null } } | undefined,
roster: Partial<AgentPresetSettingsState> = {},
) {
// The chip and the label read the same roster, metadata included.
@@ -367,7 +367,10 @@ describe('the chip introduce cue', () => {
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' })
const { load } = renderLabel({
blank: false,
projectionValues: { agentPreset: 'standard' },
})
await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) })
// A control here would promise a switch the host refuses outright.
@@ -376,13 +379,16 @@ describe('the session-header label', () => {
})
it('falls back to the id, and to the generic hint, when metadata is absent', () => {
renderLabel({ blank: true, agentPreset: 'mine' })
renderLabel({ blank: true, projectionValues: { 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: [] })
renderLabel({
blank: false,
projectionValues: { 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.
@@ -7,7 +7,9 @@
import { describe, expect, it } from 'vitest'
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
} from '../src/client/settings-store.ts'
@@ -17,7 +19,8 @@ function derivedController(api: IApiClient) {
return new AgentPresetSettingsController(api, new SettingsDescribeMirror(api))
}
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
import type { SeatSessionSummary } from '../src/client/seat-store.ts'
type SeatSession = Pick<SessionSummary, 'id' | 'blank' | 'projectionValues'>
interface Recorded { ns: string; patch: unknown }
@@ -245,7 +248,7 @@ 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,
current: SeatSession | undefined | (() => SeatSession | undefined),
options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {},
): AgentPresetSeatController {
const api = {
@@ -265,7 +268,10 @@ describe('the new-session chip controller', () => {
},
},
} as unknown as IApiClient
return new AgentPresetSeatController(api, () => current as SeatSessionSummary | undefined)
return new AgentPresetSeatController(
api,
typeof current === 'function' ? current : () => current,
)
}
const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [
@@ -331,9 +337,32 @@ describe('the new-session chip controller', () => {
expect(controller.store.getSnapshot().current).toBe('minimal')
})
it('replaces the default display when an existing blank session arrives after roster load', async () => {
const state: { current?: SeatSession } = {}
const controller = chip([
{ id: 'standard', trust: 'system', isDefault: false },
{ id: 'minimal', trust: 'system', isDefault: true },
], () => state.current)
await controller.load()
expect(controller.store.getSnapshot().current).toBe('minimal')
state.current = {
id: 's1' as SessionId,
blank: true,
projectionValues: { agentPreset: 'standard' },
}
await controller.apply()
expect(controller.store.getSnapshot().current).toBe('standard')
})
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 current = {
id: 's1' as SessionId,
blank: true,
projectionValues: { agentPreset: 'standard' },
}
const controller = chip(ROSTER, current, { writes })
await controller.load()
await controller.select('minimal')
@@ -344,7 +373,11 @@ describe('the new-session chip controller', () => {
it('spends the stage exactly once', async () => {
const writes: Recorded[] = []
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes })
const controller = chip(ROSTER, {
id: 's1' as SessionId,
blank: true,
projectionValues: { agentPreset: 'standard' },
}, { writes })
await controller.load()
await controller.select('minimal')
@@ -358,7 +391,11 @@ describe('the new-session chip controller', () => {
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 })
const controller = chip(ROSTER, {
id: 's1' as SessionId,
blank: false,
projectionValues: { agentPreset: 'standard' },
}, { writes })
await controller.load()
await controller.select('minimal')
@@ -369,7 +406,11 @@ describe('the new-session chip controller', () => {
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 })
const controller = chip(ROSTER, {
id: 's1' as SessionId,
blank: true,
projectionValues: { agentPreset: 'minimal' },
}, { writes })
await controller.load()
await controller.select('minimal')
@@ -379,7 +420,14 @@ describe('the new-session chip controller', () => {
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' })
ROSTER,
{
id: 's1' as SessionId,
blank: true,
projectionValues: { agentPreset: 'standard' },
},
{ failSelect: 'already started' },
)
await controller.load()
await controller.select('minimal')
@@ -391,7 +439,14 @@ describe('the new-session chip controller', () => {
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' })
ROSTER,
{
id: 's1' as SessionId,
blank: true,
projectionValues: { agentPreset: 'standard' },
},
{ throwOn: 'select' },
)
await controller.load()
await controller.select('minimal')
@@ -402,7 +457,11 @@ describe('the new-session chip controller', () => {
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 })
const controller = chip(ROSTER, {
id: 's1' as SessionId,
blank: true,
projectionValues: { agentPreset: 'standard' },
}, { writes })
await controller.load()
const first = controller.select('minimal')
@@ -50,6 +50,9 @@
{
"path": "../../api/session-controller/tsconfig.client.json"
},
{
"path": "../../preset/agent-presets"
},
{
"path": "../../core/session"
}
@@ -62,6 +62,18 @@ export class CommandDirectory {
for (const key of this.entries.keys()) void this.refresh(key)
}
/**
* Drop one Session's obsolete composition-specific snapshot and prewarm its replacement.
* @param sessionId - Session whose effective command composition changed.
*/
resetSession(sessionId: SessionId): void {
const entry = this.entry(sessionId)
entry.state = 'cold'
entry.commands = []
entry.lastError = undefined
void this.refresh(sessionId)
}
/**
* Hard reset on reconnect: every entry drops its snapshot (the agent world
* may have changed shape across the generation) and prewarms.
@@ -156,10 +156,9 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
}), 'command: slash source')
ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() })
// A preset switch changes which commands one session's agent resolves and
// registers nothing globally, so the registry-wide signal above never
// fires for it: repull that key alone, soft, so the old snapshot serves
// the menu until the new one lands.
ctx.remote.$on('agent-preset/selected', (sessionId) => { void this.directory.refresh(sessionId) })
// registers nothing globally. Drop that key's old composition before
// prewarming so a newly opened menu waits for the replacement catalog.
ctx.remote.$on('agent-preset/selected', (sessionId) => { this.directory.resetSession(sessionId) })
ctx.on('connection/reset', () => { this.directory.resetConnected() })
}
@@ -187,6 +187,30 @@ describe('resetConnected (reconnect hard)', () => {
})
})
describe('resetSession (preset-change hard)', () => {
it('drops and prewarms only the changed Session', async () => {
const { dir, pull, countOf } = bench()
const first = dir.refresh(S1)
const second = dir.refresh(S2)
pull(S1, 0).resolve(CMDS)
pull(S2, 0).resolve(S2_CMDS)
await Promise.all([first, second])
dir.resetSession(S1)
expect(dir.status(S1)).toBe('pending')
expect(dir.resolve(S1, 'plan')).toBeUndefined()
expect(dir.status(S2)).toBe('ready')
expect(dir.resolve(S2, 'attach')).toBeDefined()
expect(countOf(S1)).toBe(2)
expect(countOf(S2)).toBe(1)
pull(S1, 1).resolve([{ name: 'fresh', description: 'new composition' }])
await Promise.resolve()
await Promise.resolve()
expect(dir.resolve(S1, 'fresh')).toBeDefined()
})
})
describe('warm', () => {
it('launches a pull from cold, again after failure, and never over pending/ready', async () => {
const { dir, pull, countOf } = bench()
@@ -766,7 +766,7 @@ describe('directory invalidation events', () => {
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
})
it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => {
it('agent-preset/selected drops and repulls the recomposed session while leaving others served', async () => {
const rounds = new Map<SessionId, number>()
const { source, warm, remote } = await bench({
commands: (payload) => {
@@ -784,6 +784,8 @@ describe('directory invalidation events', () => {
// A preset switch changes which commands one session's agent resolves;
// every other session keeps the catalog its own composition serves.
remote.emit('agent-preset/selected', [sid('s1'), 'minimal'])
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined()
await new Promise(resolve => setTimeout(resolve, 0))
expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
@@ -364,7 +364,17 @@ export class InputTriggerController {
/** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
private watchLexicon(source: InputTriggerSource, projection: ClientSessionContext): void {
if (source.lexicon === undefined || source.subscribeLexicon === undefined) return
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() }))
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => {
this.refreshLexicon()
const hit = this.hit
if (hit === null || !this.menu.getSnapshot().open || hit.trigger !== source.trigger) return
// Let every source process the same invalidation before rebuilding the
// open menu, so one source cannot contribute its previous catalog.
void Promise.resolve().then(() => {
if (this.disposed || this.hit !== hit || !this.menu.getSnapshot().open) return
this.fetchCandidates(hit, this.deps.roster.sources(hit.trigger))
})
}))
}
/** Launch the candidate fetch for one hit generation, superseding the previous one. */
@@ -623,13 +623,13 @@ describe('lexicon', () => {
expect(rolls.has('@')).toBe(false)
})
it('a source lexicon notification republishes the aggregated store', () => {
let roll: readonly string[] | undefined = undefined
it('a source lexicon notification republishes the roll and refreshes an open menu', async () => {
let roll: readonly string[] | undefined = ['old']
let notify: (() => void) | undefined
const source: InputTriggerSource = {
trigger: '/',
name: 'skill',
candidates: () => Promise.resolve([]),
candidates: () => Promise.resolve((roll ?? []).map(name => ({ name }))),
onPick: () => undefined,
lexicon: () => roll,
subscribeLexicon: (_session, listener) => {
@@ -638,12 +638,18 @@ describe('lexicon', () => {
},
}
const { controller } = controllerBench([source])
expect(controller.lexicon.getSnapshot().size).toBe(0)
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['old'])
controller.track('/', 1, { tier: 'plain' }, 1)
await tick()
expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'old' }])
const seen: number[] = []
controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
roll = ['commit-helper']
notify?.()
await tick()
await tick()
expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
expect(controller.menu.getSnapshot().groups[0]?.items).toEqual([{ name: 'commit-helper' }])
expect(seen).toEqual([1])
controller.dispose()
expect(notify).toBeUndefined()
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
README.md: 3e92f02518c5a36412c9448a26d32958c217f79c
README.zh.md: 4600589749a063df924f9c961cc449506ba2af9f
README.md: 1c943f0d56a720fbb3225371ed8161bfeb07b677
README.zh.md: 6f348be04092eee87da74490dbcf73831e53215e
+1 -1
View File
@@ -40,7 +40,7 @@ The child records the joined id on its own durable header ([`dsh-subagent`](../.
### Which preset a session runs
The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header.
The creation header names the preset a session STARTED with; the `agentPreset` Session projection names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — consumes that projection rather than reading the header or folding the log independently.
The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. The service re-emits that committed fact as the non-scoped cordis event `agent-preset/selected(sessionId, agentPreset)` declared by the client-safe `./types` export, allowing remote consumers to invalidate session-derived state without importing Host runtime types. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent.
+1 -1
View File
@@ -40,7 +40,7 @@ subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装,
### 会话实际运行的是哪个 preset
创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。
创建头部记录的是会话**以什么开始**,`agentPreset` Session projection 记录的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都消费该 projection,而非直接读头部或各自重新归约日志。
头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。服务会把这项已提交事实重新发为不带 scope 的 cordis 事件 `agent-preset/selected(sessionId, agentPreset)`,其声明位于 client-safe 的 `./types` 出口,使远端消费方无需导入 Host 运行时类型即可让会话派生状态失效。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。
+4 -1
View File
@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
@@ -53,7 +54,8 @@
},
"dependencies": {
"js-yaml": "^4.1.0",
"@deepseek-ai/schemastery": "workspace:^"
"@deepseek-ai/schemastery": "workspace:^",
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
@@ -66,6 +68,7 @@
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-settings-file": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
+8 -2
View File
@@ -27,6 +27,7 @@ import z from '@deepseek-ai/schemastery'
import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
// Type-only: resolves the `agent/created` lifecycle event this service watches.
import type {} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-projection'
// Type-only: resolves the registry notification emitted after scope reparenting.
import type {} from '@deepseek-ai/dsh-tools'
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
@@ -36,7 +37,8 @@ import { copyComposition, deleteComposition, readComposition } from './authoring
import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
import { PresetExistsError } from './authoring.ts'
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts'
import type {} from './types.ts'
import { agentPresetProjectionDefinition } from './session.ts'
export type * from './types.ts'
/** Settings namespace carrying the user's chosen default preset. */
export const SETTINGS_NAMESPACE = 'agent-presets'
@@ -64,7 +66,7 @@ export {
copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError,
PresetNotWritableError, readComposition, writableRoot,
} from './authoring.ts'
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
export { agentPresetProjectionDefinition } from './session.ts'
export { PresetMountError, UnknownPresetError } from './preset.ts'
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts'
@@ -158,6 +160,10 @@ export class AgentPresets extends Service {
}, 'agentPresets.settings()')
})
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register(agentPresetProjectionDefinition)
})
// Advisory, not fatal: a synchronous `agent/created` listener that throws
// VETOES publication, and this service must not, because composing an agent
// outside the roster is legal — `recompose` binds exactly such a bare agent
+16 -26
View File
@@ -9,11 +9,13 @@
* it is required outright by the repo's model-visible ⟺ logged rule, since the
* preset decides the tool schemas and prompt sections the model sees.
*
* Reconstruction reads {@link resolveSessionPreset}, never the header alone.
* Reconstruction reads the `agentPreset` Session projection, never the header
* alone.
* @module @deepseek-ai/dsh-agent-presets/session
*/
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { z } from 'zod'
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
@@ -27,28 +29,16 @@ declare module '@deepseek-ai/dsh-session/types' {
}
}
/** The minimum a caller must supply to resolve a session's preset. */
export interface PresetBearingSession {
/** The session's creation header. */
readonly header: SessionHeader
/** The session's event log, oldest first. */
readonly events: readonly SessionEvent[]
}
const agentPresetSchema = z.union([z.string(), z.null()])
/**
* The preset a session actually runs, newest selection winning.
*
* The header supplies the creation-time value; every later selection is a
* logged event, so the last one is the answer. Reading the header alone
* rebuilds a switched session under the composition it was created with, not
* the one its history was produced under.
* @param session - the session's header and event log.
* @returns the preset id, or `undefined` when the deployment composes none.
*/
export function resolveSessionPreset(session: PresetBearingSession): string | undefined {
for (let index = session.events.length - 1; index >= 0; index -= 1) {
const event = session.events[index]
if (event?.type === 'agent-preset/selected') return event.data.agentPreset
}
return session.header.agentPreset
}
/** Current Session preset, initialized from its header and advanced by selection events. */
export const agentPresetProjectionDefinition = {
key: 'agentPreset',
stateSchema: agentPresetSchema,
init: header => header.agentPreset ?? null,
apply: (state, event) => event.type === 'agent-preset/selected'
? event.data.agentPreset
: state,
wire: { viewSchema: agentPresetSchema, view: state => state },
stateVersion: 1,
} satisfies ProjectionDefinition<'agentPreset', string | null>
@@ -1,6 +1,16 @@
/** Client-safe event declarations owned by the agent-preset domain. */
import type { SessionId } from '@deepseek-ai/dsh-session/types'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
agentPreset: string | null
}
interface SessionProjectionMap {
/** Preset the Session runs, or null when the deployment composes none. */
agentPreset: string | null
}
}
declare module '@deepseek-ai/cordis' {
interface Events {
/**
@@ -1,16 +1,9 @@
/**
* Which preset a session ran is a question about its LOG, not its header: the
* header records the creation-time choice, and a switch made during the blank
* window is an event. Every reconstruction — the list row, the header label,
* resume, fork — goes through this resolver, so a resolver that read the header
* alone would rebuild a switched session under a composition its own history
* contradicts.
*/
/** The Session projection that records which preset a Session runs. */
import { describe, expect, it } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { resolveSessionPreset } from '../src/session.ts'
import { agentPresetProjectionDefinition } from '../src/session.ts'
/** A header carrying the creation-time preset, if any. */
function header(agentPreset?: string): SessionHeader {
@@ -28,35 +21,24 @@ function selected(agentPreset: string, seq: number): SessionEvent {
return { type: 'agent-preset/selected', seq, time: seq, data: { agentPreset } }
}
describe('resolving which preset a session ran', () => {
it('reads the creation-time value when nothing was switched', () => {
expect(resolveSessionPreset({ header: header('standard'), events: [] })).toBe('standard')
describe('agent preset selection projection', () => {
it('starts from the creation header, including no configured preset', () => {
expect(agentPresetProjectionDefinition.init(header('standard'))).toBe('standard')
expect(agentPresetProjectionDefinition.init(header())).toBeNull()
})
it('prefers a logged switch over the header', () => {
// The switch's effect outlives the blank window it was made in: the turns
// that follow run under the newer composition.
expect(resolveSessionPreset({ header: header('standard'), events: [selected('minimal', 0)] }))
.toBe('minimal')
})
it('starts from the header and keeps the latest selected preset', () => {
const definition = agentPresetProjectionDefinition
let state = definition.init(header('standard'))
expect(state).toBe('standard')
it('takes the last switch when a session was moved twice', () => {
expect(resolveSessionPreset({
header: header('standard'),
events: [selected('minimal', 0), selected('cordis', 1)],
})).toBe('cordis')
})
state = definition.apply(state, selected('minimal', 0))
state = definition.apply(state, {
type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } },
})
state = definition.apply(state, selected('cordis', 2))
it('finds a switch behind later events', () => {
const later = { type: 'turn/end', seq: 2, time: 2, data: { turn: 1 } } as SessionEvent
expect(resolveSessionPreset({ header: header(), events: [selected('minimal', 0), later] }))
.toBe('minimal')
})
it('reports none when the deployment composes no presets', () => {
// A valid deployment: every session shares the host composition, and no
// surface should invent a preset name for it.
expect(resolveSessionPreset({ header: header(), events: [] })).toBeUndefined()
expect(definition.wire.view(state)).toBe('cordis')
expect(definition.stateSchema.parse(state)).toBe('cordis')
})
})
@@ -13,6 +13,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -43,6 +44,7 @@ async function harness(
ctx.loader.builtins.include = Include
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
@@ -27,6 +27,9 @@
{
"path": "../../core/session"
},
{
"path": "../../session/session-projection"
},
{
"path": "../../core/system-prompt"
},