feat(web): rewrite subagent conversations for FIFO activation
This commit is contained in:
@@ -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-model/README.md
|
||||
README.md: 267717c78434f7a73b1c1eebca0cc0f9d65c3642
|
||||
README.zh.md: 6d6f433315336812a51b5110ceeac3eecbd9bbd4
|
||||
README.md: 27fb7b936b796b956f7348fa776856180350bb56
|
||||
README.zh.md: 9cc6b04ef2e7ba24fb8fc3f6d5456bf88f0652fe
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). The `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope.
|
||||
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the `session.selectModel` RPC both entries submit: the Host snapshots the selected provider/model/reasoning target at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target. The selection becomes durable only when the existing request header records a request that consumes it; menu interaction adds no prompt content.
|
||||
Indirectly, through the `session.selectModel` RPC available to ordinary sessions, both entries submit the provider/model/reasoning target that the Host snapshots at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target; the selection becomes durable only when the existing request header records a request that consumes it, and menu interaction adds no prompt content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -16,6 +16,6 @@ Switching the route can reduce or invalidate provider-side cache reuse for subse
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No create-time selection** — both entries address an existing session's agent; there is no draft-phase model choice to fold into session creation (the seed order at the host's `targetFor` documents where such a tier would go).
|
||||
- **No create-time or addressed-subagent selection** — both entries require an existing ordinary session's Agent; there is no draft-phase model choice to fold into session creation, and subagent continuation deliberately exposes no independent model-retargeting contract.
|
||||
- **Directory names are presentation-only** — selection and persistence use provider/model/effort ids; a provider whose catalog or exact-model metadata lookup fails lists as an unselectable failure row until reload.
|
||||
- **No arbitrary effort input** — the composer offers only the exact model's adapter-advertised levels; an adapter without reasoning metadata leaves the Effort row absent.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。
|
||||
模型选择插件(浏览器半侧):**两个入口共用一份 per-session 目录**,由 `ModelService`(`ctx.models`)持有。对于普通会话,`/model` popupSelect contribution(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` 坑位都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选确切模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方/模型/推理(reasoning)目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。逐提供方元数据失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话 scope 一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
|
||||
|
||||
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、坑位注入面类型。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接影响,经两个入口共同提交的 `session.selectModel` RPC:Host 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此下一次请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
|
||||
间接影响,经仅普通会话可用的 `session.selectModel` RPC,两个入口都会提交提供方/模型/推理强度目标,Host 会在下一次提示词组装边界对该目标进行快照,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标;只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化,且菜单交互不会添加提示词内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无创建期选择**——两个入口都面向既有会话的 agent(智能体);没有将草稿阶段的模型选择纳入会话创建的通道(host 的 `targetFor` 中的种子顺序说明了该层未来的落点)。
|
||||
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或具体模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
|
||||
- **不能任意输入推理强度**——composer 仅提供具体模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
|
||||
- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可折入会话创建的 Draft 期模型选择,subagent 继续执行也有意不公开独立更改模型目标的契约。
|
||||
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
|
||||
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
|
||||
|
||||
@@ -40,7 +40,8 @@ interface EffortChoice {
|
||||
* @returns the trigger and, while open, the two-level menu.
|
||||
*/
|
||||
export function ModelSelect(
|
||||
{ locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
|
||||
{ locked, available, directory, load, select, t }:
|
||||
ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
|
||||
) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => directory.subscribe(fn),
|
||||
@@ -92,7 +93,9 @@ export function ModelSelect(
|
||||
const busy = state.status === 'selecting'
|
||||
|
||||
// Mount-time load resolves the trigger label; every open refreshes.
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => {
|
||||
if (available) load()
|
||||
}, [available, load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -103,6 +106,8 @@ export function ModelSelect(
|
||||
return () => { document.removeEventListener('mousedown', closeOutside) }
|
||||
}, [open])
|
||||
|
||||
if (!available) return null
|
||||
|
||||
const show = (): void => {
|
||||
setPane('root')
|
||||
setOpen(true)
|
||||
|
||||
@@ -39,10 +39,12 @@ export class ModelDirectory {
|
||||
/**
|
||||
* @param sessions - the session wire face (captured from the plugin's root connection).
|
||||
* @param sessionId - the owning session.
|
||||
* @param available - whether this session may use Agent-bound model RPCs.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessions: Pick<IApiClient['sessions'], 'models' | 'selectModel'>,
|
||||
private readonly sessionId: SessionId,
|
||||
private readonly available: () => boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -51,6 +53,7 @@ export class ModelDirectory {
|
||||
* @returns the fresh directory value.
|
||||
*/
|
||||
async load(): Promise<SessionModels> {
|
||||
this.assertAvailable()
|
||||
const generation = ++this.generation
|
||||
this.store.update((s) => { s.status = 'loading'; s.error = null })
|
||||
const { result } = await this.sessions.models({ sessionId: this.sessionId })
|
||||
@@ -80,6 +83,7 @@ export class ModelDirectory {
|
||||
* @param target - provider, provider-owned model id, and optional adapter-owned effort.
|
||||
*/
|
||||
async select(target: ModelTarget): Promise<void> {
|
||||
this.assertAvailable()
|
||||
const generation = ++this.generation
|
||||
this.store.update((s) => { s.status = 'selecting'; s.error = null })
|
||||
const { result } = await this.sessions.selectModel({
|
||||
@@ -116,6 +120,7 @@ export class ModelDirectory {
|
||||
s.status = 'idle'
|
||||
s.error = null
|
||||
})
|
||||
if (!this.available()) return
|
||||
void this.load().catch(() => { /* the next menu open remains the explicit retry surface */ })
|
||||
}
|
||||
|
||||
@@ -123,4 +128,10 @@ export class ModelDirectory {
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
}
|
||||
|
||||
private assertAvailable(): void {
|
||||
if (!this.available()) {
|
||||
throw new Error('model selection is unavailable for addressed subagent sessions')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* so the host-reported current target is the single fact both surfaces echo
|
||||
* — a switch made in either entry is what the other shows next. Failures
|
||||
* ride each entry's own retry surface (popup shell error/retry; seat menu
|
||||
* inline error) without forking the state.
|
||||
* inline error) without forking the state. Addressed subagent sessions expose
|
||||
* neither entry because those Agent-bound RPCs would activate persisted
|
||||
* history outside the direct-parent continuation seam.
|
||||
*/
|
||||
import type { ModelTarget, SessionModels } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -119,14 +121,23 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.inject(['command', 'models'], (scope: ClientContext) => {
|
||||
const command = scope.get('command') as CommandServiceContract
|
||||
const models = scope.models
|
||||
const sessions = scope.sessions
|
||||
scope.effect(() => command.register({
|
||||
name: 'model',
|
||||
description: t('command.description'),
|
||||
available: () => true,
|
||||
available: session => sessions.subagentAddress(session.sessionId) === undefined,
|
||||
ui: {
|
||||
kind: 'popupSelect',
|
||||
options: async session => optionsOf(await models.directoryFor(session.sessionId).load(), t),
|
||||
options: async (session) => {
|
||||
if (sessions.subagentAddress(session.sessionId) !== undefined) {
|
||||
throw new Error('model selection is unavailable for addressed subagent sessions')
|
||||
}
|
||||
return optionsOf(await models.directoryFor(session.sessionId).load(), t)
|
||||
},
|
||||
onSelect: async (option, session) => {
|
||||
if (sessions.subagentAddress(session.sessionId) !== undefined) {
|
||||
throw new Error('model selection is unavailable for addressed subagent sessions')
|
||||
}
|
||||
const directory = models.directoryFor(session.sessionId)
|
||||
const target = targetOf(directory.store.getSnapshot(), option.id)
|
||||
if (target === undefined) {
|
||||
@@ -143,15 +154,22 @@ export function apply(ctx: ClientContext): void {
|
||||
// conversation service's presence is the registration-safe signal.
|
||||
ctx.inject(['slots', 'conversation', 'models'], (scope: ClientContext) => {
|
||||
const models = scope.models
|
||||
const sessions = scope.sessions
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.model',
|
||||
locale: NS,
|
||||
inject: (sessionId): ModelSelectInjected => {
|
||||
const directory = models.directoryFor(sessionId)
|
||||
const available = sessions.subagentAddress(sessionId) === undefined
|
||||
return {
|
||||
available,
|
||||
directory: directory.store,
|
||||
load: () => { directory.load().catch(() => { /* surfaced on the store */ }) },
|
||||
select: (target: ModelTarget) => directory.select(target).then(() => true, () => false),
|
||||
load: () => {
|
||||
if (available) directory.load().catch(() => { /* surfaced on the store */ })
|
||||
},
|
||||
select: (target: ModelTarget) => available
|
||||
? directory.select(target).then(() => true, () => false)
|
||||
: Promise.resolve(false),
|
||||
}
|
||||
},
|
||||
}, ModelSelect), 'ui-model: composer model seat registration')
|
||||
|
||||
@@ -68,7 +68,11 @@ export class ModelService extends Service {
|
||||
const actx = sessions.scope(sessionId)
|
||||
if (actx === undefined) throw new Error(`ui-model: session "${String(sessionId)}" resolved no scope`)
|
||||
const connection = this.ctx.get('connection') as ConnectionHandle
|
||||
const directory = new ModelDirectory(connection.api.sessions, sessionId)
|
||||
const directory = new ModelDirectory(
|
||||
connection.api.sessions,
|
||||
sessionId,
|
||||
() => sessions.subagentAddress(sessionId) === undefined,
|
||||
)
|
||||
live.directories.set(sessionId, directory)
|
||||
actx.effect(() => () => {
|
||||
directory.dispose()
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { ModelDirectoryState } from './directory.ts'
|
||||
|
||||
/** Injected business face of the composer model seat. */
|
||||
export interface ModelSelectInjected {
|
||||
/** Whether this session supports Agent-bound model inspection and selection. */
|
||||
available: boolean
|
||||
/** The session's shared directory store (same instance the /model popup reads). */
|
||||
directory: SnapshotStore<ModelDirectoryState>
|
||||
/** Refresh the advisory directory (fire-and-forget; errors land on the store). */
|
||||
|
||||
@@ -93,7 +93,13 @@ async function bench() {
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) })
|
||||
const addressed = new Set<SessionId>()
|
||||
ctx.provide('sessions', {
|
||||
scope: (id: SessionId) => scopes.get(id),
|
||||
subagentAddress: (id: SessionId) => addressed.has(id)
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: undefined,
|
||||
})
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await ctx.plugin(function probe() {}).await()
|
||||
@@ -108,6 +114,7 @@ async function bench() {
|
||||
seat: () => seats.get('conversation.input.model')!,
|
||||
hostCurrent: () => current,
|
||||
setHostCurrent: (target: ModelTarget) => { current = target },
|
||||
address: (id: SessionId) => { addressed.add(id) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,4 +221,30 @@ describe('ui-model dual entry', () => {
|
||||
const b = await bench()
|
||||
expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/)
|
||||
})
|
||||
|
||||
it('withholds both model entries from addressed subagent sessions without Agent-bound RPCs', async () => {
|
||||
const b = await bench()
|
||||
b.mint('child')
|
||||
b.address(sid('child'))
|
||||
|
||||
expect(b.contribution().available(projection('child'))).toBe(false)
|
||||
await expect(b.contribution().ui.options(
|
||||
projection('child'),
|
||||
new AbortController().signal,
|
||||
)).rejects.toThrow(/unavailable for addressed subagent/)
|
||||
|
||||
const face = b.seat().inject!(sid('child'))
|
||||
expect(face.available).toBe(false)
|
||||
face.load()
|
||||
await expect(face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' })).resolves.toBe(false)
|
||||
await expect(b.ctx.models.directoryFor(sid('child')).load())
|
||||
.rejects.toThrow(/unavailable for addressed subagent/)
|
||||
await expect(b.ctx.models.directoryFor(sid('child')).select({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
})).rejects.toThrow(/unavailable for addressed subagent/)
|
||||
b.ctx.emit('connection/reset')
|
||||
await Promise.resolve()
|
||||
expect(b.calls).toEqual({ models: 0, select: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('ModelSelect reasoning effort', () => {
|
||||
})
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={select}
|
||||
@@ -95,6 +96,7 @@ describe('ModelSelect reasoning effort', () => {
|
||||
}))
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
@@ -108,4 +110,19 @@ describe('ModelSelect reasoning effort', () => {
|
||||
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
|
||||
.toEqual(['Default', 'Standard'])
|
||||
})
|
||||
|
||||
it('renders no Agent-bound control for an addressed subagent session', () => {
|
||||
const load = vi.fn()
|
||||
render(<ModelSelect
|
||||
locked={false}
|
||||
available={false}
|
||||
directory={createSnapshotStore(state())}
|
||||
load={load}
|
||||
select={vi.fn().mockResolvedValue(false)}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(load).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user