Просмотр исходного кода

feat(web): pick a session's agent preset from the composer

A seat in the composer tool row, left of the model select, showing the preset
THIS session runs — its own recorded one, not the deployment default, because a
resumed session runs what it was created with. `SessionSummary` carries
`agentPreset` for that, alongside `cwd` and `origin`.

The switch exists only while the conversation has not started. After the first
turn the seat becomes a plain label rather than a disabled menu: a greyed
control reads as "temporarily unavailable", when in fact the choice is gone for
good. The seat never asks in that state, and the host refuses independently
with `agent-preset-locked`, so a stale client cannot slip one past it.

A rejected switch restores the previous value and surfaces the host's message
rather than leaving the seat showing something the session is not running.
Yichen Jiang 2 месяцев назад
Родитель
Сommit
a2ab09003f

+ 2 - 0
packages/client/connection/tests/fake-api.ts

@@ -169,6 +169,8 @@ export class FakeApiClient implements IApiClient {
 
   readonly agentPresets: IApiClient['agentPresets'] = {
     list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))),
+    select: (payload: { agentPreset: string }) =>
+      this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
   }
 
   readonly skills: IApiClient['skills'] = {

+ 2 - 0
packages/client/runtime/src/client/sessions/lineage.ts

@@ -25,6 +25,8 @@ export interface SessionListEntry {
   /** Coarse durable origin for navigation filtering; not a continuation capability. */
   origin?: 'subagent'
   cwd?: string
+  /** Agent preset the session's agent was composed from (summary passthrough). */
+  agentPreset?: string
   /** Current host-computed projection values for list consumers. */
   projectionValues?: Readonly<Partial<SessionProjectionMap>>
   /** User interaction currently blocking this session, derived from live mux frames. */

+ 7 - 0
packages/client/runtime/src/client/sessions/service.ts

@@ -45,6 +45,12 @@ export interface SessionSummary {
   /** Human-facing label: durable title, project basename, then session id. */
   displayTitle: string
   cwd?: string
+  /**
+   * Agent preset this session's agent was composed from; absent when the
+   * deployment composes no presets. A composer seat shows what the session
+   * actually runs rather than the deployment's current default.
+   */
+  agentPreset?: string
   parentId?: SessionId
   /** Coarse durable origin for navigation filtering; not a continuation capability. */
   origin?: 'subagent'
@@ -629,6 +635,7 @@ export class SessionsService implements ISessions {
         ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
         ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
         ...(entry.origin !== undefined ? { origin: entry.origin } : {}),
+        ...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}),
       }
     }
     if (current !== undefined && currentAddress !== undefined) {

+ 2 - 0
packages/client/runtime/tests/fake-api.ts

@@ -204,6 +204,8 @@ export class FakeApiClient implements IApiClient {
 
   readonly agentPresets: IApiClient['agentPresets'] = {
     list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))),
+    select: (payload: { agentPreset: string }) =>
+      this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
   }
 
   readonly skills: IApiClient['skills'] = {

+ 2 - 2
packages/client/ui-agent-preset/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/client/ui-agent-preset/README.md
-README.md: c22d5a40659bf6300f133ff902fbc3bf8bc75276
-README.zh.md: 6b7e43adcdfe7d6de781511edb5d91a4c45cf11e
+README.md: 322fc7c8ba6eb621f27cb09475079e3d5bccf03f
+README.zh.md: 6eece3248d7f3c3652a30579f907eaf5f888f35f

+ 6 - 1
packages/client/ui-agent-preset/README.md

@@ -8,6 +8,12 @@ The agent-preset surface: one General-settings row choosing which [preset](../..
 
 A session's preset is fixed when the session is created — the host refuses to adopt an existing session under a different one, because that session's history was produced under the first preset's tools. So this row cannot be a live switch, and it says so: changing it applies to sessions started afterwards while running sessions keep the composition they began with.
 
+## The composer seat
+
+A second surface, in the composer tool row left of the model select: the preset THIS session runs. It shows the session's own recorded preset rather than the deployment default, because a resumed session runs what it was created with.
+
+The switch exists only while the conversation has not started. After the first turn the seat becomes a plain label — offering a disabled menu would suggest the choice is merely unavailable rather than gone. The host enforces the same rule and answers `agent-preset-locked`, so a stale client cannot slip a switch past it.
+
 ## What it reads and writes
 
 Options and the current default both come from one `agentPreset.list` call. The roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection; the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation.
@@ -30,6 +36,5 @@ No direct invalidation. Changing the default never touches a running session's p
 
 ## Known Limitations and Deferred Work
 
-- **No per-session choice at creation** — this row sets the default only. The wire already carries `agentPreset` on `session.create`, so a session-start surface can offer the choice; that surface does not exist yet.
 - **Presets are listed by id** — a preset carries no display metadata, so the menu shows directory names.
 - **No authoring** — creating, editing, or deleting a preset is a filesystem act; this surface only chooses among what the roster supplies.

+ 6 - 1
packages/client/ui-agent-preset/README.zh.md

@@ -8,6 +8,12 @@ agent preset 表层:General 设置中的一行,用于选择新建会话据
 
 会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。
 
+## composer 座位
+
+第二个表层,位于 composer 工具行、模型选择器左侧:**本会话**所运行的 preset。它显示会话自身记录的 preset 而非部署默认值,因为被恢复的会话运行的是它创建时的那一份。
+
+切换只在对话尚未开始时存在。第一个轮次之后,该座位变为纯文本标签——展示一个禁用的菜单会让人以为这个选择只是暂时不可用,而非已经消失。宿主执行同一条规则并返回 `agent-preset-locked`,因此过期的客户端无法绕过它。
+
 ## 它读什么、写什么
 
 选项与当前默认值都来自同一次 `agentPreset.list` 调用。名单本身已经报告了"未显式选择的会话会得到哪个 id",因此本行无需对 settings schema 做内省;写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的那个字段。
@@ -30,6 +36,5 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre
 
 ## Known Limitations and Deferred Work
 
-- **创建时无法逐会话选择** —— 本行只设置默认值。wire 上 `session.create` 已经携带 `agentPreset`,因此会话开启表层可以提供该选择;该表层尚不存在。
 - **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单显示的是目录名。
 - **不提供创作能力** —— 创建、编辑或删除 preset 是文件系统行为;本表层只在名单提供的范围内做选择。

+ 4 - 1
packages/client/ui-agent-preset/package.json

@@ -26,7 +26,8 @@
     "inject": [
       "@deepseek-ai/dsh-client-connection",
       "@deepseek-ai/dsh-client-locale",
-      "@deepseek-ai/dsh-client-runtime"
+      "@deepseek-ai/dsh-client-runtime",
+      "@deepseek-ai/dsh-client-ui-conversation"
     ],
     "platform": "web"
   },
@@ -39,6 +40,7 @@
     "@deepseek-ai/dsh-client-connection": "^0.0.1",
     "@deepseek-ai/dsh-client-locale": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
@@ -49,6 +51,7 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",

+ 34 - 0
packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css

@@ -0,0 +1,34 @@
+/* Agent-preset seat: a compact selector pill in the composer tool row. */
+
+.seat {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  height: 28px;
+  padding: 0 6px 0 8px;
+  border: none;
+  border-radius: 8px;
+  background: transparent;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-secondary);
+  cursor: pointer;
+}
+
+.seat:hover:not(:disabled) {
+  background: var(--dsw-alias-fill-tsp-secondary);
+}
+
+.seat:disabled {
+  cursor: default;
+  color: var(--dsw-alias-label-quaternary);
+}
+
+.chevron {
+  flex: none;
+  opacity: 0.6;
+}
+
+.locked {
+  cursor: default;
+}

+ 95 - 0
packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx

@@ -0,0 +1,95 @@
+/**
+ * Composer seat for the session's agent preset.
+ *
+ * The switch exists only while the conversation has not started: after the
+ * first turn the session's history was produced under this preset's tools, so
+ * the seat becomes a plain label rather than offering a choice it cannot honor.
+ */
+
+import { useEffect, useState } from 'react'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
+// Type-only: pulls the ui-conversation SlotMap merge (the agentPreset seat).
+import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { AgentPresetSeatState } from './seat-store.ts'
+import css from './AgentPresetSeat.module.css'
+
+/** Registration-side business face for the composer seat. */
+export interface AgentPresetSeatInjected {
+  hooks: {
+    /** Seat snapshot bound by the renderer as useAgentPresetSeat. */
+    agentPresetSeat: SnapshotStore<AgentPresetSeatState>
+  }
+  /** Load the roster and this session's state when the seat first renders. */
+  load: () => Promise<void>
+  /** Switch this session to another preset. */
+  select: (id: string) => Promise<void>
+}
+
+/** Full component props. */
+export type AgentPresetSeatProps =
+  PropsRuntime<'conversation.input.agentPreset'>
+  & PropsLocale<'settings.agentPreset'>
+  & InjectFace<AgentPresetSeatInjected>
+
+/**
+ * Render the session's agent-preset seat.
+ * @param props - composed slot props; `locked` is the composer's own busy state.
+ * @returns the seat, or null when the deployment composes no presets.
+ */
+export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }: AgentPresetSeatProps) {
+  const state = useAgentPresetSeat(snapshot => snapshot)
+  const [open, setOpen] = useState(false)
+
+  useEffect(() => {
+    void load()
+  }, [load])
+
+  useEffect(() => {
+    if (state.switchable) return
+    setOpen(false)
+  }, [state.switchable])
+
+  // Nothing to choose between: the deployment composes no presets and every
+  // session shares the host composition.
+  if (state.options.length === 0 || state.current === '') return null
+
+  // Past the first turn the preset is a fact about this session, not a
+  // control — showing a disabled menu would suggest it could still be changed.
+  if (!state.switchable) {
+    return <span className={`${css.seat} ${css.locked}`} title={t('lockedHint')}>{state.current}</span>
+  }
+
+  return (
+    <Menu
+      open={open}
+      onClose={() => { setOpen(false) }}
+      items={state.options.map(option => ({
+        id: option.id,
+        label: option.trust === 'user' ? `${option.id} · ${t('userTrust')}` : option.id,
+      }))}
+      selectedId={state.current}
+      onSelect={(id) => {
+        setOpen(false)
+        void select(id)
+      }}
+      align="end"
+      portal
+      anchor={(
+        <button
+          type="button"
+          className={css.seat}
+          aria-haspopup="menu"
+          aria-expanded={open}
+          title={state.error ?? t('seatHint')}
+          disabled={locked || state.busy}
+          onClick={() => { setOpen(value => !value) }}
+        >
+          {state.current}
+          <IconChevronDownOutline14 className={css.chevron} />
+        </button>
+      )}
+    />
+  )
+}

+ 37 - 1
packages/client/ui-agent-preset/src/client/index.ts

@@ -12,13 +12,18 @@
 import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
-import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import { AgentPresetRow } from './AgentPresetRow.tsx'
 import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
+import { AgentPresetSeat } from './AgentPresetSeat.tsx'
+import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
+import { AgentPresetSeatController } from './seat-store.ts'
 import { en, zh } from './locales.ts'
 import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
 
 export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
+export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
+export type { AgentPresetSeatState } from './seat-store.ts'
 export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
 export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts'
 
@@ -54,6 +59,37 @@ export function apply(ctx: ClientContext): void {
     return () => { for (const dispose of disposers) dispose() }
   }, 'ui-agent-preset: settings refresh')
 
+  // The composer seat: one controller per session, because the switch and the
+  // "may it still switch" bit are both per-session facts.
+  ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => {
+    const api = (scope.get('connection') as ConnectionHandle).api
+    const seats = new Map<SessionId, AgentPresetSeatController>()
+    const seatFor = (sessionId: SessionId): AgentPresetSeatController => {
+      const existing = seats.get(sessionId)
+      if (existing !== undefined) return existing
+      const created = new AgentPresetSeatController(api, sessionId, () => {
+        const summary = scope.sessions.list.getSnapshot().byId[sessionId]
+        return summary === undefined
+          ? undefined
+          : { blank: summary.blank, ...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset } }
+      })
+      seats.set(sessionId, created)
+      return created
+    }
+    scope.effect(() => scope.slots.register({
+      name: 'conversation.input.agentPreset',
+      locale: 'settings.agentPreset',
+      inject: (sessionId: SessionId): AgentPresetSeatInjected => {
+        const seat = seatFor(sessionId)
+        return {
+          hooks: { agentPresetSeat: seat.store },
+          load: () => seat.load(),
+          select: (id: string) => seat.select(id),
+        }
+      },
+    }, AgentPresetSeat), 'ui-agent-preset: composer seat registration')
+  })
+
   ctx.slots.inject('settings.general.item', () => ctx.slots.register({
     name: 'settings.general.item',
     id: 'agent-preset',

+ 5 - 1
packages/client/ui-agent-preset/src/client/locales.ts

@@ -2,7 +2,7 @@
 
 /** Locale keys this row renders. */
 export type AgentPresetSettingsKey =
-  | 'title' | 'description' | 'loading' | 'error' | 'userTrust'
+  | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint'
 
 /** English copy. */
 export const en: Record<AgentPresetSettingsKey, string> = {
@@ -11,6 +11,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
   loading: 'Loading presets…',
   error: 'Could not load agent presets.',
   userTrust: 'Local',
+  seatHint: 'Agent preset for this session — switchable until you send the first message',
+  lockedHint: 'This session\'s agent preset is fixed once the conversation starts',
 }
 
 /** Simplified Chinese copy. */
@@ -20,4 +22,6 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
   loading: '正在加载 preset…',
   error: '无法加载 agent preset。',
   userTrust: '本地',
+  seatHint: '本会话的 agent preset —— 发送第一条消息前可切换',
+  lockedHint: '会话开始后,其 agent preset 即固定',
 }

+ 100 - 0
packages/client/ui-agent-preset/src/client/seat-store.ts

@@ -0,0 +1,100 @@
+/**
+ * Composer-seat controller: what one session may switch to, and whether it
+ * still may.
+ *
+ * A session's composition is fixed once its conversation starts, so the seat
+ * reads the session's own `blank` bit rather than a local guess — the host
+ * enforces the same rule and answers `agent-preset-locked` to a late attempt.
+ */
+
+import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
+import {
+  createSnapshotStore, type SessionId, type SnapshotStore,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import type { AgentPresetOption } from './settings-store.ts'
+
+/** Composer-seat snapshot for one session. */
+export interface AgentPresetSeatState {
+  /** Presets the deployment supplies; empty means the seat renders nothing. */
+  options: readonly AgentPresetOption[]
+  /** The preset this session runs, empty until the roster and summary load. */
+  current: string
+  /** False once the conversation has started — the switch is gone for good. */
+  switchable: boolean
+  /** A rejected switch's message, cleared by the next attempt. */
+  error: string | null
+  busy: boolean
+}
+
+const INITIAL: AgentPresetSeatState = {
+  options: [], current: '', switchable: false, error: null, busy: false,
+}
+
+/** Reads what one session may switch to and performs the switch. */
+export class AgentPresetSeatController {
+  /** Seat snapshot the renderer subscribes to. */
+  readonly store: SnapshotStore<AgentPresetSeatState> = createSnapshotStore(INITIAL)
+
+  constructor(
+    private readonly api: IApiClient,
+    private readonly sessionId: SessionId,
+    /** Reads this session's blank bit and recorded preset from the session list. */
+    private readonly summary: () => { blank: boolean; agentPreset?: string } | undefined,
+  ) {}
+
+  private set(patch: Partial<AgentPresetSeatState>): void {
+    this.store.set({ ...this.store.getSnapshot(), ...patch })
+  }
+
+  /**
+   * Load the roster and reconcile with this session's own state.
+   * @returns once the snapshot reflects the host.
+   */
+  async load(): Promise<void> {
+    const summary = this.summary()
+    try {
+      const response = await this.api.agentPresets.list({})
+      if (!response.result.ok) {
+        this.set({ error: response.result.error.message })
+        return
+      }
+      const presets = response.result.value.presets
+      this.set({
+        options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
+        // The session's recorded preset wins over the roster default: a
+        // resumed session runs what it was created with, not what the
+        // deployment now prefers.
+        current: summary?.agentPreset ?? presets.find(preset => preset.isDefault)?.id ?? '',
+        switchable: summary?.blank ?? false,
+        error: null,
+      })
+    } catch (error) {
+      this.set({ error: error instanceof Error ? error.message : String(error) })
+    }
+  }
+
+  /**
+   * Switch this session to another preset.
+   * @param id - the preset to compose the session's agent from.
+   * @returns once the switch settled; a rejection leaves the previous value.
+   */
+  async select(id: string): Promise<void> {
+    const before = this.store.getSnapshot()
+    if (before.busy || id === before.current || !before.switchable) return
+    this.set({ busy: true, error: null, current: id })
+    try {
+      const response = await this.api.agentPresets.select({ sessionId: this.sessionId, agentPreset: id })
+      if (!response.result.ok) {
+        this.set({ busy: false, current: before.current, error: response.result.error.message })
+        return
+      }
+      this.set({ busy: false, current: response.result.value.agentPreset })
+    } catch (error) {
+      this.set({
+        busy: false,
+        current: before.current,
+        error: error instanceof Error ? error.message : String(error),
+      })
+    }
+  }
+}

+ 98 - 0
packages/client/ui-agent-preset/tests/settings-store.spec.ts

@@ -10,6 +10,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
 import {
   AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController,
 } from '../src/client/settings-store.ts'
+import { AgentPresetSeatController } from '../src/client/seat-store.ts'
 
 interface Recorded { ns: string; patch: unknown }
 
@@ -120,3 +121,100 @@ describe('the agent-preset settings controller', () => {
     expect(state.error).toBe('host down')
   })
 })
+
+describe('the composer seat controller', () => {
+  /** A seat over a fixed session summary. */
+  function seat(
+    presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
+    summary: { blank: boolean; agentPreset?: string } | undefined,
+    options: { writes?: Recorded[]; failSelect?: string } = {},
+  ): AgentPresetSeatController {
+    const api = {
+      agentPresets: {
+        list: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { presets } } }),
+        select: (payload: { agentPreset: string }) => {
+          options.writes?.push({ ns: 'select', patch: payload.agentPreset })
+          return Promise.resolve(options.failSelect === undefined
+            ? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }
+            : { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } })
+        },
+      },
+    } as unknown as IApiClient
+    return new AgentPresetSeatController(api, 's1' as never, () => summary)
+  }
+
+  const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [
+    { id: 'standard', trust: 'system', isDefault: true },
+    { id: 'core-web', trust: 'system', isDefault: false },
+  ]
+
+  it('shows what the session runs, not the deployment default', async () => {
+    const controller = seat(ROSTER, { blank: true, agentPreset: 'core-web' })
+
+    await controller.load()
+
+    // A resumed session runs what it was created with; showing `standard`
+    // because it is the current default would be a lie about this session.
+    expect(controller.store.getSnapshot().current).toBe('core-web')
+    expect(controller.store.getSnapshot().switchable).toBe(true)
+  })
+
+  it('falls back to the roster default when the session records none', async () => {
+    const controller = seat(ROSTER, { blank: true })
+
+    await controller.load()
+
+    expect(controller.store.getSnapshot().current).toBe('standard')
+  })
+
+  it('is not switchable once the conversation has started', async () => {
+    const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' })
+
+    await controller.load()
+
+    expect(controller.store.getSnapshot().switchable).toBe(false)
+  })
+
+  it('refuses to switch a session that already started', async () => {
+    const writes: Recorded[] = []
+    const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' }, { writes })
+    await controller.load()
+
+    await controller.select('core-web')
+
+    // The host enforces the same rule; the seat simply never asks.
+    expect(writes).toEqual([])
+    expect(controller.store.getSnapshot().current).toBe('standard')
+  })
+
+  it('switches a blank session and keeps the host\'s answer', async () => {
+    const writes: Recorded[] = []
+    const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { writes })
+    await controller.load()
+
+    await controller.select('core-web')
+
+    expect(writes).toEqual([{ ns: 'select', patch: 'core-web' }])
+    expect(controller.store.getSnapshot().current).toBe('core-web')
+  })
+
+  it('restores the previous value when the host rejects the switch', async () => {
+    const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { failSelect: 'already started' })
+    await controller.load()
+
+    await controller.select('core-web')
+
+    const state = controller.store.getSnapshot()
+    expect(state.current).toBe('standard')
+    expect(state.error).toBe('already started')
+  })
+
+  it('reports no options when the session is unknown to the list yet', async () => {
+    const controller = seat([], undefined)
+
+    await controller.load()
+
+    expect(controller.store.getSnapshot().options).toEqual([])
+    expect(controller.store.getSnapshot().switchable).toBe(false)
+  })
+})

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

@@ -20,6 +20,9 @@
     {
       "path": "../runtime"
     },
+    {
+      "path": "../ui-conversation"
+    },
     {
       "path": "../ui-primitives"
     },

+ 1 - 0
packages/client/ui-conversation/src/client/apply.ts

@@ -228,6 +228,7 @@ export function apply(ctx: Context): void {
     children: {
       'conversation.input.plan': { kind: 'single', scope: 'session' },
       'conversation.input.model': { kind: 'single', scope: 'session' },
+      'conversation.input.agentPreset': { kind: 'single', scope: 'session' },
     },
     inject: (sessionId: SessionId | undefined): ComposerBarInjected => {
       if (sessionId === undefined) {

+ 7 - 1
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -102,6 +102,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      * empty-until-registered contract as the plan seat.
      */
     'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
+    /**
+     * The agent-preset seat in the composer tool row, left of the model.
+     * Same empty-until-registered contract as the other two; its owner
+     * decides on its own whether the session may still switch.
+     */
+    'conversation.input.agentPreset': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
   }
 
   /**
@@ -325,7 +331,7 @@ export interface InputControlOwnerProps {
 /** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */
 export type ComposerBarProps =
   PropsRuntime<'conversation.composer.bar'>
-  & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
+  & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model' | 'conversation.input.agentPreset'>
   & InjectFace<ComposerBarInjected>
   & PropsLocale<'conversation'>
 

+ 1 - 0
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -512,6 +512,7 @@ export function InputBar({
           </div>
           <div className={css.trailing}>
             {rightItems}
+            {renderSlot('conversation.input.agentPreset', { locked })}
             {renderSlot('conversation.input.model', { locked })}
             <ContextMeter useProjection={useProjection} t={t} />
             {/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}

+ 5 - 3
packages/client/ui-conversation/tests/input-bar.spec.tsx

@@ -686,13 +686,15 @@ describe('strips and variants', () => {
 })
 
 describe('command launcher chrome and control seats', () => {
-  it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
+  it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries (B ruling)', () => {
     const { view, slotCalls } = bench()
     expect(view.getByLabelText('命令')).toBeTruthy()
     // Capability absent (no projection value): the chip renders nothing.
     expect(view.queryByLabelText(/^访问模式/)).toBeNull()
-    // Both seats dispatched, nothing rendered.
-    expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
+    // Every seat dispatched, nothing rendered.
+    expect(slotCalls.map(c => c.key)).toEqual([
+      'conversation.input.plan', 'conversation.input.agentPreset', 'conversation.input.model',
+    ])
     expect(view.queryByLabelText('Plan mode')).toBeNull()
     expect(view.queryByLabelText('Model')).toBeNull()
   })

+ 2 - 0
packages/host/apiproxy/src/api-proxy.ts

@@ -260,11 +260,13 @@ function sessionListFields(header: SessionHeader): {
   parentSessionId?: SessionId
   origin?: 'subagent'
   cwd?: string
+  agentPreset?: string
 } {
   return {
     ...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession },
     ...header.origin === undefined ? {} : { origin: header.origin },
     ...header.cwd === undefined ? {} : { cwd: header.cwd },
+    ...header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset },
   }
 }
 

+ 1 - 0
packages/host/apiproxy/src/api/sessions.schema.ts

@@ -55,6 +55,7 @@ export const sessionSummarySchema = z.object({
   parentSessionId: sessionIdSchema.optional(),
   origin: z.literal('subagent').optional(),
   cwd: z.string().optional(),
+  agentPreset: z.string().optional(),
   projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
 }) as unknown as z.ZodType<Wire<SessionSummary>>
 

+ 7 - 0
packages/host/apiproxy/src/api/sessions.ts

@@ -156,6 +156,13 @@ export interface SessionSummary {
   origin?: 'subagent'
   /** Session working directory (header.cwd passthrough); absent when unrecorded. */
   cwd?: string
+  /**
+   * Agent preset this session's agent was composed from (header passthrough);
+   * absent when the deployment composes no presets. A surface offering a
+   * switch reads this to show what the session actually runs rather than what
+   * the deployment currently defaults to.
+   */
+  agentPreset?: string
   /**
    * Projection baseline for this row, with zero log loads: attached sessions
    * read the registry's live watermark cut; cold sessions read the persisted

+ 3 - 0
pnpm-lock.yaml

@@ -1446,6 +1446,9 @@ importers:
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
+      '@deepseek-ai/dsh-client-ui-conversation':
+        specifier: workspace:^
+        version: link:../ui-conversation
       '@deepseek-ai/dsh-client-ui-primitives':
         specifier: workspace:^
         version: link:../ui-primitives