feat(web): rewrite subagent conversations for FIFO activation

This commit is contained in:
Dudu-0223
2026-07-30 23:33:07 +08:00
committed by Tianyi Cui
parent f0ab04273d
commit 8a518e353b
52 changed files with 829 additions and 420 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
视图环本身就是 slot会话注册声明 `'conversation.view'` 列表 slotSession 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))。

View File

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

View File

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

View File

@@ -248,6 +248,8 @@ describe('bash sample row', () => {
},
current: undefined,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
})
}

View File

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

View File

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

View File

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

View File

@@ -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` RPCHost 在下一次提示词组装边界快照所选提供方/模型/推理强度目标,因此下一次请求采用所选路由和推理强度,而运行中的步骤保留已组装目标只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化菜单交互不会添加提示词内容。
间接影响,经仅普通会话可用`session.selectModel` RPC,两个入口都会提交提供方/模型/推理强度目标,Host 在下一次提示词组装边界对该目标进行快照,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化,且菜单交互不会添加提示词内容。
#### KV Cache 影响
@@ -16,6 +16,6 @@
## 已知限制与暂缓事项
- **无创建期选择**——两个入口都面向既有会话的 agent智能体没有将草稿阶段的模型选择纳入会话创建的通道host 的 `targetFor` 中的种子顺序说明了该层未来的落点)
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id目录查询或具体模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
- **不能任意输入推理强度**——composer 仅提供具体模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可折入会话创建的 Draft 期模型选择subagent 继续执行也有意不公开独立更改模型目标的契约
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRightArrowLeft 展开和折叠分支ArrowUpArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空目录到达后,它会显示健康的直接 child 数量,并按服务顺序显示一棵紧凑树。可继续和 one-shot 行会显示 mode`running``inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id。损坏、不受支持或不可用的行仍保持可读但禁用。展开某一行时,会懒加载该 child 的直接目录,并向运行时报告每个可见分支,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRightArrowLeft 展开和折叠分支ArrowUpArrowDown、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 会有歧义,因此它们刻意不获得继续执行语义。

View File

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

View File

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

View File

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

View File

@@ -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('一次性任务不支持后续消息')
})
})

View File

@@ -18,6 +18,8 @@ import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
@@ -28,7 +30,7 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
SessionSummary, SettingsNamespaceView, SubagentListEntry as SubagentCatalogEntry, ToolEventView,
SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import {
@@ -69,8 +71,6 @@ import type {
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import { openNativePath } from './native-path-opener.ts'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import type { SubagentListEntry as CoreSubagentListEntry } from '@deepseek-ai/dsh-subagent'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -484,92 +484,6 @@ function historyPage(
}
}
/** Map a continuation failure without exposing descriptor or provider details. */
function subagentPromptError(
request: RpcRequest<{ childSessionId: SessionId }>,
error: unknown,
signal?: AbortSignal,
): RpcResponse<never> {
const childSessionId = request.payload.childSessionId
if (signal?.aborted) {
return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} })
}
if (error instanceof SubagentError) {
switch (error.code) {
case 'NOT_RESUMABLE':
return err(request, { code: 'subagent-not-resumable', message: 'subagent cannot be resumed', details: { childSessionId } })
case 'UNAUTHORIZED':
return err(request, { code: 'subagent-unauthorized', message: 'subagent does not belong to this parent', details: { childSessionId } })
case 'ACTIVATION_CLOSING':
case 'DRAINING':
return err(request, { code: 'subagent-not-delivered', message: 'message was not delivered', details: { childSessionId } })
default:
break
}
}
return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
}
/** Verify one address against the complete durable direct-child catalog. */
async function healthyCatalogChild(
ctx: Context,
parentSessionId: SessionId,
childSessionId: SessionId,
signal?: AbortSignal,
): Promise<{ error?: RpcError }> {
try {
const entries = await ctx.subagents.listChildren(parentSessionId, signal)
const entry = entries.find(candidate => candidate.id === childSessionId)
if (entry === undefined || (entry.kind === 'child' && entry.mode !== 'continuable')) {
return {
error: {
code: 'subagent-not-found',
message: `session "${childSessionId}" is not a continuable direct child of "${parentSessionId}"`,
details: { parentSessionId, childSessionId },
},
}
}
if (entry.kind === 'diagnostic') {
return {
error: {
code: 'subagent-catalog-diagnostic',
message: `subagent "${childSessionId}" is ${entry.reason}`,
details: { parentSessionId, childSessionId, reason: entry.reason },
},
}
}
return {}
} catch (error: unknown) {
if (error instanceof SubagentError && error.code === 'CANCELLED') {
return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
}
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
return {
error: {
code: 'subagent-not-found',
message: `parent session "${parentSessionId}" is unavailable`,
details: { parentSessionId, childSessionId },
},
}
}
return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
}
}
/** Project the durable catalog onto the continuable-only browser surface. */
function continuableCatalog(entries: readonly CoreSubagentListEntry[]): SubagentCatalogEntry[] {
return entries.flatMap((entry): SubagentCatalogEntry[] => {
if (entry.kind === 'diagnostic') return [entry]
if (entry.mode !== 'continuable') return []
return [{
kind: 'child',
id: entry.id,
label: entry.label,
activity: entry.activity,
}]
})
}
/**
* The projection baseline for one history tail page: the registry's
* watermark-cache snapshot — one fully synchronous read (no await between the
@@ -607,6 +521,107 @@ function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session
}
}
/** Projection baseline for a detached history tail without Agent activation. */
function detachedProjectionsFor(
ctx: Context,
events: readonly SessionEvent[],
): SessionProjectionsBlock | undefined {
const registry = ctx.get('sessionProjections')
if (registry === undefined) return undefined
return registry.restore({}, events, 0).snapshot
}
/** Map continuation admission failures without exposing provider details. */
function subagentPromptError(
request: RpcRequest<{ childSessionId: SessionId }>,
error: unknown,
signal: AbortSignal,
): RpcResponse<never> {
const childSessionId = request.payload.childSessionId
if (signal.aborted) {
return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} })
}
if (error instanceof SubagentError) {
switch (error.code) {
case 'NOT_RESUMABLE':
return err(request, {
code: 'subagent-not-resumable',
message: 'subagent cannot be resumed',
details: { childSessionId },
})
case 'UNAUTHORIZED':
return err(request, {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId },
})
case 'DRAINING':
case 'ACTIVATION_CLOSING':
case 'CONTINUATION_UNAVAILABLE':
case 'PERSISTENCE_UNAVAILABLE':
return err(request, {
code: 'subagent-delivery-unavailable',
message: 'subagent follow-up is temporarily unavailable',
details: { childSessionId },
})
default:
break
}
}
return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
}
/** Verify one address and mode against the complete direct-child catalog. */
async function catalogChild(
ctx: Context,
address: SubagentAddress,
signal?: AbortSignal,
): Promise<{
entry?: Extract<CatalogSubagentListEntry, { kind: 'child' }>
error?: RpcError
}> {
const { parentSessionId, childSessionId, mode } = address
try {
const entries = await ctx.subagents.listChildren(parentSessionId, signal)
const entry = entries.find(candidate => candidate.id === childSessionId)
if (entry === undefined || (entry.kind === 'child' && entry.mode !== mode)) {
return {
error: {
code: 'subagent-not-found',
message: `session "${childSessionId}" is not a ${mode} direct child of "${parentSessionId}"`,
details: { parentSessionId, childSessionId },
},
}
}
if (entry.kind === 'diagnostic') {
return {
error: {
code: 'subagent-catalog-diagnostic',
message: `subagent "${childSessionId}" is ${entry.reason}`,
details: { parentSessionId, childSessionId, reason: entry.reason },
},
}
}
return { entry }
} catch (error: unknown) {
if (signal?.aborted
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
}
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
return {
error: {
code: 'subagent-not-found',
message: `parent session "${parentSessionId}" was not found`,
details: { parentSessionId, childSessionId },
},
}
}
return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
}
}
/**
* Thrown by the cold-resume path when the id names no servable session
* (absent from the store, or a pre-project legacy log without a cwd).
@@ -1698,20 +1713,34 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
try {
const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal)
return ok(request, {
entries: continuableCatalog(entries),
entries,
parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
})
} catch (error: unknown) {
if (error instanceof SubagentError && error.code === 'CANCELLED') {
return err(request, { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} })
if (signal?.aborted
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
return err(request, {
code: 'cancelled',
message: 'subagent catalog read was cancelled',
details: {},
})
}
return err(request, { code: 'internal', message: 'subagent catalog read failed', details: {} })
return err(request, {
code: 'internal',
message: 'subagent catalog read failed',
details: {},
})
}
},
async history(request, signal) {
const { parentSessionId, childSessionId, beforeSeq, maxMessages } = request.payload
const verified = await healthyCatalogChild(ctx, parentSessionId, childSessionId, signal)
const {
parentSessionId, childSessionId, mode, beforeSeq, maxMessages,
} = request.payload
const verified = await catalogChild(ctx, {
parentSessionId, childSessionId, mode,
}, signal)
if (verified.error !== undefined) return err(request, verified.error)
try {
const snapshot = await ctx.sessionQuery.readSession(childSessionId)
@@ -1723,24 +1752,33 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: { childSessionId },
})
}
return ok(request, historyPage(ctx, snapshot.events, beforeSeq, maxMessages))
const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages)
const projections = beforeSeq === undefined
? detachedProjectionsFor(ctx, snapshot.events)
: undefined
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
} catch (error: unknown) {
if (signal?.aborted) {
return err(request, { code: 'cancelled', message: 'subagent history read was cancelled', details: {} })
if (signal?.aborted
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
return err(request, {
code: 'cancelled',
message: 'subagent history read was cancelled',
details: {},
})
}
if (error instanceof SessionQueryError) {
if (error.code === 'SESSION_QUERY_ABORTED') {
return err(request, { code: 'cancelled', message: 'subagent history read was cancelled', details: {} })
}
if (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
return err(request, {
code: 'subagent-not-found',
message: 'subagent disappeared during history read',
details: { parentSessionId, childSessionId },
})
}
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
return err(request, {
code: 'subagent-not-found',
message: 'subagent disappeared during history read',
details: { parentSessionId, childSessionId },
})
}
return err(request, { code: 'internal', message: 'subagent history read failed', details: {} })
return err(request, {
code: 'internal',
message: 'subagent history read failed',
details: {},
})
}
},
@@ -1754,19 +1792,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: { parentSessionId },
})
}
const verified = await healthyCatalogChild(ctx, parentSessionId, childSessionId, signal)
const verified = await catalogChild(ctx, {
parentSessionId, childSessionId, mode: 'continuable',
}, signal)
if (verified.error !== undefined) return err(request, verified.error)
const operationSignal = signal ?? new AbortController().signal
try {
const messageId = await ctx.subagents.followup(
parent,
childSessionId,
content,
{ source: { kind: 'user', rpcId: request.rpcId }, signal: operationSignal },
)
const messageId = await ctx.subagents.followup(parent, childSessionId, content, {
source: { kind: 'user', rpcId: request.rpcId },
signal,
})
return ok(request, { messageId })
} catch (error: unknown) {
return subagentPromptError(request, error, operationSignal)
return subagentPromptError(request, error, signal)
}
},
},

View File

@@ -65,7 +65,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
}) }),
z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-not-delivered'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-delivery-unavailable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -80,7 +80,7 @@ export interface RpcErrorDetailsMap {
}
'subagent-not-resumable': { childSessionId: SessionId }
'subagent-unauthorized': { childSessionId: SessionId }
'subagent-not-delivered': { childSessionId: SessionId }
'subagent-delivery-unavailable': { childSessionId: SessionId }
'internal': {}
}

View File

@@ -1,19 +1,29 @@
/** Zod schemas for the browser-safe subagent domain. */
import { z } from 'zod'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import { contentBlockSchema, historyEntrySchema, sessionIdSchema } from './sessions.schema.ts'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import {
contentBlockSchema, historyEntrySchema, sessionIdSchema, sessionProjectionsBlockSchema,
} from './sessions.schema.ts'
import type { SubagentListEntry } from './subagents.ts'
/** Healthy and diagnostic durable catalog rows. */
export const subagentListEntrySchema = z.discriminatedUnion('kind', [
export const subagentListEntrySchema = z.union([
z.object({
kind: z.literal('child'),
id: sessionIdSchema,
label: z.string(),
mode: z.literal('one-shot'),
activity: z.union([z.literal('running'), z.literal('inactive')]),
label: z.string().optional(),
}),
z.object({
kind: z.literal('child'),
id: sessionIdSchema,
mode: z.literal('continuable'),
activity: z.union([z.literal('running'), z.literal('inactive')]),
label: z.string(),
}),
z.object({
kind: z.literal('diagnostic'),
@@ -37,6 +47,7 @@ export const subagentListValueSchema = z.object({
export const subagentHistoryRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
mode: z.union([z.literal('one-shot'), z.literal('continuable')]),
beforeSeq: z.number().int().nonnegative().optional(),
maxMessages: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.history'>>>
@@ -45,12 +56,14 @@ export const subagentHistoryRequestSchema = z.object({
export const subagentHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
projections: sessionProjectionsBlockSchema.optional(),
}) as unknown as z.ZodType<Wire<ResponseValue<'subagent.history'>>>
/** subagent.prompt request payload. */
export const subagentPromptRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
mode: z.literal('continuable'),
content: z.array(contentBlockSchema),
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>

View File

@@ -1,23 +1,31 @@
/**
* Browser-safe subagent domain contract. Persisted transcript reads never
* activate an Agent, while prompts route through the direct parent's
* Activation-backed continuation owner.
* activate an Agent, while continuable prompts route through the exact live
* direct parent into the child's Agent inbox.
*/
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RpcRequest, RpcResponse } from './rpc.ts'
import type { HistoryEntry } from './sessions.ts'
import type { HistoryEntry, SessionProjectionsBlock } from './sessions.ts'
/** Complete durable direct-child catalog row. */
export type SubagentListEntry =
| {
kind: 'child'
id: SessionId
label: string
activity: 'running' | 'inactive'
}
} & (
| {
mode: 'one-shot'
label?: string
}
| {
mode: 'continuable'
label: string
}
)
| {
kind: 'diagnostic'
id: SessionId
@@ -30,10 +38,15 @@ export interface SubagentPromptReceipt {
}
/** Durable parent/child address that selects subagent transport in the client. */
export interface SubagentAddress {
parentSessionId: SessionId
childSessionId: SessionId
}
export type SubagentAddress =
& {
parentSessionId: SessionId
childSessionId: SessionId
}
& (
| { mode: 'one-shot' }
| { mode: 'continuable' }
)
/** Complete direct-child catalog plus the delivery-time parent availability hint. */
export interface SubagentCatalog {
@@ -44,8 +57,9 @@ export interface SubagentCatalog {
/** Subagent-domain unary methods. */
export interface SubagentsApi {
/**
* Lists direct continuable children without loading either side. Parent
* availability is a hint; prompt performs the authoritative check.
* Lists direct session-backed children without loading either side. Parent
* availability is a hint; continuable prompt performs the authoritative
* check.
*/
list(
request: RpcRequest<{ parentSessionId: SessionId }>,
@@ -59,14 +73,21 @@ export interface SubagentsApi {
history(
request: RpcRequest<SubagentAddress & { beforeSeq?: number; maxMessages?: number }>,
signal?: AbortSignal,
): Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
): Promise<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: SessionProjectionsBlock
}>>
/**
* Delivers human content through the exact live parent's continuation
* owner. Success identifies the accepted inbox message.
* Delivers human content to a continuable child through the exact live
* parent's continuation owner. Success identifies the message accepted by
* the child's FIFO inbox; later execution is independent of this request.
*/
prompt(
request: RpcRequest<SubagentAddress & { content: ContentBlock[] }>,
signal?: AbortSignal,
request: RpcRequest<
Extract<SubagentAddress, { mode: 'continuable' }> & { content: ContentBlock[] }
>,
signal: AbortSignal,
): Promise<RpcResponse<SubagentPromptReceipt>>
}

View File

@@ -71,7 +71,14 @@ describe('subagent gateway', () => {
expect(response.rpcId).toBe('subagent-rpc')
expect(response.result).toMatchObject({
ok: true,
value: { parentAvailable: false, entries: [{ kind: 'child' }, { kind: 'diagnostic' }] },
value: {
parentAvailable: false,
entries: [
{ kind: 'child', mode: 'continuable' },
{ kind: 'child', mode: 'one-shot' },
{ kind: 'diagnostic' },
],
},
})
expect(listChildren).toHaveBeenCalledWith(PARENT, undefined)
})
@@ -79,7 +86,7 @@ describe('subagent gateway', () => {
it('reads a healthy direct child without looking up or activating any Agent', async () => {
const { api, getAgent, readSession } = bench()
const response = await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, maxMessages: 10,
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
}))
expect(response.result).toMatchObject({
ok: true,
@@ -89,12 +96,26 @@ describe('subagent gateway', () => {
expect(getAgent).not.toHaveBeenCalled()
})
it('reads one-shot history and rejects an address with the wrong mode', async () => {
const oneShot = {
kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch', activity: 'inactive',
}
const { api, readSession } = bench({ entries: [oneShot] })
expect((await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot',
}))).result).toMatchObject({ ok: true })
expect((await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } })
expect(readSession).toHaveBeenCalledTimes(1)
})
it('rejects a diagnostic address before reading history', async () => {
const { api, readSession } = bench({ entries: [
{ kind: 'diagnostic', id: CHILD, reason: 'unsupported' },
] })
const response = await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD,
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))
expect(response.result).toMatchObject({
ok: false,
@@ -109,30 +130,36 @@ describe('subagent gateway', () => {
it('routes human content through the exact live parent with rpc attribution', async () => {
const { api, parent, followup } = bench()
const content = [{ type: 'text' as const, text: '继续' }]
const signal = new AbortController().signal
const response = await api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, content,
}))
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content,
}), signal)
expect(response.result).toMatchObject({
ok: true, value: { messageId: 'message-1' },
})
expect(followup).toHaveBeenCalledTimes(1)
const [actualParent, actualChild, actualContent, delivery] = followup.mock.calls[0]!
expect([actualParent, actualChild, actualContent]).toEqual([parent, CHILD, content])
expect(delivery.source).toEqual({ kind: 'user', rpcId: RpcId('subagent-rpc') })
expect(delivery.signal).toBeInstanceOf(AbortSignal)
expect(followup).toHaveBeenCalledWith(
parent,
CHILD,
content,
{ source: { kind: 'user', rpcId: RpcId('subagent-rpc') }, signal },
)
})
it('fails before delivery when the parent is absent and maps continuation failures', async () => {
const absent = bench({ parentLive: false })
expect((await absent.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, content: [],
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-parent-unavailable' } })
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'subagent-parent-unavailable' },
})
expect(absent.listChildren).not.toHaveBeenCalled()
const failed = bench({ followupError: new SubagentError('not delivered', 'DRAINING') })
const failed = bench({ followupError: new SubagentError('draining', 'DRAINING') })
expect((await failed.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, content: [],
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-delivered' } })
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'subagent-delivery-unavailable' },
})
})
it('maps history disappearance and hides unexpected backend details', async () => {
@@ -140,7 +167,7 @@ describe('subagent gateway', () => {
readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'),
})
expect((await disappeared.api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD,
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))).result).toMatchObject({
ok: false,
error: {
@@ -160,8 +187,8 @@ describe('subagent gateway', () => {
const prompt = bench({ followupError: new Error('secret provider') })
expect((await prompt.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, content: [],
}))).result).toMatchObject({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false,
error: { code: 'internal', message: 'subagent prompt failed' },
})

View File

@@ -110,7 +110,18 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async history(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { events: [], hasMore: false } } }
},
async prompt(request) {
async prompt(request, signal) {
if (request.payload.content.some(block => block.type === 'text' && block.text === 'hang')) {
if (!signal.aborted) {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
return {
rpcId: request.rpcId,
result: { ok: false, error: { code: 'cancelled' as const, message: 'aborted', details: {} } },
}
}
return {
rpcId: request.rpcId,
result: { ok: true, value: { messageId: 'message-1' as never } },
@@ -400,6 +411,23 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
}
})
it('round-trips the subagent domain through the wire form', async () => {
const c = client()
expect((await c.subagents.list({ parentSessionId: 'parent' as never })).result)
.toEqual({ ok: true, value: { entries: [], parentAvailable: false } })
expect((await c.subagents.history({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'one-shot',
})).result).toEqual({ ok: true, value: { events: [], hasMore: false } })
expect((await c.subagents.prompt({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'continuable',
content: [],
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
})
it('keeps caller and connection aborts on command.execute', async () => {
const api = fakeApi()
const started = Promise.withResolvers<AbortSignal>()
@@ -465,6 +493,34 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(parsed.result.error?.code).toBe('cancelled')
})
it('propagates the carrier Request signal into subagent.prompt', async () => {
const handler = toFetchHandler(fakeApi())
const controller = new AbortController()
const body = JSON.stringify({
type: 'client-request',
rpcId: 'r-subagent-sig',
method: 'subagent.prompt',
payload: {
parentSessionId: 'parent',
childSessionId: 'child',
mode: 'continuable',
content: [{ type: 'text', text: 'hang' }],
},
})
const pending = handler.fetch(new Request(
'http://x/api/subagent.prompt',
{ method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal },
))
controller.abort()
const response = await pending
const parsed = await response.json() as {
rpcId: string
result: { error?: { code: string } }
}
expect(parsed.rpcId).toBe('r-subagent-sig')
expect(parsed.result.error?.code).toBe('cancelled')
})
it('propagates the carrier Request signal into host.pickDirectory', async () => {
const api = fakeApi()
api.host.pickDirectory = async (request, signal) => {

View File

@@ -85,7 +85,7 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'subagent-catalog-diagnostic', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c', reason: 'corrupt' } }).code).toBe('subagent-catalog-diagnostic')
expect(rpcErrorSchema.parse({ code: 'subagent-not-resumable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-resumable')
expect(rpcErrorSchema.parse({ code: 'subagent-unauthorized', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-unauthorized')
expect(rpcErrorSchema.parse({ code: 'subagent-not-delivered', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-delivered')
expect(rpcErrorSchema.parse({ code: 'subagent-delivery-unavailable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-delivery-unavailable')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
@@ -291,29 +291,34 @@ describe('sessions domain schemas', () => {
describe('subagent domain schemas', () => {
it('validates the direct catalog and addressed history pair', () => {
const child = { kind: 'child', id: 'c', label: 'worker', activity: 'running' }
const child = {
kind: 'child', id: 'c', mode: 'continuable', label: 'worker', activity: 'running',
}
const oneShot = { kind: 'child', id: 'o', mode: 'one-shot', activity: 'inactive' }
const diagnostic = { kind: 'diagnostic', id: 'bad', reason: 'unsupported' }
expect(subagentListEntrySchema.parse(child)).toEqual(child)
expect(subagentListEntrySchema.parse(oneShot)).toEqual(oneShot)
expect(subagentListEntrySchema.parse(diagnostic)).toEqual(diagnostic)
expect(subagentListRequestSchema.parse({ parentSessionId: 'p' })).toEqual({ parentSessionId: 'p' })
expect(subagentListValueSchema.parse({
entries: [child, diagnostic], parentAvailable: true,
}).entries).toHaveLength(2)
entries: [child, oneShot, diagnostic], parentAvailable: true,
}).entries).toHaveLength(3)
expect(subagentHistoryRequestSchema.parse({
parentSessionId: 'p', childSessionId: 'c', beforeSeq: 4, maxMessages: 2,
parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', beforeSeq: 4, maxMessages: 2,
}).beforeSeq).toBe(4)
expect(() => subagentHistoryRequestSchema.parse({
parentSessionId: 'p', childSessionId: 'c', maxMessages: 0,
parentSessionId: 'p', childSessionId: 'c', mode: 'continuable', maxMessages: 0,
})).toThrow()
expect(subagentHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
})
it('validates prompt content and the accepted inbox identity', () => {
it('validates continuable prompt content and the accepted inbox identity', () => {
expect(subagentPromptRequestSchema.parse({
parentSessionId: 'p', childSessionId: 'c', content: [{ type: 'text', text: '继续' }],
parentSessionId: 'p', childSessionId: 'c', mode: 'continuable',
content: [{ type: 'text', text: '继续' }],
}).childSessionId).toBe('c')
expect(subagentPromptValueSchema.parse({ messageId: 'm1' }).messageId).toBe('m1')
expect(() => subagentPromptValueSchema.parse({ taskId: 't1' })).toThrow()
expect(() => subagentPromptValueSchema.parse({ route: 'started', taskId: 't2' })).toThrow()
})
})

View File

@@ -88,6 +88,8 @@ export function childSessionMeta(
return {
...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
parentSession: parentHeader.id,
// Navigation classification only; the descriptor remains the authority
// for mode and continuation capability.
origin: 'subagent',
// Durable: the recursion budget must survive persistence and resume.
delegationDepth: childDepth,

View File

@@ -211,6 +211,7 @@ describe('SubagentService.startContinuable', () => {
expect('surfaceOp' in descriptor).toBe(false)
expect(loaded.meta.id).toBe(started.childId)
expect(loaded.meta.parentSession).toBe(SessionId('parent'))
expect(loaded.meta.origin).toBe('subagent')
})
it('rolls the child back completely when the caller signal aborts before acceptance', async () => {