feat(web): rewrite subagent conversations for FIFO activation
This commit is contained in:
@@ -2435,7 +2435,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'subagent.list': return this.api.subagents.list(request)
|
||||
case 'subagent.history': return this.api.subagents.history(request)
|
||||
case 'subagent.prompt': return this.api.subagents.prompt(request)
|
||||
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
|
||||
|
||||
@@ -153,7 +153,7 @@ export class SessionManager {
|
||||
selectSubagent(address: SubagentAddress): void {
|
||||
const catalog = this.catalogs.get(address.parentSessionId)
|
||||
const entry = catalog?.entries.find(candidate => candidate.id === address.childSessionId)
|
||||
if (entry === undefined || entry.kind !== 'child') {
|
||||
if (entry === undefined || entry.kind !== 'child' || entry.mode !== address.mode) {
|
||||
throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`)
|
||||
}
|
||||
this.addresses.set(address.childSessionId, address)
|
||||
@@ -625,7 +625,7 @@ export class SessionManager {
|
||||
case 'host/session-removed': {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
if (this.addresses.has(frame.sessionId)) {
|
||||
// A continuable activation detaching is not durable child deletion:
|
||||
// An Activation detaching is not durable child deletion:
|
||||
// keep the addressed conversation usable and return its catalog row
|
||||
// to the inactive state.
|
||||
this.sessions.get(frame.sessionId)?.handleRunning(false)
|
||||
|
||||
@@ -642,7 +642,7 @@ export class SessionsService implements ISessions {
|
||||
if (child?.kind === 'child') {
|
||||
byId[current] = {
|
||||
id: current,
|
||||
displayTitle: child.label,
|
||||
displayTitle: child.label ?? current,
|
||||
parentId: currentAddress.parentSessionId,
|
||||
origin: 'subagent',
|
||||
running: child.activity === 'running',
|
||||
@@ -660,7 +660,8 @@ export class SessionsService implements ISessions {
|
||||
} else if (byId[current] !== undefined
|
||||
&& (persisted !== current
|
||||
|| this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
|
||||
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId)) {
|
||||
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
|
||||
|| this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
|
||||
this.selection.set({
|
||||
sessionId: current,
|
||||
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
|
||||
|
||||
@@ -223,6 +223,15 @@ export class Session implements SessionFace {
|
||||
try {
|
||||
if (this.address === undefined) {
|
||||
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
|
||||
} else if (this.address.mode === 'one-shot') {
|
||||
result = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-not-resumable',
|
||||
message: 'one-shot subagent conversations are read-only',
|
||||
details: { childSessionId: this.address.childSessionId },
|
||||
},
|
||||
}
|
||||
} else {
|
||||
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
@@ -270,7 +279,7 @@ export class Session implements SessionFace {
|
||||
const result: RpcResult<{ accepted: true }> = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-not-delivered',
|
||||
code: 'subagent-delivery-unavailable',
|
||||
message: 'subagent activation cancellation is unavailable',
|
||||
details: { childSessionId: this.address.childSessionId },
|
||||
},
|
||||
@@ -512,6 +521,7 @@ export class Session implements SessionFace {
|
||||
configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
|
||||
const same = this.address?.parentSessionId === address?.parentSessionId
|
||||
&& this.address?.childSessionId === address?.childSessionId
|
||||
&& this.address?.mode === address?.mode
|
||||
this.address = address
|
||||
this.parentAvailable = parentAvailable
|
||||
if (!same && this.openState !== 'cold') void this.resync()
|
||||
|
||||
@@ -286,38 +286,43 @@ describe('subagent catalogs', () => {
|
||||
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
|
||||
] as never[] }))
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{ kind: 'child', id: S2, label: 'worker', activity: 'running' }] as never[],
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running',
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2 })
|
||||
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
|
||||
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual({
|
||||
parentSessionId: S1, childSessionId: S2,
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: S1, childSessionId: S2 },
|
||||
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
// Clicking the same child through an ordinary list-selection path must not
|
||||
// erase the catalog-derived address and fall back to session.* transport.
|
||||
manager.select(S2)
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual({
|
||||
parentSessionId: S1, childSessionId: S2,
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: S1, childSessionId: S2 },
|
||||
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await manager.get(S2).open()
|
||||
await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: S1, childSessionId: S2, maxMessages: 50 },
|
||||
{ parentSessionId: S1, childSessionId: S2, mode: 'continuable', maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([
|
||||
{ parentSessionId: S1, childSessionId: S2, content: [{ type: 'text', text: 'continue' }] },
|
||||
{
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
|
||||
content: [{ type: 'text', text: 'continue' }],
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
@@ -337,7 +342,9 @@ describe('subagent catalogs', () => {
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot()).toMatchObject({
|
||||
removed: false,
|
||||
subagent: { address: { parentSessionId: S1, childSessionId: S2 } },
|
||||
subagent: {
|
||||
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -554,7 +561,9 @@ describe('connected generation', () => {
|
||||
|
||||
it('reloads the durable parent address for a restored child selection', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const address = { parentSessionId: S1, childSessionId: S2 }
|
||||
const address = {
|
||||
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
|
||||
}
|
||||
const manager = new SessionManager(api, S2, address)
|
||||
|
||||
manager.handleConnected()
|
||||
|
||||
@@ -584,7 +584,7 @@ describe('prompt and cancel errors', () => {
|
||||
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID },
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await session.open()
|
||||
@@ -592,22 +592,40 @@ describe('prompt and cancel errors', () => {
|
||||
const cancelled = await session.cancel()
|
||||
|
||||
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-not-delivered' } })
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, maxMessages: 50 },
|
||||
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, content: [{ type: 'text', text: '继续' }] },
|
||||
{
|
||||
parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
|
||||
content: [{ type: 'text', text: '继续' }],
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
expect(api.callsOf('session.cancel')).toEqual([])
|
||||
expect(session.getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: PARENT, childSessionId: SID },
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
|
||||
})
|
||||
await session.open()
|
||||
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
|
||||
|
||||
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([])
|
||||
})
|
||||
|
||||
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
|
||||
const { api, session } = makeSession()
|
||||
// The blank → engaging edge fires before the RPC settles: the first-send
|
||||
|
||||
@@ -74,7 +74,7 @@ async function bench(opts: BenchOptions = {}) {
|
||||
scope: (id: SessionId) => scopes.get(id)?.ctx,
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
subagentAddress: (id: SessionId) => id === opts.addressed
|
||||
? { parentSessionId: sid('parent'), childSessionId: id }
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: undefined,
|
||||
})
|
||||
ctx.provide('connection', { api })
|
||||
|
||||
@@ -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-conversation/README.md
|
||||
README.md: fef922ab42e71813faaa826cc2580bdec72baa74
|
||||
README.zh.md: 4c1e7670bbfceaed73328108d3b5a3e646ee86bf
|
||||
README.md: 65e4542e622713e2cd120906926fc0601ef280bd
|
||||
README.zh.md: 4828ad214bb5ec0a9921307dd01dcc282910b330
|
||||
|
||||
@@ -12,7 +12,7 @@ The view ring IS a slot: the conversation registration declares the `'conversati
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
|
||||
|
||||
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes `subagentReadOnly`; ui-subagent claims that state to explain the unavailable-parent condition, while the ordinary InputBar hides Stop for every addressed continuable subagent conversation because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
|
||||
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包(package)自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
|
||||
|
||||
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含 `subagentReadOnly`;ui-subagent 会接管该状态并说明 parent 不可用,而普通 InputBar 会在所有已寻址的可继续 subagent 对话中隐藏 Stop,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
|
||||
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
|
||||
|
||||
|
||||
@@ -334,8 +334,8 @@ export type ComposerBarProps =
|
||||
*/
|
||||
export interface ComposerChainProps {
|
||||
interactions: readonly PendingInteraction[]
|
||||
/** A catalog-addressed child whose exact parent Agent is unavailable. */
|
||||
subagentReadOnly: boolean
|
||||
/** Current conversation facts for feature-owned takeover selectors. */
|
||||
session: ConversationSnapshot | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,6 @@ export function ConversationRoot({
|
||||
const openState = useSession(s => s.openState)
|
||||
const composerPhase = useSession(s => s.composerPhase)
|
||||
const pending = useSession(s => s.pending) ?? []
|
||||
const subagentReadOnly = useSession(s => s?.subagent?.parentAvailable === false) ?? false
|
||||
const session = useSession(s => s)
|
||||
const inputState = useInput(s => s)
|
||||
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
|
||||
@@ -154,7 +153,7 @@ export function ConversationRoot({
|
||||
const phase = settling ? 'settling' : hero ? 'hero' : 'active'
|
||||
const composer = renderSlotChain(
|
||||
'conversation.composer',
|
||||
{ interactions: pending, subagentReadOnly },
|
||||
{ interactions: pending, session },
|
||||
{ fallback: composerBar, overlay: true },
|
||||
)
|
||||
|
||||
|
||||
@@ -248,6 +248,8 @@ describe('bash sample row', () => {
|
||||
},
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ describe('tails', () => {
|
||||
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, blank: false, updatedAt: 0 } },
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
})
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ const seatOver = (dict: Record<string, string>, common: Record<string, string>):
|
||||
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
|
||||
const kit = {
|
||||
sessionId: SID,
|
||||
session: undefined,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
useWorkspaces: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<WorkspaceListState>,
|
||||
|
||||
@@ -26,7 +26,7 @@ const seatOver = (dict: Record<string, string>, common: Record<string, string>):
|
||||
* the composed props type mandates delivery of the rest (framework hooks are
|
||||
* plain stubs per the client testing discipline). */
|
||||
const kit = {
|
||||
subagentReadOnly: false,
|
||||
session: undefined,
|
||||
sessionId: SID,
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
|
||||
@@ -31,7 +31,7 @@ async function bench(list: ListFn, addressed?: SessionId) {
|
||||
ctx.provide('connection', { api: { skills: { list } } })
|
||||
ctx.provide('sessions', {
|
||||
subagentAddress: (id: SessionId) => id === addressed
|
||||
? { parentSessionId: sid('parent'), childSessionId: id }
|
||||
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
|
||||
: undefined,
|
||||
})
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
@@ -11,11 +11,10 @@
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/**
|
||||
* The provider-facing projection of one client session. Client sessions are
|
||||
* always agent-backed — the host births Session+Agent+cwd together and the
|
||||
* client only creates scopes for materialized sessions — so the projection
|
||||
* carries the stable session identity alone: sources address every RPC by
|
||||
* `sessionId` with no capability discrimination.
|
||||
* The provider-facing projection of one client session. It carries stable
|
||||
* identity alone; a source that calls Agent-bound RPCs must consult its own
|
||||
* service's capability state because an addressed persisted subagent may
|
||||
* have a client scope without a live Host Agent.
|
||||
*/
|
||||
export interface ClientSessionContext {
|
||||
readonly sessionId: SessionId
|
||||
|
||||
@@ -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-subagent/README.md
|
||||
README.md: c69211bd6f84b6e09760ff47d5a319b1ef2ce28c
|
||||
README.zh.md: 43776fbc32a5bd369e837779787cdc82ceb0f749
|
||||
README.md: 7399681265a9905f12fa0cbaf621528fb86562a3
|
||||
README.zh.md: f9a507336058920835745c3882fc3b9fe83c5ab0
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, the unavailable-parent replacement to the conversation composer chain, and the existing `@` reference source to `ctx.slash`.
|
||||
Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`.
|
||||
|
||||
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty catalog arrives it shows the healthy direct-child count and a compact tree in service order. Each healthy row combines its durable label, `running`/`inactive` activity (rendered as `正在处理`/`已完成`), optional log-backed title, and session-summary activity time; corrupt, unsupported, or unavailable rows remain readable but disabled. Expanding a row lazily opens that child's direct catalog and reports every visible branch to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
|
||||
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty catalog arrives it shows the healthy direct-child count and a compact tree in service order. Continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id. Corrupt, unsupported, or unavailable rows remain readable but disabled. Expanding a row lazily opens that child's direct catalog and reports every visible branch to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
|
||||
|
||||
An addressed child with no exact live parent elects the read-only composer entry and explains the recovery path. A child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; this package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
|
||||
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
|
||||
|
||||
Subagent-origin Session rows are omitted from the ordinary sidebar, so the parent header catalog is their navigation entry point. Ordinary forks remain in the sidebar.
|
||||
|
||||
@@ -18,7 +18,7 @@ The `@` source remains deliberately separate and inert. Candidates are zero-RPC
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Only the legacy `@` reference source affects model input: a picked candidate reaches the ordinary user message as literal `@label`, without a dedicated block or host-side resolution. Catalog browsing, child navigation, persisted transcript viewing, and human continuation UI add no prompt section; continuation content becomes a normal user-role event through the host subagent adapter.
|
||||
Only the legacy `@` reference source affects model input: a picked candidate reaches the ordinary user message as literal `@label`, without a dedicated block or host-side resolution. Catalog browsing, child navigation, and persisted transcript viewing add no prompt section; accepted continuation content becomes a normal FIFO user message through the host subagent adapter.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The catalog has coarse liveness only** — it cannot show durable outcome, elapsed time, exact Activation state, or a correct cancel button.
|
||||
- **The catalog has coarse activity only** — it cannot show durable outcome, elapsed time, Activation identity, or an authority-safe cancel button.
|
||||
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献 parent 不可用时的替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。
|
||||
Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。
|
||||
|
||||
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空目录到达后,它会显示健康的直接 child 数量,并按服务顺序显示一棵紧凑树。每个健康行都组合其持久化 label、`running`/`inactive` 活动状态(分别呈现为「正在处理」/「已完成」)、由日志支撑的可选 title 与会话摘要中的活动时间;损坏、不受支持或不可用的行仍保持可读但禁用。展开某一行时,会懒加载该 child 的直接目录,并向运行时报告每个可见分支,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
|
||||
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空目录到达后,它会显示健康的直接 child 数量,并按服务顺序显示一棵紧凑树。可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id。损坏、不受支持或不可用的行仍保持可读但禁用。展开某一行时,会懒加载该 child 的直接目录,并向运行时报告每个可见分支,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
|
||||
|
||||
已寻址 child 没有确切的存活 parent 时,会选中只读编辑器配置项并说明恢复路径。parent 存活时,child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。
|
||||
one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。
|
||||
|
||||
普通侧边栏会省略带 subagent origin 的 Session 行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。
|
||||
|
||||
@@ -18,7 +18,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
只有旧有 `@` 引用 source 会影响模型输入:pick 的候选以字面文本 `@label` 进入普通用户消息,没有专用内容块或宿主侧解析。浏览目录、导航 child、查看持久化 transcript 与用户继续交互 UI 都不会添加提示词 section;继续交互内容会经宿主 subagent 适配器成为普通 user-role 事件。
|
||||
只有旧有 `@` 引用 source 会影响模型输入:pick 的候选以字面文本 `@label` 进入普通用户消息,没有专用内容块或宿主侧解析。浏览目录、导航 child 与查看持久化 transcript 都不会添加提示词 section;获准进入的继续交互内容会经宿主 subagent 适配器成为普通 FIFO 用户消息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -30,5 +30,5 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **目录只有粗粒度存活状态**:它不能显示持久化结果、耗时、确切的 Activation 状态或正确的取消按钮。
|
||||
- **目录只有粗粒度活动状态**:它不能显示持久化结果、耗时、Activation 身份或具备安全授权的取消按钮。
|
||||
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。
|
||||
|
||||
@@ -16,9 +16,9 @@ type Catalogs = SessionListState['subagentsByParent']
|
||||
|
||||
/** Business actions supplied by the slot registration. */
|
||||
export interface SubagentCatalogInjected {
|
||||
openChild(address: SubagentAddress): void
|
||||
refresh(parentSessionId: SessionId): void
|
||||
setCatalogOpen(parentSessionId: SessionId, open: boolean): void
|
||||
openChild: (address: SubagentAddress) => void
|
||||
refresh: (parentSessionId: SessionId) => void
|
||||
setCatalogOpen: (parentSessionId: SessionId, open: boolean) => void
|
||||
}
|
||||
|
||||
/** Full props for the session-header catalog action. */
|
||||
@@ -33,16 +33,16 @@ interface CatalogRowsProps {
|
||||
expanded: ReadonlySet<SessionId>
|
||||
level: number
|
||||
now: number
|
||||
openChild(address: SubagentAddress): void
|
||||
refresh(parentSessionId: SessionId): void
|
||||
toggleBranch(childSessionId: SessionId): void
|
||||
closeCatalog(): void
|
||||
openChild: (address: SubagentAddress) => void
|
||||
refresh: (parentSessionId: SessionId) => void
|
||||
toggleBranch: (childSessionId: SessionId) => void
|
||||
closeCatalog: () => void
|
||||
}
|
||||
|
||||
function diagnosticReason(entry: Extract<CatalogEntry, { kind: 'diagnostic' }>): string {
|
||||
switch (entry.reason) {
|
||||
case 'corrupt': return '会话记录损坏'
|
||||
case 'unsupported': return '不是可继续的子代理'
|
||||
case 'unsupported': return '子代理记录版本不受支持'
|
||||
case 'unavailable': return '会话记录暂不可用'
|
||||
}
|
||||
}
|
||||
@@ -119,11 +119,16 @@ function CatalogRows({
|
||||
const isExpanded = expanded.has(entry.id)
|
||||
const knownLeaf = childCatalog?.state === 'ready' && childCatalog.entries.length === 0
|
||||
const summary = summaries[entry.id]
|
||||
const secondary = summary?.title ?? (entry.activity === 'running' ? '正在处理' : '已完成')
|
||||
const label = entry.label ?? entry.id
|
||||
const mode = entry.mode === 'one-shot' ? '一次性' : '可继续'
|
||||
const activity = entry.activity === 'running' ? '正在运行' : '当前未运行'
|
||||
const secondary = [summary?.title, mode, activity]
|
||||
.filter(value => value !== undefined)
|
||||
.join(' · ')
|
||||
const time = relativeTime(summary?.updatedAt, now)
|
||||
|
||||
const open = (): void => {
|
||||
openChild({ parentSessionId, childSessionId: entry.id })
|
||||
openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
|
||||
closeCatalog()
|
||||
}
|
||||
const handleKey = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
@@ -131,11 +136,10 @@ function CatalogRows({
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
open()
|
||||
} else if (event.key === 'ArrowRight' && !knownLeaf && !isExpanded) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleBranch(entry.id)
|
||||
} else if (event.key === 'ArrowLeft' && isExpanded) {
|
||||
} else if (
|
||||
(event.key === 'ArrowRight' && !knownLeaf && !isExpanded)
|
||||
|| (event.key === 'ArrowLeft' && isExpanded)
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleBranch(entry.id)
|
||||
@@ -153,7 +157,7 @@ function CatalogRows({
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={level}
|
||||
aria-label={[entry.label, secondary, time].filter(value => value !== undefined).join(' ')}
|
||||
aria-label={[label, secondary, time].filter(value => value !== undefined).join(' ')}
|
||||
{...knownLeaf ? {} : { 'aria-expanded': isExpanded }}
|
||||
className={css.row}
|
||||
onClick={open}
|
||||
@@ -166,7 +170,7 @@ function CatalogRows({
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`}
|
||||
aria-label={`${isExpanded ? '收起' : '展开'} ${entry.label} 的下级子代理`}
|
||||
aria-label={`${isExpanded ? '收起' : '展开'} ${label} 的下级子代理`}
|
||||
onClick={toggle}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
@@ -174,7 +178,7 @@ function CatalogRows({
|
||||
)}
|
||||
<StateDot state={entry.activity === 'running' ? 'ongoing' : 'done'} />
|
||||
<span className={css.content}>
|
||||
<span className={css.label}>{entry.label}</span>
|
||||
<span className={css.label}>{label}</span>
|
||||
<span className={css.summary}>{secondary}</span>
|
||||
</span>
|
||||
{time !== undefined && <span className={css.time}>{time}</span>}
|
||||
@@ -341,7 +345,7 @@ export function SubagentCatalogAction({
|
||||
<span>{healthy.length} 个子代理</span>
|
||||
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
|
||||
</button>
|
||||
{open && catalog !== undefined && (
|
||||
{open && (
|
||||
<div className={css.menu} role="tree" aria-label="子代理会话">
|
||||
<CatalogRows
|
||||
parentSessionId={sessionId}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import css from './SubagentReadOnlyComposer.module.css'
|
||||
|
||||
/** Why a catalog-addressed conversation cannot accept human input. */
|
||||
export interface SubagentReadOnlyMatch {
|
||||
reason: 'one-shot' | 'parent-unavailable'
|
||||
}
|
||||
|
||||
/** Full chain props after the read-only subagent selector accepts the owner currency. */
|
||||
export type SubagentReadOnlyComposerProps =
|
||||
PropsRuntime<'conversation.composer'> & { matched: ComposerChainProps }
|
||||
PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch }
|
||||
|
||||
/**
|
||||
* Explain why the normal composer is unavailable for a parentless child.
|
||||
* Explain why the normal composer is unavailable for an addressed child.
|
||||
* @param props - selector-owned read-only reason plus standard slot props.
|
||||
* @returns A read-only composer replacement.
|
||||
*/
|
||||
export function SubagentReadOnlyComposer() {
|
||||
export function SubagentReadOnlyComposer({
|
||||
matched,
|
||||
}: Pick<SubagentReadOnlyComposerProps, 'matched'>) {
|
||||
const oneShot = matched.reason === 'one-shot'
|
||||
return (
|
||||
<div className={css.frame} role="status">
|
||||
<strong>此子代理暂时只读</strong>
|
||||
<span>父会话当前不在线,重新打开父会话后即可继续发送消息。</span>
|
||||
<strong>{oneShot ? '一次性子代理记录' : '此子代理暂时只读'}</strong>
|
||||
<span>
|
||||
{oneShot
|
||||
? '一次性任务不支持后续消息,可在这里查看完整执行记录。'
|
||||
: '父会话当前不在线,重新打开父会话后即可继续发送消息。'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,19 +15,26 @@ import type {
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentCatalogAction.tsx'
|
||||
import { SubagentReadOnlyComposer } from './SubagentReadOnlyComposer.tsx'
|
||||
import {
|
||||
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
|
||||
} from './SubagentReadOnlyComposer.tsx'
|
||||
|
||||
export type {
|
||||
SubagentCatalogActionProps, SubagentCatalogInjected,
|
||||
} from './SubagentCatalogAction.tsx'
|
||||
export type { SubagentReadOnlyComposerProps } from './SubagentReadOnlyComposer.tsx'
|
||||
export type {
|
||||
SubagentReadOnlyComposerProps, SubagentReadOnlyMatch,
|
||||
} from './SubagentReadOnlyComposer.tsx'
|
||||
|
||||
/** Required services for references, conversation slots, and session navigation. */
|
||||
export const inject = ['slash', 'sessions', 'conversation', 'slots']
|
||||
|
||||
/** Claim the composer only when an addressed child has no live continuation owner. */
|
||||
function selectReadOnlySubagent(owner: ComposerChainProps): ComposerChainProps | null {
|
||||
return owner.subagentReadOnly ? owner : null
|
||||
/** Claim the composer for one-shot history or an unavailable continuation owner. */
|
||||
function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
|
||||
const subagent = owner.session?.subagent
|
||||
if (subagent === undefined || subagent === null) return null
|
||||
if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' }
|
||||
return subagent.parentAvailable ? null : { reason: 'parent-unavailable' }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,9 +105,9 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.composer',
|
||||
priority: 10,
|
||||
priority: -10,
|
||||
select: selectReadOnlySubagent,
|
||||
}, SubagentReadOnlyComposer),
|
||||
'ui-subagent: unavailable-parent composer',
|
||||
'ui-subagent: read-only addressed composer',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,13 @@ const GRANDCHILD = 'grandchild' as SessionId
|
||||
function catalog(over: Partial<SubagentCatalogSnapshot> = {}): SubagentCatalogSnapshot {
|
||||
return {
|
||||
entries: [
|
||||
{ kind: 'child', id: CHILD, label: 'worker', activity: 'running' },
|
||||
{ kind: 'child', id: 'child-2' as SessionId, label: 'reviewer', activity: 'inactive' },
|
||||
{
|
||||
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', activity: 'running',
|
||||
},
|
||||
{
|
||||
kind: 'child', id: 'child-2' as SessionId, mode: 'one-shot',
|
||||
label: 'reviewer', activity: 'inactive',
|
||||
},
|
||||
{ kind: 'diagnostic', id: 'bad' as SessionId, reason: 'corrupt' },
|
||||
],
|
||||
parentAvailable: true,
|
||||
@@ -42,6 +47,7 @@ function props(
|
||||
displayTitle: 'worker',
|
||||
running: true,
|
||||
blank: false,
|
||||
waitingApproval: false,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
@@ -49,9 +55,12 @@ function props(
|
||||
subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested },
|
||||
currentAddress: undefined,
|
||||
} satisfies SessionListState
|
||||
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
|
||||
return select(state)
|
||||
}
|
||||
return {
|
||||
sessionId: PARENT,
|
||||
useSessions: (<T,>(select: (snapshot: SessionListState) => T) => select(state)),
|
||||
useSessions,
|
||||
openChild: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
setCatalogOpen: vi.fn(),
|
||||
@@ -67,14 +76,14 @@ describe('SubagentCatalogAction', () => {
|
||||
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
|
||||
expect(screen.getAllByRole('treeitem')).toHaveLength(3)
|
||||
expect(screen.getByText('正在扫描项目文件')).toBeTruthy()
|
||||
expect(screen.getByText('已完成')).toBeTruthy()
|
||||
expect(screen.getByText('正在扫描项目文件 · 可继续 · 正在运行')).toBeTruthy()
|
||||
expect(screen.getByText('一次性 · 当前未运行')).toBeTruthy()
|
||||
const diagnostic = screen.getByRole('treeitem', { name: /会话记录损坏/ })
|
||||
expect(diagnostic.getAttribute('aria-disabled')).toBe('true')
|
||||
|
||||
fireEvent.click(screen.getByRole('treeitem', { name: /worker/ }))
|
||||
expect(input.openChild).toHaveBeenCalledWith({
|
||||
parentSessionId: PARENT, childSessionId: CHILD,
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
})
|
||||
expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false)
|
||||
})
|
||||
@@ -102,7 +111,10 @@ describe('SubagentCatalogAction', () => {
|
||||
it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {
|
||||
const childCatalog = catalog({
|
||||
entries: [
|
||||
{ kind: 'child', id: GRANDCHILD, label: 'indexer', activity: 'inactive' },
|
||||
{
|
||||
kind: 'child', id: GRANDCHILD, mode: 'continuable',
|
||||
label: 'indexer', activity: 'inactive',
|
||||
},
|
||||
],
|
||||
})
|
||||
const grandchildCatalog = catalog({ entries: [] })
|
||||
@@ -120,7 +132,7 @@ describe('SubagentCatalogAction', () => {
|
||||
|
||||
fireEvent.click(nested)
|
||||
expect(input.openChild).toHaveBeenCalledWith({
|
||||
parentSessionId: CHILD, childSessionId: GRANDCHILD,
|
||||
parentSessionId: CHILD, childSessionId: GRANDCHILD, mode: 'continuable',
|
||||
})
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, false)
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
|
||||
@@ -129,7 +141,10 @@ describe('SubagentCatalogAction', () => {
|
||||
it('uses ArrowRight and ArrowLeft for branch disclosure', async () => {
|
||||
const input = props(catalog(), {
|
||||
[CHILD]: catalog({
|
||||
entries: [{ kind: 'child', id: GRANDCHILD, label: 'indexer', activity: 'running' }],
|
||||
entries: [{
|
||||
kind: 'child', id: GRANDCHILD, mode: 'continuable',
|
||||
label: 'indexer', activity: 'running',
|
||||
}],
|
||||
}),
|
||||
})
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
@@ -165,7 +180,10 @@ describe('SubagentCatalogAction', () => {
|
||||
it('closes every observed catalog when the root becomes empty', () => {
|
||||
const populated = props(catalog(), {
|
||||
[CHILD]: catalog({
|
||||
entries: [{ kind: 'child', id: GRANDCHILD, label: 'indexer', activity: 'inactive' }],
|
||||
entries: [{
|
||||
kind: 'child', id: GRANDCHILD, mode: 'continuable',
|
||||
label: 'indexer', activity: 'inactive',
|
||||
}],
|
||||
}),
|
||||
})
|
||||
const view = render(<SubagentCatalogAction {...populated} />)
|
||||
@@ -182,7 +200,12 @@ describe('SubagentCatalogAction', () => {
|
||||
|
||||
describe('SubagentReadOnlyComposer', () => {
|
||||
it('explains the exact missing-parent recovery path', () => {
|
||||
render(<SubagentReadOnlyComposer />)
|
||||
render(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} />)
|
||||
expect(screen.getByRole('status').textContent).toContain('父会话当前不在线')
|
||||
})
|
||||
|
||||
it('explains that one-shot histories never accept follow-ups', () => {
|
||||
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} />)
|
||||
expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user