feat(web): add subagent conversation transport

This commit is contained in:
Dudu-0223
2026-08-02 12:51:09 +08:00
committed by Tianyi Cui
parent 9adfc576da
commit a27492507d
58 changed files with 1212 additions and 62 deletions
+3
View File
@@ -262,6 +262,9 @@
- id: tool-subagent-control
name: '@deepseek-ai/dsh-tool-subagent-control'
- id: tool-subagent-list-agents
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
+1 -1
View File
@@ -532,7 +532,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c
## `@deepseek-ai/dsh-host-apiproxy`
Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace`
Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace`
```ts config-catalog
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
@@ -16,6 +16,7 @@ export type {
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {
@@ -1910,6 +1910,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return ok(request, { accepted: true as const })
},
},
subagents: {
list: request => ok(request, { entries: [], parentAvailable: true }),
history: (request) => {
const log = logs.get(request.payload.childSessionId) ?? []
return Promise.resolve(ok(
request,
pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50),
))
},
prompt: request => Promise.resolve(ok(request, {
messageId: `fixture-message-${request.payload.childSessionId}` as never,
})),
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
// Deterministic native pick: the keyless lanes drive the full
@@ -2420,6 +2433,9 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
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 '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)
@@ -18,6 +18,7 @@ export type {
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
@@ -113,6 +113,20 @@ export class FakeApiClient implements IApiClient {
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({
entries: [],
parentAvailable: true,
}))),
history: (payload: unknown) => this.record('subagent.history', payload, Promise.resolve(ok({
events: [],
hasMore: false,
}))),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
messageId: 'fake-message' as never,
}))),
}
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
@@ -16,7 +16,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
+2 -2
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/runtime/README.md
README.md: 0ae71ff17b13be67c16786ff69a0e1626437913a
README.zh.md: 52a443d9df753ba01650b6cbf189c39633f6a461
README.md: ffee75c0e28cd39c4b80a2f93a2496f2a8a22092
README.zh.md: 85511bc191d64cb4954515a2ba1efdf912c3f937
+4
View File
@@ -54,6 +54,10 @@ The Session object validates plugin-owned, provider-routed `llm/retry` payloads
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
## Addressed subagent conversations
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh. Catalog reads are single-flight; `host/session-status` flips a listed child's coarse activity in place, while `host/session-added` causes one debounced refetch only while that parent catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
## Model Experience
None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content.
+4
View File
@@ -54,6 +54,10 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle``loading``ready``selecting``error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。
## 已寻址的 subagent 对话
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间把地址与所选会话一同持久化。目录读取为 single-flight`host/session-status` 就地翻转已列 child 的粗粒度活跃状态,`host/session-added` 则只在对应 parent 目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
## 模型体验
无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
@@ -8,7 +8,9 @@
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from 'cordis'
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {
RpcResult, SessionId, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionSearchResultItem } from '../sessions/manager.ts'
import type {
@@ -34,6 +36,12 @@ export interface ISessions {
* @param id - session id (must exist in the list; unknown ids fail loud).
*/
open(id: SessionId): void
/**
* Resolve an already discovered direct-parent address without opening it.
* @param id - possible addressed child id.
* @returns the retained address, when present.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined
/** Clear the current selection into the no-session view state. */
clear(): void
/**
+2 -1
View File
@@ -31,7 +31,8 @@ export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {
@@ -8,7 +8,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView,
InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
@@ -335,6 +335,11 @@ export interface ConversationSnapshot {
/** Authoritative transient inbox snapshot, replaced after every host-side change. */
queue: readonly QueuedMessage[]
running: boolean
/**
* Catalog-discovered continuation address. Its parent availability controls
* human input; null means ordinary session transport.
*/
subagent: { address: SubagentAddress; parentAvailable: boolean } | null
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
composerPhase: ComposerPhase
/** Set after host/session-removed; the UI grays out and disables input. */
@@ -4,7 +4,7 @@
import type {
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
SessionSummary, WorkspaceId,
SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -45,6 +45,14 @@ export interface SessionListSnapshot {
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
phase: SessionListPhase
error: RpcError | null
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
currentAddress: SubagentAddress | undefined
}
/** One parent-addressed durable catalog projected through the sessions snapshot. */
export interface SubagentCatalogSnapshot extends SubagentCatalog {
state: 'loading' | 'ready' | 'error'
error: RpcError | null
}
type SessionListMutation =
@@ -84,6 +92,11 @@ export class SessionManager {
private listInflight: Promise<void> | null = null
/** Mutations arriving after a list request starts are replayed over its response. */
private listMutations: SessionListMutation[] | null = null
private readonly addresses = new Map<SessionId, SubagentAddress>()
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
private readonly catalogInflight = new Map<SessionId, Promise<void>>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
private selected: SessionId | undefined
@@ -104,8 +117,10 @@ export class SessionManager {
constructor(
private readonly api: IApiClient,
restoredSelection?: SessionId,
restoredAddress?: SubagentAddress,
) {
this.selected = restoredSelection
if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress)
this.listSnapshotCache = this.buildListSnapshot()
}
@@ -119,7 +134,27 @@ export class SessionManager {
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
throw new Error(`sessions.select: unknown session ${sessionId}`)
}
this.addresses.delete(sessionId)
this.sessions.get(sessionId)?.configureSubagent(undefined)
this.selected = sessionId
void this.refreshSubagents(sessionId)
this.notifier.notifyNow()
}
/**
* Select a healthy child through its durable direct-parent address.
* @param address - catalog-derived parent and child ids.
*/
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') {
throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`)
}
this.addresses.set(address.childSessionId, address)
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
this.selected = address.childSessionId
void this.refreshSubagents(address.childSessionId)
this.notifier.notifyNow()
}
@@ -129,6 +164,15 @@ export class SessionManager {
this.notifier.notifyNow()
}
/**
* Return the durable catalog address retained for one child.
* @param sessionId - possible addressed child id.
* @returns The direct-parent address, when navigation discovered one.
*/
subagentAddress(sessionId: SessionId): SubagentAddress | undefined {
return this.addresses.get(sessionId)
}
// ---- Instance management ----
/**
@@ -168,13 +212,23 @@ export class SessionManager {
if (summary !== undefined) {
session.handleBlank(summary.blank)
session.handleRunning(summary.running)
} else {
const address = this.addresses.get(sessionId)
const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries
.find(entry => entry.kind === 'child' && entry.id === sessionId)
if (child?.kind === 'child') session.handleRunning(child.activity === 'running')
}
}
return session
}
private createSession(sessionId: SessionId): Session {
const address = this.addresses.get(sessionId)
return new Session(sessionId, this.api, {
...(address === undefined ? {} : {
address,
parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
}),
// The sender's local first-send flip mirrors into the list row so the
// session surfaces (lists filter on blank) before any host frame lands.
onEngaged: (engaged) => {
@@ -197,6 +251,78 @@ export class SessionManager {
return store
}
/**
* Refresh one direct-child catalog, reusing its in-flight request.
* @param parentSessionId - catalog owner.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void> {
const existing = this.catalogInflight.get(parentSessionId)
if (existing !== undefined) return existing
const previous = this.catalogs.get(parentSessionId)
this.catalogs.set(parentSessionId, {
entries: previous?.entries ?? [],
parentAvailable: previous?.parentAvailable ?? false,
state: 'loading',
error: null,
})
this.notifier.markDirty()
const operation = (async () => {
try {
const { result } = await this.api.subagents.list({ parentSessionId })
if (result.ok) {
this.catalogs.set(parentSessionId, {
...result.value,
state: 'ready',
error: null,
})
for (const [childId, address] of this.addresses) {
if (address.parentSessionId !== parentSessionId) continue
this.sessions.get(childId)?.handleSubagentParentAvailable(result.value.parentAvailable)
}
} else {
this.catalogs.set(parentSessionId, {
entries: previous?.entries ?? [],
parentAvailable: previous?.parentAvailable ?? false,
state: 'error',
error: result.error,
})
}
} catch (error: unknown) {
const folded = transportError<never>(error)
this.catalogs.set(parentSessionId, {
entries: previous?.entries ?? [],
parentAvailable: previous?.parentAvailable ?? false,
state: 'error',
error: folded.ok ? null : folded.error,
})
} finally {
this.catalogInflight.delete(parentSessionId)
this.notifier.markDirty()
}
})()
this.catalogInflight.set(parentSessionId, operation)
return operation
}
/**
* Mark whether a catalog menu is consuming live membership updates.
* @param parentSessionId - catalog owner.
* @param open - current menu state.
*/
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
if (open) {
this.openCatalogs.add(parentSessionId)
void this.refreshSubagents(parentSessionId)
} else {
this.openCatalogs.delete(parentSessionId)
const timer = this.catalogDebounce.get(parentSessionId)
if (timer !== undefined) {
clearTimeout(timer)
this.catalogDebounce.delete(parentSessionId)
}
}
}
// ---- List surface ----
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
@@ -484,11 +610,23 @@ export class SessionManager {
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
})
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
if (frame.parentSessionId !== undefined
&& (this.selected === frame.parentSessionId || this.openCatalogs.has(frame.parentSessionId))) {
this.scheduleCatalogRefresh(frame.parentSessionId)
}
return
}
case 'host/session-removed': {
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
if (this.addresses.has(frame.sessionId)) {
// A continuable 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)
this.updateCatalogActivity(frame.sessionId, false)
} else {
this.sessions.get(frame.sessionId)?.handleRemoved()
}
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
@@ -497,6 +635,7 @@ export class SessionManager {
case 'host/session-status': {
this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running })
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
this.updateCatalogActivity(frame.sessionId, frame.running)
return
}
case 'host/agent-error': {
@@ -535,9 +674,40 @@ export class SessionManager {
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
handleConnected(): void {
void this.refreshList()
const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected)
if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId)
if (this.selected !== undefined) void this.refreshSubagents(this.selected)
for (const parentSessionId of this.openCatalogs) void this.refreshSubagents(parentSessionId)
for (const session of this.sessions.values()) void session.resync()
}
/** Debounce membership refetches while one parent catalog is open. */
private scheduleCatalogRefresh(parentSessionId: SessionId): void {
if (this.catalogDebounce.has(parentSessionId)) return
const timer = setTimeout(() => {
this.catalogDebounce.delete(parentSessionId)
void this.refreshSubagents(parentSessionId)
}, 50)
this.catalogDebounce.set(parentSessionId, timer)
}
/** Flip a listed child's coarse activity in place from the shared Host frame. */
private updateCatalogActivity(childSessionId: SessionId, running: boolean): void {
let changed = false
for (const [parentSessionId, catalog] of this.catalogs) {
const activity = running ? 'running' as const : 'inactive' as const
if (!catalog.entries.some(entry =>
entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity)) continue
const entries = catalog.entries.map((entry) => {
if (entry.kind !== 'child' || entry.id !== childSessionId) return entry
return { ...entry, activity }
})
changed = true
this.catalogs.set(parentSessionId, { ...catalog, entries })
}
if (changed) this.notifier.markDirty()
}
private buildListSnapshot(): SessionListSnapshot {
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
// List rows read the generic 'title' projection key (host-computed unit
@@ -566,7 +736,8 @@ export class SessionManager {
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
if (!sameOrder) this.itemsCache = items
const selected = this.selected
const current = selected !== undefined && items.some(item => item.sessionId === selected)
const current = selected !== undefined
&& (items.some(item => item.sessionId === selected) || this.addresses.has(selected))
? selected
: undefined
return {
@@ -575,6 +746,8 @@ export class SessionManager {
state: this.listState,
phase: this.listPhase,
error: this.listError,
subagentsByParent: Object.fromEntries(this.catalogs),
currentAddress: current === undefined ? undefined : this.addresses.get(current),
}
}
}
@@ -17,7 +17,7 @@
*/
import type { Context, Fiber } from 'cordis'
import type {
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -31,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
import type { ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
import { SessionProvideChannel } from './provide.ts'
import type { Session } from './session.ts'
@@ -68,6 +68,16 @@ export interface SessionListState {
current: SessionId | undefined
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
phase: SessionListPhase
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/** Current session's catalog-derived address, absent on ordinary navigation. */
currentAddress: SubagentAddress | undefined
}
/** Persisted navigation cell: address survives refresh for correct history routing. */
interface SessionSelection {
sessionId?: SessionId
subagentAddress?: SubagentAddress
}
/** Structured session-create failure. */
@@ -221,7 +231,7 @@ export class SessionsService implements ISessions {
* selection survives transient list states (reconnect re-pull) and
* resurfaces when its session returns.
*/
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly selection: SnapshotStore<SessionSelection>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */
@@ -244,12 +254,14 @@ export class SessionsService implements ISessions {
private readonly rootCtx: Context,
api: IApiClient,
) {
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
this.selection = createSnapshotStore<SessionSelection>(
{},
{ persist: { name: 'dsh.sessions.current' } })
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
const restored = this.selection.getSnapshot()
this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, currentAddress: undefined,
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
@@ -303,6 +315,41 @@ export class SessionsService implements ISessions {
this.manager.select(id)
}
/**
* Open a healthy catalog child through its direct-parent address.
* @param address - catalog-derived parent and child ids.
*/
openSubagent(address: SubagentAddress): void {
this.manager.selectSubagent(address)
}
/**
* Resolve an already discovered direct-parent address without opening it.
* Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
* @param id - possible addressed child id.
* @returns The retained address, when present.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined {
return this.manager.subagentAddress(id)
}
/**
* Inform the runtime whether a catalog menu is consuming membership updates.
* @param parentSessionId - selected parent.
* @param open - menu state.
*/
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
this.manager.setSubagentCatalogOpen(parentSessionId, open)
}
/**
* Refresh one direct-child catalog.
* @param parentSessionId - catalog owner.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void> {
return this.manager.refreshSubagents(parentSessionId)
}
/**
* Clear the current selection so the layout shows the no-session empty
* state (new-session affordance and the workspace preselection flow).
@@ -509,6 +556,7 @@ export class SessionsService implements ISessions {
* cannot miss; kept so a future current writer cannot crash the notify. */
if (record !== undefined) {
void record.session.open()
void this.manager.refreshSubagents(current)
}
}
@@ -566,7 +614,9 @@ export class SessionsService implements ISessions {
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const { items, current, phase } = this.manager.getListSnapshot()
const {
items, current, phase, subagentsByParent, currentAddress,
} = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
for (const entry of items) {
@@ -583,15 +633,36 @@ export class SessionsService implements ISessions {
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
if (current !== undefined && currentAddress !== undefined && byId[current] === undefined) {
const child = subagentsByParent[currentAddress.parentSessionId]?.entries
.find(entry => entry.kind === 'child' && entry.id === current)
if (child?.kind === 'child') {
byId[current] = {
id: current,
displayTitle: child.label,
parentId: currentAddress.parentSessionId,
running: child.activity === 'running',
waitingApproval: false,
blank: false,
updatedAt: 0,
}
}
}
const persisted = this.selection.getSnapshot().sessionId
// No current (cleared, or masked gap) wipes the persisted cell — a reload
// stays on empty; the in-memory selection still resurfaces a masked id.
if (current === undefined) {
if (persisted !== undefined) this.selection.set({})
} else if (byId[current] !== undefined && persisted !== current) {
this.selection.set({ sessionId: current })
} else if (byId[current] !== undefined
&& (persisted !== current
|| this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId)) {
this.selection.set({
sessionId: current,
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
})
}
this.list.set({ ids, byId, current, phase })
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
this.pruneScopes(byId)
}
@@ -6,7 +6,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
RpcId, RpcResult, SessionId, ToolEventView,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -34,6 +34,10 @@ const MAX_RETRY_DELAY_MS = 2_147_483_647
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
address?: SubagentAddress
/** Whether the exact direct parent Agent was live at the latest catalog read. */
parentAvailable?: boolean
/**
* First ACCEPTED prompt on a blank session (fires at most once, on the
* prompt RPC's success response): the manager mirrors the blank→false flip
@@ -119,6 +123,8 @@ export class Session implements SessionFace {
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
private running = false
private address: SubagentAddress | undefined
private parentAvailable = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
* synchronously before prompt()'s first await, never reset — the blank →
@@ -174,6 +180,8 @@ export class Session implements SessionFace {
private readonly options: SessionOptions = {},
) {
this.projections = options.projections ?? new ProjectionValueStore()
this.address = options.address
this.parentAvailable = options.parentAvailable ?? false
this.snapshotCache = this.buildSnapshot()
}
@@ -213,7 +221,12 @@ export class Session implements SessionFace {
this.notifier.markDirty()
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
if (this.address === undefined) {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
} else {
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
} catch (error) {
result = transportError(error)
}
@@ -253,6 +266,19 @@ export class Session implements SessionFace {
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
if (this.address !== undefined) {
const result: RpcResult<{ accepted: true }> = {
ok: false,
error: {
code: 'subagent-not-delivered',
message: 'subagent activation cancellation is unavailable',
details: { childSessionId: this.address.childSessionId },
},
}
this.promptError = { op: 'stop', error: result.error }
this.notifier.markDirty()
return result
}
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
@@ -318,9 +344,7 @@ export class Session implements SessionFace {
this.loadingOlder = true
this.notifier.markDirty()
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
})
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {
@@ -479,6 +503,31 @@ export class Session implements SessionFace {
this.notifier.markDirty()
}
/**
* Install or clear the catalog-discovered transport address. A changed
* address rebuilds an already-open window through its new history route.
* @param address - direct parent/child address, or undefined for ordinary transport.
* @param parentAvailable - latest exact-parent availability hint.
*/
configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
const same = this.address?.parentSessionId === address?.parentSessionId
&& this.address?.childSessionId === address?.childSessionId
this.address = address
this.parentAvailable = parentAvailable
if (!same && this.openState !== 'cold') void this.resync()
else this.notifier.markDirty()
}
/**
* Update only the parent availability hint from a catalog refresh.
* @param available - whether the exact direct parent is live.
*/
handleSubagentParentAvailable(available: boolean): void {
if (this.parentAvailable === available) return
this.parentAvailable = available
this.notifier.markDirty()
}
/**
* Blank-bit relay from the authoritative summary source (list baseline and
* the session-added frame). Monotone: once any signal (local first send,
@@ -533,7 +582,7 @@ export class Session implements SessionFace {
this.openError = null
this.notifier.markDirty()
try {
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
let { result } = await this.history({ maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
if (!result.ok) {
this.openState = 'error'
@@ -544,7 +593,7 @@ export class Session implements SessionFace {
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
result = (await this.history({ maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
@@ -621,7 +670,7 @@ export class Session implements SessionFace {
this.stitching = true
const generation = this.openGeneration
try {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
const { result } = await this.history({ maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
@@ -888,6 +937,9 @@ export class Session implements SessionFace {
codeDispatches: this.dispatchesCache.value,
queue: this.queueCache.value,
running: this.running,
subagent: this.address === undefined
? null
: { address: this.address, parentAvailable: this.parentAvailable },
composerPhase: derivePhase(
// Command lifecycle nodes are not conversation: running /permission
// or /plan on a fresh session keeps the hero (the client mirror of
@@ -905,6 +957,17 @@ export class Session implements SessionFace {
lastAgentError: this.lastAgentError,
}
}
/** Select ordinary or addressed history transport from the stored browser fact. */
private history(payload: { beforeSeq?: number; maxMessages?: number }): Promise<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: ProjectionsBaseline
}>> {
return this.address === undefined
? this.api.sessions.history({ sessionId: this.sessionId, ...payload })
: this.api.subagents.history({ ...this.address, ...payload })
}
}
/** Validate the plugin-owned payload at the session-event wire boundary. */
+13
View File
@@ -132,6 +132,19 @@ export class FakeApiClient implements IApiClient {
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
onSubagentHistory: (payload: unknown) => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>>
= () => Promise.resolve(ok({ events: [], hasMore: false }))
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
}
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
@@ -272,6 +272,87 @@ describe('host frame routing', () => {
})
})
describe('subagent catalogs', () => {
it('selects only a catalog-discovered child and keeps its durable address across status frames', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [
summary(S1),
] as never[] }))
api.onSubagentList = () => Promise.resolve(ok({
entries: [{ kind: 'child', id: S2, 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 })
expect(manager.getListSnapshot().currentAddress).toEqual({
parentSessionId: S1, childSessionId: S2,
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address: { parentSessionId: S1, childSessionId: S2 },
parentAvailable: true,
})
const listCalls = api.callsOf('subagent.list').length
manager.handleHostEnvelope({
rpcId: 'child-complete' as never,
payload: { type: 'host/session-status', sessionId: S2, running: false },
})
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
kind: 'child', id: S2, activity: 'inactive',
})
expect(api.callsOf('subagent.list')).toHaveLength(listCalls)
manager.handleHostEnvelope({
rpcId: 'child-detached' as never,
payload: { type: 'host/session-removed', sessionId: S2 },
})
expect(manager.get(S2).getSnapshot()).toMatchObject({
removed: false,
subagent: { address: { parentSessionId: S1, childSessionId: S2 } },
})
})
it('refetches debounced membership only while the parent catalog is open', async () => {
vi.useFakeTimers()
try {
const api = new FakeApiClient()
const manager = new SessionManager(api)
await manager.refreshSubagents(S1)
manager.setSubagentCatalogOpen(S1, true)
await Promise.resolve()
const baseline = api.callsOf('subagent.list').length
manager.handleHostEnvelope({
rpcId: 'child-added' as never,
payload: {
type: 'host/session-added', sessionId: S2, parentSessionId: S1, blank: false,
},
})
manager.handleHostEnvelope({
rpcId: 'child-added-again' as never,
payload: {
type: 'host/session-added', sessionId: 'fk-m3' as SessionId, parentSessionId: S1, blank: false,
},
})
await vi.advanceTimersByTimeAsync(50)
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
manager.setSubagentCatalogOpen(S1, false)
manager.handleHostEnvelope({
rpcId: 'child-added-closed' as never,
payload: {
type: 'host/session-added', sessionId: 'fk-m4' as SessionId, parentSessionId: S1, blank: false,
},
})
await vi.advanceTimersByTimeAsync(50)
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
} finally {
vi.useRealTimers()
}
})
})
describe('remaining branches', () => {
it('refreshList folds a transport throw into the error state', async () => {
const api = new FakeApiClient()
@@ -435,6 +516,19 @@ describe('connected generation', () => {
expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1)
})
})
it('reloads the durable parent address for a restored child selection', async () => {
const api = new FakeApiClient()
const address = { parentSessionId: S1, childSessionId: S2 }
const manager = new SessionManager(api, S2, address)
manager.handleConnected()
await vi.waitFor(() => {
expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 })
})
expect(manager.getListSnapshot().currentAddress).toEqual(address)
})
})
describe('waiting-approval list bit', () => {
@@ -18,6 +18,7 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
const SID = 'fk-s1' as SessionId
const PARENT = 'fk-parent' as SessionId
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, api) }
@@ -580,6 +581,33 @@ describe('paging', () => {
})
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 },
parentAvailable: true,
})
await session.open()
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
const cancelled = await session.cancel()
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-not-delivered' } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, 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 },
parentAvailable: true,
})
})
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
@@ -52,6 +52,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
pending: [],
queue: [],
running: false,
subagent: null,
composerPhase: 'active',
removed: false,
openState: 'open',
@@ -187,6 +187,7 @@ export class TestSessions implements ISessions {
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, currentAddress: undefined,
})
this.channel = new SessionProvideChannel({
rebuildBundles: () => {
@@ -395,6 +396,11 @@ export class TestSessions implements ISessions {
this.list.update((draft) => { draft.current = id })
}
/** Test fixtures do not synthesize catalog addresses. */
subagentAddress(_id: SessionId): undefined {
return undefined
}
/** Clear the current selection (recorded; the production no-session flow). */
clear(): void {
this.calls.push({ method: 'clear', args: [] })
+2 -2
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-command/README.md
README.md: 64d06f1d9baae98ef31c7e2a62242eda6a8174da
README.zh.md: 0cec3b8c8f7baf2fb1c408bccfe8937aa78e4bf9
README.md: c892f2f244d7924014ad1b4d6e9fe16ff4e044e4
README.zh.md: ed607de783e833eed94fba09bc20c74375711a4f
+1 -1
View File
@@ -6,7 +6,7 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach
`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
`PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`.
+1 -1
View File
@@ -6,7 +6,7 @@
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)``decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claimspace / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key:每个会话恒为 agent-backed,因此 `command.list({sessionId})` 是唯一的寻址形状source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
`CommandDirectory``src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
`PopupSelectController``src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。
@@ -43,6 +43,7 @@ export class CommandService extends Service implements CommandServiceContract {
const connection = ctx.get('connection') as ConnectionHandle | undefined
if (connection === undefined) throw new Error('ui-command: connection service unavailable')
this.directory = new CommandDirectory(async (sessionId) => {
if (this.sessions().subagentAddress(sessionId) !== undefined) return []
const { result } = await connection.api.commands.list({ sessionId })
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
return result.value.commands
@@ -37,6 +37,7 @@ interface BenchOptions {
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }>
execute?: (payload: { sessionId: SessionId; line: string }) => Promise<ExecuteValue>
addressed?: SessionId
}
async function bench(opts: BenchOptions = {}) {
@@ -67,11 +68,14 @@ async function bench(opts: BenchOptions = {}) {
return () => { registered.delete(key) }
},
})
// Real scope tags behind a fake sessions face (scope/scopeOf are all the service reads).
// Real scope tags behind a fake sessions face.
const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
ctx.provide('sessions', {
scope: (id: SessionId) => scopes.get(id)?.ctx,
scopeOf: (c: Context) => scopeOf(c),
subagentAddress: (id: SessionId) => id === opts.addressed
? { parentSessionId: sid('parent'), childSessionId: id }
: undefined,
})
ctx.provide('connection', { api })
/** Notices the fake conversation face collected (runDetached routing). */
@@ -154,6 +158,12 @@ describe('registration', () => {
})
describe('candidates', () => {
it('does not fetch Agent-bound commands for an addressed child', async () => {
const b = await bench({ addressed: sid('child') })
await expect(b.warm(proj('child'))).resolves.toBeUndefined()
expect(b.listCalls).toEqual([])
})
it('pulls the session catalog; prefix filter and hint mapping apply', async () => {
const { source, listCalls } = await bench()
const list = await source.candidates(proj('s1'), req('g'))
+2 -2
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-skill/README.md
README.md: 2cb382f53466c07b977eef4d5a1ef2804c13abea
README.zh.md: 2fc30da5e4c895027ab9dea78e9e3f86890cafc1
README.md: fc83ae47dc83e72d60f382892aa678989902d217
README.zh.md: 60f2c258acdb7e19148e05f19061e0e3f2c28ee7
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path lets a user insert a model reference rather than loading the body directly. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText``/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path inserts a model reference rather than loading the body directly. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText``/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent(智能体)支撑host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径让用户插入模型引用,而不是直接加载正文。目录按会话缓存,拉取走 single-flightscope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText``/name``serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace``matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续子代理在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flightscope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText``/name``serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace``matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
`skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。
+5 -3
View File
@@ -21,7 +21,7 @@
* with an aborted signal just returns early.
*/
import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
/** One session's catalog fetch: the shared promise plus its own abort handle. */
@@ -32,8 +32,8 @@ interface CatalogFetch {
settled?: readonly SkillEntry[]
}
/** Required services: the slash registry + the wire face the source closes over. */
export const inject = ['slash', 'connection']
/** Required services: slash registry, routed sessions, and the wire face. */
export const inject = ['slash', 'connection', 'sessions']
/**
* Client plugin body: register the '/' skill source over the root wire face.
@@ -41,6 +41,7 @@ export const inject = ['slash', 'connection']
*/
export function apply(ctx: ClientContext): void {
const skills = (ctx.get('connection') as ConnectionHandle).api.skills
const sessions = ctx.get('sessions') as ISessions
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
// the fiber effect below is its teardown boundary.
const fetches = new Map<SessionId, CatalogFetch>()
@@ -61,6 +62,7 @@ export function apply(ctx: ClientContext): void {
}
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
if (sessions.subagentAddress(sessionId) !== undefined) return Promise.resolve([])
const existing = fetches.get(sessionId)
if (existing !== undefined) return existing.promise
const abort = new AbortController()
@@ -24,11 +24,16 @@ type ListResult =
type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }>
/** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */
async function bench(list: ListFn) {
async function bench(list: ListFn, addressed?: SessionId) {
const ctx = new Context()
let captured: SlashSource | undefined
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
ctx.provide('connection', { api: { skills: { list } } })
ctx.provide('sessions', {
subagentAddress: (id: SessionId) => id === addressed
? { parentSessionId: sid('parent'), childSessionId: id }
: undefined,
})
await ctx.plugin({ inject: [...inject], apply }).await()
return { ctx, source: captured! }
}
@@ -60,7 +65,7 @@ const req = (query: string, signal?: AbortSignal) =>
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'connection'])
expect(inject).toEqual(['slash', 'connection', 'sessions'])
})
it('registers the "/" skill source; disposal frees the name (HMR safety)', async () => {
@@ -106,6 +111,14 @@ describe('candidates: sessionId addressing', () => {
await expect(source.candidates(proj('s1'), req('co')))
.rejects.toThrow('skill.list failed: internal: boom')
})
it('does not fetch Agent-bound skills for an addressed child', async () => {
const { list, payloads } = countingList()
const { source } = await bench(list, sid('child'))
await expect(source.candidates(proj('child'), req(''))).resolves.toEqual([])
source.warm!(proj('child'))
expect(payloads).toEqual([])
})
})
describe('catalog cache', () => {
@@ -22,7 +22,7 @@ const COPY: Record<string, string> = {
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
@@ -112,7 +112,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
@@ -17,7 +17,7 @@ const list = (...items: SessionSummary[]): SessionListState => ({
ids: items.map(item => item.id),
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready',
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
})
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title,
@@ -29,6 +29,8 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial<Sessi
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
...overrides,
})
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
@@ -28,7 +28,7 @@ function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
const sessions: SessionListState = {
ids: [], byId: {}, current: undefined, phase: 'ready',
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
}
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
+2 -2
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/host/apiproxy/README.md
README.md: 3c5a83a468b0cf9e596b8b13fafe40c409576fc5
README.zh.md: f8533564575bf6b716f3fa7241ce47b8d4dd435f
README.md: abca1e9b209891b152ff7f1a58fadb9f91e65839
README.zh.md: dca3924af940a99fa99afb283f37a84570d1ad90
+2
View File
@@ -36,6 +36,8 @@ The `command.*` and `skill.*` domains expose the host command registry and skill
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
The `subagent.*` domain addresses continuable direct children by `{parentSessionId, childSessionId}`. `subagent.list` projects the durable continuable catalog plus an exact-live-parent hint from `ctx.subagents.listChildren`, excluding one-shot children; `subagent.history` verifies a healthy direct-child entry and reads its persisted log through `ctx.sessionQuery` without resuming an Agent. `subagent.prompt` requires that exact live parent, delivers human content through `ctx.subagents.followup()` with the request `rpcId` as attribution, and returns the accepted inbox `messageId`. Typed errors preserve catalog diagnostics, parent availability, resumability, authorization, and not-delivered distinctions without exposing the model-hidden continuation descriptor. See the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
## Carrier layer (`/client` + root)
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
+2
View File
@@ -36,6 +36,8 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision``settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}``settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission``ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址可继续的直接 child。`subagent.list``ctx.subagents.listChildren` 投影持久化的可继续目录及确切 parent 是否存活的提示,并排除 one-shot child`subagent.history` 先验证健康的直接 child 条目,再通过 `ctx.sessionQuery` 读取其持久化日志,且不恢复 Agent。`subagent.prompt` 要求该确切 parent 已存活,通过 `ctx.subagents.followup()` 投递用户内容,以请求 `rpcId` 作为来源信息,并返回已接纳消息的 inbox `messageId`。类型化错误保留目录诊断、parent 可用性、可恢复性、授权和未投递等区别,同时不暴露对模型隐藏的继续执行描述符。见 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)。
## 载体层(`/client` + 根路径)
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient``toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
+1
View File
@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
+187 -10
View File
@@ -28,7 +28,8 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
SessionSummary, SettingsNamespaceView, ToolEventView, WorkspaceId, WorkspaceView,
SessionSummary, SettingsNamespaceView, SubagentListEntry as SubagentCatalogEntry, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import {
SESSION_SEARCH_RESULT_LIMIT,
@@ -68,6 +69,8 @@ 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
@@ -462,6 +465,109 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
return undefined
}
/** Render one detached history page through the same presenter path as ordinary history. */
function historyPage(
ctx: Context,
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number | undefined,
): { events: HistoryEntry[]; hasMore: boolean } {
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
return {
events: page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
}),
hasMore: page.hasMore,
}
}
/** 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
@@ -1390,19 +1496,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if ('error' in found) return err(request, found.error)
// Everything below the resume above is synchronous: the page slice,
// the seq read, and the projection walk see one un-torn session state.
const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
// Views are computed against the registry at pagination time; result
// pairing scans within the page only (message-boundary pagination keeps
// a call and its result on one page — a cross-page miss soft-falls).
const entries: HistoryEntry[] = page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
})
const page = historyPage(ctx, found.agent.session.events, beforeSeq, maxMessages)
// Baseline rider: tail page only — loadOlder (beforeSeq present) is
// the one path that never needs a fresh projection baseline.
const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
return ok(request, {
events: entries,
events: page.events,
hasMore: page.hasMore,
...projections === undefined ? {} : { projections },
})
@@ -1592,6 +1691,84 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
},
subagents: {
async list(request, signal) {
try {
const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal)
return ok(request, {
entries: continuableCatalog(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: {} })
}
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)
if (verified.error !== undefined) return err(request, verified.error)
try {
const snapshot = await ctx.sessionQuery.readSession(childSessionId)
signal?.throwIfAborted()
if (snapshot.session.parentSession !== parentSessionId) {
return err(request, {
code: 'subagent-unauthorized',
message: 'subagent parent changed during history read',
details: { childSessionId },
})
}
return ok(request, historyPage(ctx, snapshot.events, beforeSeq, maxMessages))
} catch (error: unknown) {
if (signal?.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 },
})
}
}
return err(request, { code: 'internal', message: 'subagent history read failed', details: {} })
}
},
async prompt(request, signal) {
const { parentSessionId, childSessionId, content } = request.payload
const parent = ctx.agents.get(parentSessionId)
if (parent === undefined) {
return err(request, {
code: 'subagent-parent-unavailable',
message: `parent session "${parentSessionId}" is not live`,
details: { parentSessionId },
})
}
const verified = await healthyCatalogChild(ctx, parentSessionId, childSessionId, 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 },
)
return ok(request, { messageId })
} catch (error: unknown) {
return subagentPromptError(request, error, operationSignal)
}
},
},
workspace: {
list(request) {
return Promise.resolve(ok(request, {
+5
View File
@@ -9,6 +9,7 @@ import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { SkillsApi } from './skills.ts'
import type { SubagentsApi } from './subagents.ts'
import type { EventsApi } from './events.ts'
import type { GoalsApi } from './goals.ts'
import type { SettingsApi } from './settings.ts'
@@ -19,6 +20,7 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts'
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
export interface ApiProxy {
sessions: SessionsApi
subagents: SubagentsApi
host: HostApi
workspace: WorkspaceApi
commands: CommandsApi
@@ -39,6 +41,9 @@ export type {
SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
} from './subagents.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
@@ -13,6 +13,7 @@ import type { GoalsApi } from './goals.ts'
import type { SettingsApi } from './settings.ts'
import type { CredentialsApi } from './credentials.ts'
import type { LlmApi } from './llm.ts'
import type { SubagentsApi } from './subagents.ts'
import type { RpcResponse } from './rpc.ts'
/**
@@ -32,6 +33,9 @@ export interface RpcMethodMap {
'session.prompt': SessionsApi['prompt']
'session.updateQueue': SessionsApi['updateQueue']
'session.cancel': SessionsApi['cancel']
'subagent.list': SubagentsApi['list']
'subagent.history': SubagentsApi['history']
'subagent.prompt': SubagentsApi['prompt']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']
@@ -56,6 +56,16 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }),
z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({
parentSessionId: z.string(),
childSessionId: z.string(),
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
}) }),
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('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>
+10
View File
@@ -71,6 +71,16 @@ export interface RpcErrorDetailsMap {
'credential-rejected': { ref: string }
'title-invalid': { sessionId: SessionId }
'fork-unavailable': { sessionId: SessionId }
'subagent-parent-unavailable': { parentSessionId: SessionId }
'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId }
'subagent-catalog-diagnostic': {
parentSessionId: SessionId
childSessionId: SessionId
reason: 'corrupt' | 'unsupported' | 'unavailable'
}
'subagent-not-resumable': { childSessionId: SessionId }
'subagent-unauthorized': { childSessionId: SessionId }
'subagent-not-delivered': { childSessionId: SessionId }
'internal': {}
}
@@ -194,10 +194,10 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [
]) as unknown as z.ZodType<ToolEventView>
/** One session.history item: the session event plus its optional host-computed tool view. */
export const historyEntrySchema = z.object({
export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({
event: sessionEventSchema,
view: toolEventViewSchema.optional(),
}) satisfies z.ZodType<Wire<HistoryEntry>>
}) as unknown as z.ZodType<Wire<HistoryEntry>>
/**
* Projection baseline passthrough: `values` stays a wide record — each value
@@ -211,11 +211,11 @@ export const sessionProjectionsBlockSchema = z.object({
}) as unknown as z.ZodType<SessionProjectionsBlock>
/** session.history response value (projections rides the tail page only). */
export const sessionHistoryValueSchema = z.object({
export const sessionHistoryValueSchema: z.ZodType<Wire<ResponseValue<'session.history'>>> = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
projections: sessionProjectionsBlockSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
})
/** session.models request payload. */
export const sessionModelsRequestSchema = z.object({
@@ -0,0 +1,62 @@
/** Zod schemas for the browser-safe subagent domain. */
import { z } from 'zod'
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 type { SubagentListEntry } from './subagents.ts'
/** Healthy and diagnostic durable catalog rows. */
export const subagentListEntrySchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('child'),
id: sessionIdSchema,
label: z.string(),
activity: z.union([z.literal('running'), z.literal('inactive')]),
}),
z.object({
kind: z.literal('diagnostic'),
id: sessionIdSchema,
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
}),
]) satisfies z.ZodType<Wire<SubagentListEntry>>
/** subagent.list request payload. */
export const subagentListRequestSchema = z.object({
parentSessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.list'>>>
/** subagent.list response value. */
export const subagentListValueSchema = z.object({
entries: z.array(subagentListEntrySchema),
parentAvailable: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.list'>>>
/** subagent.history request payload. */
export const subagentHistoryRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
beforeSeq: z.number().int().nonnegative().optional(),
maxMessages: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.history'>>>
/** subagent.history response value. */
export const subagentHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
}) as unknown as z.ZodType<Wire<ResponseValue<'subagent.history'>>>
/** subagent.prompt request payload. */
export const subagentPromptRequestSchema = z.object({
parentSessionId: sessionIdSchema,
childSessionId: sessionIdSchema,
content: z.array(contentBlockSchema),
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
/** subagent.prompt response value. */
export const subagentPromptValueSchema = z.object({
messageId: messageIdSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.prompt'>>>
@@ -0,0 +1,72 @@
/**
* Browser-safe subagent domain contract. Persisted transcript reads never
* activate an Agent, while prompts route through the direct parent's
* Activation-backed continuation owner.
*/
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'
/** Complete durable direct-child catalog row. */
export type SubagentListEntry =
| {
kind: 'child'
id: SessionId
label: string
activity: 'running' | 'inactive'
}
| {
kind: 'diagnostic'
id: SessionId
reason: 'corrupt' | 'unsupported' | 'unavailable'
}
/** Inbox identity returned once the continuation accepts one human message. */
export interface SubagentPromptReceipt {
messageId: MessageId
}
/** Durable parent/child address that selects subagent transport in the client. */
export interface SubagentAddress {
parentSessionId: SessionId
childSessionId: SessionId
}
/** Complete direct-child catalog plus the delivery-time parent availability hint. */
export interface SubagentCatalog {
entries: SubagentListEntry[]
parentAvailable: boolean
}
/** 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.
*/
list(
request: RpcRequest<{ parentSessionId: SessionId }>,
signal?: AbortSignal,
): Promise<RpcResponse<SubagentCatalog>>
/**
* Reads one healthy catalog child's persisted raw log with ordinary
* message-aligned pagination and render intents, without Agent activation.
*/
history(
request: RpcRequest<SubagentAddress & { beforeSeq?: number; maxMessages?: number }>,
signal?: AbortSignal,
): Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
/**
* Delivers human content through the exact live parent's continuation
* owner. Success identifies the accepted inbox message.
*/
prompt(
request: RpcRequest<SubagentAddress & { content: ContentBlock[] }>,
signal?: AbortSignal,
): Promise<RpcResponse<SubagentPromptReceipt>>
}
@@ -55,6 +55,11 @@ import {
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
} from '../api/credentials.schema.ts'
import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
import {
subagentHistoryValueSchema,
subagentListValueSchema,
subagentPromptValueSchema,
} from '../api/subagents.schema.ts'
/**
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
@@ -86,6 +91,11 @@ export interface IApiClient {
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
}
subagents: {
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
@@ -155,6 +165,9 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.prompt': sessionPromptValueSchema,
'session.updateQueue': sessionUpdateQueueValueSchema,
'session.cancel': sessionCancelValueSchema,
'subagent.list': subagentListValueSchema,
'subagent.history': subagentHistoryValueSchema,
'subagent.prompt': subagentPromptValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
@@ -385,6 +398,12 @@ export abstract class AbstractApiClient implements IApiClient {
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
}
readonly subagents: IApiClient['subagents'] = {
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
}
readonly host: IApiClient['host'] = {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
// A native system dialog is user-paced and may legitimately stay open
@@ -57,6 +57,11 @@ import {
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
} from '../api/credentials.schema.ts'
import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
import {
subagentHistoryRequestSchema,
subagentListRequestSchema,
subagentPromptRequestSchema,
} from '../api/subagents.schema.ts'
/**
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
@@ -87,6 +92,9 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },
+6 -1
View File
@@ -45,7 +45,10 @@ export interface Config {
* project directory and the fallback parent for name-created Workspaces.
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = ['agents', 'directoryPicker', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
static inject = [
'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
'tools', 'userInteraction', 'workspace',
]
static Config: z<Config> = z.object({
provider: z.string().required(),
@@ -54,6 +57,7 @@ export class ApiProxyService extends Service implements ApiProxy {
})
readonly sessions: ApiProxy['sessions']
readonly subagents: ApiProxy['subagents']
readonly workspace: ApiProxy['workspace']
readonly host: ApiProxy['host']
readonly commands: ApiProxy['commands']
@@ -75,6 +79,7 @@ export class ApiProxyService extends Service implements ApiProxy {
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
})
this.sessions = api.sessions
this.subagents = api.subagents
this.workspace = api.workspace
this.host = api.host
this.commands = api.commands
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import { RpcId } from '../src/api/rpc.ts'
import type { RpcRequest } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts'
const sid = (value: string): SessionId => value as SessionId
const PARENT = sid('parent')
const CHILD = sid('child')
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId('subagent-rpc'), payload }
}
function bench(options: {
parentLive?: boolean
entries?: object[]
followupError?: Error
listError?: Error
readError?: Error
historyParent?: SessionId
} = {}) {
const parent = { id: PARENT }
const getAgent = vi.fn((id: SessionId) =>
options.parentLive !== false && id === PARENT ? parent : undefined)
const listChildren = vi.fn(() => options.listError === undefined
? Promise.resolve(options.entries ?? [
{ kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive' },
])
: Promise.reject(options.listError))
const followup = vi.fn((
_parent: unknown,
_childId: SessionId,
_content: unknown,
_delivery: { source: { kind: string; rpcId: RpcId }; signal: AbortSignal },
) => options.followupError === undefined
? Promise.resolve('message-1')
: Promise.reject(options.followupError))
const readSession = vi.fn(() => options.readError === undefined
? Promise.resolve({
session: {
version: 0, id: CHILD, createdAt: 1, parentSession: options.historyParent ?? PARENT,
} satisfies SessionHeader,
events: [
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
] as unknown as SessionEvent[],
})
: Promise.reject(options.readError))
const ctx = new Context()
ctx.provide('agents', { get: getAgent })
ctx.provide('subagents', { listChildren, followup })
ctx.provide('sessionQuery', { readSession })
ctx.provide('userInteraction', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp',
})
return { api, getAgent, listChildren, readSession, followup, parent }
}
describe('subagent gateway', () => {
it('lists the complete catalog and reports exact live-parent availability', async () => {
const { api, listChildren } = bench({ parentLive: false, entries: [
{ kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive' },
{ kind: 'child', id: sid('one-shot'), mode: 'one-shot', activity: 'inactive' },
{ kind: 'diagnostic', id: sid('bad'), reason: 'corrupt' },
] })
const response = await api.subagents.list(request({ parentSessionId: PARENT }))
expect(response.rpcId).toBe('subagent-rpc')
expect(response.result).toMatchObject({
ok: true,
value: { parentAvailable: false, entries: [{ kind: 'child' }, { kind: 'diagnostic' }] },
})
expect(listChildren).toHaveBeenCalledWith(PARENT, undefined)
})
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,
}))
expect(response.result).toMatchObject({
ok: true,
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
})
expect(readSession).toHaveBeenCalledWith(CHILD)
expect(getAgent).not.toHaveBeenCalled()
})
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,
}))
expect(response.result).toMatchObject({
ok: false,
error: {
code: 'subagent-catalog-diagnostic',
details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' },
},
})
expect(readSession).not.toHaveBeenCalled()
})
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 response = await api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, content,
}))
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)
})
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' } })
expect(absent.listChildren).not.toHaveBeenCalled()
const failed = bench({ followupError: new SubagentError('not delivered', 'DRAINING') })
expect((await failed.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, content: [],
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-delivered' } })
})
it('maps history disappearance and hides unexpected backend details', async () => {
const disappeared = bench({
readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'),
})
expect((await disappeared.api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD,
}))).result).toMatchObject({
ok: false,
error: {
code: 'subagent-not-found',
message: 'subagent disappeared during history read',
details: { parentSessionId: PARENT, childSessionId: CHILD },
},
})
const catalog = bench({ listError: new Error('secret descriptor') })
expect((await catalog.api.subagents.list(request({
parentSessionId: PARENT,
}))).result).toMatchObject({
ok: false,
error: { code: 'internal', message: 'subagent catalog read failed' },
})
const prompt = bench({ followupError: new Error('secret provider') })
expect((await prompt.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, content: [],
}))).result).toMatchObject({
ok: false,
error: { code: 'internal', message: 'subagent prompt failed' },
})
})
})
@@ -19,6 +19,7 @@ function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>>
/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
function scriptedApi(overrides: {
sessions?: Partial<ApiProxy['sessions']>
subagents?: Partial<ApiProxy['subagents']>
host?: Partial<ApiProxy['host']>
commands?: Partial<ApiProxy['commands']>
skills?: Partial<ApiProxy['skills']>
@@ -57,6 +58,12 @@ function scriptedApi(overrides: {
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,
},
subagents: {
list: r => ok(r, { entries: [], parentAvailable: false }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { messageId: 'message-1' as never }),
...overrides.subagents,
},
host: {
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
pickDirectory: r => ok(r, { path: null }),
@@ -103,6 +103,20 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
},
subagents: {
async list(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { entries: [], parentAvailable: false } } }
},
async history(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { events: [], hasMore: false } } }
},
async prompt(request) {
return {
rpcId: request.rpcId,
result: { ok: true, value: { messageId: 'message-1' as never } },
}
},
},
host: {
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
@@ -36,6 +36,11 @@ import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../s
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
import { goalEditRequestSchema } from '../src/api/goals.schema.ts'
import {
subagentHistoryRequestSchema, subagentHistoryValueSchema, subagentListEntrySchema,
subagentListRequestSchema, subagentListValueSchema, subagentPromptRequestSchema,
subagentPromptValueSchema,
} from '../src/api/subagents.schema.ts'
describe('RpcId', () => {
it('brands a raw string at zero runtime cost', () => {
@@ -75,6 +80,12 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
expect(rpcErrorSchema.parse({ code: 'subagent-parent-unavailable', message: 'm', details: { parentSessionId: 'p' } }).code).toBe('subagent-parent-unavailable')
expect(rpcErrorSchema.parse({ code: 'subagent-not-found', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c' } }).code).toBe('subagent-not-found')
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: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
@@ -272,6 +283,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 diagnostic = { kind: 'diagnostic', id: 'bad', reason: 'unsupported' }
expect(subagentListEntrySchema.parse(child)).toEqual(child)
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)
expect(subagentHistoryRequestSchema.parse({
parentSessionId: 'p', childSessionId: 'c', beforeSeq: 4, maxMessages: 2,
}).beforeSeq).toBe(4)
expect(() => subagentHistoryRequestSchema.parse({
parentSessionId: 'p', childSessionId: 'c', maxMessages: 0,
})).toThrow()
expect(subagentHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
})
it('validates prompt content and the accepted inbox identity', () => {
expect(subagentPromptRequestSchema.parse({
parentSessionId: 'p', childSessionId: 'c', content: [{ type: 'text', text: '继续' }],
}).childSessionId).toBe('c')
expect(subagentPromptValueSchema.parse({ messageId: 'm1' }).messageId).toBe('m1')
expect(() => subagentPromptValueSchema.parse({ taskId: 't1' })).toThrow()
})
})
describe('host domain schemas', () => {
it('validates describe request/value', () => {
expect(hostDescribeRequestSchema.parse({})).toEqual({})
+6
View File
@@ -53,6 +53,12 @@
{
"path": "../../session-title/session-title"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../subagent/subagent"
},
{
"path": "../../skill/skill"
},
+3
View File
@@ -3389,6 +3389,9 @@ importers:
'@deepseek-ai/dsh-skill':
specifier: workspace:^
version: link:../../skill/skill
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
+1
View File
@@ -40,6 +40,7 @@
"apps/web/tests/shipped-composition.e2e.ts",
"apps/web/tests/goal-bar.e2e.ts",
"apps/web/tests/startup-auto-selection.e2e.ts",
"apps/web/tests/subagent-conversation.e2e.ts",
"apps/cli/tests/**/*.ts",
"examples/*/src/**/*.ts",
"examples/*/start.ts",