feat(web): add subagent conversation transport

This commit is contained in:
Dudu-0223
2026-07-27 18:33:37 +08:00
committed by Tianyi Cui
parent 9adfc576da
commit a27492507d
58 changed files with 1212 additions and 62 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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.

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 请求使用的提供方/模型路由,但不添加任何模型可见内容。

View File

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

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 {

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -52,6 +52,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
pending: [],
queue: [],
running: false,
subagent: null,
composerPhase: 'active',
removed: false,
openState: 'open',

View File

@@ -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: [] })

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

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

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 保留目录行、带参 claim(space / 带参 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` 绑定的草稿表层。

View File

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

View File

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

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

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.

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-flight;scope 创建时的 `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-flight;scope 创建时的 `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 状态。

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 => ({

View File

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