1
0
Эх сурвалжийг харах

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

imccyu 1 сар өмнө
parent
commit
b8dfa8b892
25 өөрчлөгдсөн 333 нэмэгдсэн , 168 устгасан
  1. 11 2
      apps/web/tests/agent-preset-selection.e2e.ts
  2. 2 0
      packages/client/ui-agent-preset/package.json
  3. 5 1
      packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx
  4. 3 19
      packages/client/ui-agent-preset/src/client/index.ts
  5. 32 24
      packages/client/ui-agent-preset/src/client/seat-store.ts
  6. 71 30
      packages/client/ui-agent-preset/tests/apply.client.spec.ts
  7. 10 4
      packages/client/ui-agent-preset/tests/components.client.spec.tsx
  8. 69 10
      packages/client/ui-agent-preset/tests/settings-store.client.spec.ts
  9. 3 0
      packages/client/ui-agent-preset/tsconfig.json
  10. 12 0
      packages/client/ui-commands/src/client/directory.ts
  11. 3 4
      packages/client/ui-commands/src/client/service.ts
  12. 24 0
      packages/client/ui-commands/tests/directory.client.spec.ts
  13. 3 1
      packages/client/ui-commands/tests/service.client.spec.ts
  14. 11 1
      packages/client/ui-input-trigger/src/client/controller.ts
  15. 10 4
      packages/client/ui-input-trigger/tests/service.client.spec.ts
  16. 2 2
      packages/preset/agent-presets/README.i18n.yaml
  17. 1 1
      packages/preset/agent-presets/README.md
  18. 1 1
      packages/preset/agent-presets/README.zh.md
  19. 4 1
      packages/preset/agent-presets/package.json
  20. 8 2
      packages/preset/agent-presets/src/index.ts
  21. 16 26
      packages/preset/agent-presets/src/session.ts
  22. 10 0
      packages/preset/agent-presets/src/types.ts
  23. 17 35
      packages/preset/agent-presets/tests/session.spec.ts
  24. 2 0
      packages/preset/agent-presets/tests/settings.spec.ts
  25. 3 0
      packages/preset/agent-presets/tsconfig.json

+ 11 - 2
apps/web/tests/agent-preset-selection.e2e.ts

@@ -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. */

+ 2 - 0
packages/client/ui-agent-preset/package.json

@@ -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:^",

+ 5 - 1
packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx

@@ -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(() => {

+ 3 - 19
packages/client/ui-agent-preset/src/client/index.ts

@@ -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()

+ 32 - 24
packages/client/ui-agent-preset/src/client/seat-store.ts

@@ -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
+}

+ 71 - 30
packages/client/ui-agent-preset/tests/apply.client.spec.ts

@@ -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,
+    })
+  })
+})

+ 10 - 4
packages/client/ui-agent-preset/tests/components.client.spec.tsx

@@ -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.

+ 69 - 10
packages/client/ui-agent-preset/tests/settings-store.client.spec.ts

@@ -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')

+ 3 - 0
packages/client/ui-agent-preset/tsconfig.json

@@ -50,6 +50,9 @@
     {
       "path": "../../api/session-controller/tsconfig.client.json"
     },
+    {
+      "path": "../../preset/agent-presets"
+    },
     {
       "path": "../../core/session"
     }

+ 12 - 0
packages/client/ui-commands/src/client/directory.ts

@@ -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.

+ 3 - 4
packages/client/ui-commands/src/client/service.ts

@@ -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() })
   }
 

+ 24 - 0
packages/client/ui-commands/tests/directory.client.spec.ts

@@ -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()

+ 3 - 1
packages/client/ui-commands/tests/service.client.spec.ts

@@ -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()

+ 11 - 1
packages/client/ui-input-trigger/src/client/controller.ts

@@ -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. */

+ 10 - 4
packages/client/ui-input-trigger/tests/service.client.spec.ts

@@ -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 - 2
packages/preset/agent-presets/README.i18n.yaml

@@ -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
packages/preset/agent-presets/README.md

@@ -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
packages/preset/agent-presets/README.zh.md

@@ -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
packages/preset/agent-presets/package.json

@@ -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
packages/preset/agent-presets/src/index.ts

@@ -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
packages/preset/agent-presets/src/session.ts

@@ -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>

+ 10 - 0
packages/preset/agent-presets/src/types.ts

@@ -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 {
     /**

+ 17 - 35
packages/preset/agent-presets/tests/session.spec.ts

@@ -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('takes the last switch when a session was moved twice', () => {
-    expect(resolveSessionPreset({
-      header: header('standard'),
-      events: [selected('minimal', 0), selected('cordis', 1)],
-    })).toBe('cordis')
-  })
+  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('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')
-  })
+    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('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')
   })
 })

+ 2 - 0
packages/preset/agent-presets/tests/settings.spec.ts

@@ -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)

+ 3 - 0
packages/preset/agent-presets/tsconfig.json

@@ -27,6 +27,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../session/session-projection"
+    },
     {
       "path": "../../core/system-prompt"
     },