diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 891e2b359f..eaa3bf485c 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -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: diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dd3518ae06..02c5fb02fc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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. */ diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index ae47eb9e89..04e9fcb713 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -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 { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 9f091b26c5..4ccd38735d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index e286157e46..a7ebfbbd86 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -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, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index d599de1be7..88708f0625 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -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)), diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx index 2908583191..6addc8c69c 100644 --- a/packages/client/locale/tests/language-row.spec.tsx +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -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( - { ids: [], byId: {}, current: undefined, phase: 'ready' }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 0783d0662f..798fa297be 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 0ae71ff17b..ffee75c0e2 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -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. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 52a443d9df..85511bc191 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -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 请求使用的提供方/模型路由,但不添加任何模型可见内容。 diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 79f20a234c..d273095ec1 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -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 /** diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 2e9b131c77..0a384fffa2 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -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 { diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index f34c83689a..a4c260a068 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -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. */ diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index a89d8dbc31..a2edc8f9e8 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -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> + 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 | null = null /** Mutations arriving after a list request starts are replayed over its response. */ private listMutations: SessionListMutation[] | null = null + private readonly addresses = new Map() + private readonly catalogs = new Map() + private readonly catalogInflight = new Map>() + private readonly openCatalogs = new Set() + private readonly catalogDebounce = new Map>() 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 { + 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(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), } } } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index fcc89930fa..f71da45d1a 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -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> + /** 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 private readonly scopes = new Map() /** 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( {}, { 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({ 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 { + 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 = {} 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) } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 53a69215ac..0a85609750 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -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 } | 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> { + 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> { + 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. */ diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 9cf7971049..3c0b86fec8 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -132,6 +132,19 @@ export class FakeApiClient implements IApiClient { cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } + onSubagentList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) + onSubagentHistory: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ events: [], hasMore: false })) + onSubagentPrompt: (payload: unknown) => Promise> + = () => 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)), diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 1330a49768..5ad38eeedd 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -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', () => { diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 73bf901234..1fbee80853 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -18,6 +18,7 @@ const at = (seq: number, e: Record): 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 diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index e912f63536..2544870ace 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -52,6 +52,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot pending: [], queue: [], running: false, + subagent: null, composerPhase: 'active', removed: false, openState: 'open', diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index b26c033bb7..d39f59b99d 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -187,6 +187,7 @@ export class TestSessions implements ISessions { constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) { this.list = createSnapshotStore({ 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: [] }) diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index 00aa8a1e43..fb3743a8a7 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index 64d06f1d9b..c892f2f244 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -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`. diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index 0cec3b8c8f..ed607de783 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -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` 绑定的草稿表层。 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 9da7b5dbe6..9784c56ced 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -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 diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index 1123c2c48c..c023c84771 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -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 + 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 } }>() 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')) diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index cc9be46bca..2c06d7b5c5 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 2cb382f534..fc83ae47dc 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -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 `name` 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 `name` 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. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 2fc30da5e4..60f2c258ac 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -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` → 提交时生成的模型形式 `name`。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` → 提交时生成的模型形式 `name`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index b0f55fd3e5..c23f15b770 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -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() @@ -61,6 +62,7 @@ export function apply(ctx: ClientContext): void { } const fetchCatalog = (sessionId: SessionId): Promise => { + if (sessions.subagentAddress(sessionId) !== undefined) return Promise.resolve([]) const existing = fetches.get(sessionId) if (existing !== undefined) return existing.promise const abort = new AbortController() diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 0d8f2c57cd..420be78d2b 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -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', () => { diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx index d028affd69..7b1e11a69b 100644 --- a/packages/client/ui-theme/tests/appearance-row.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -22,7 +22,7 @@ const COPY: Record = { /** Empty global standard-kit hooks (the row reads neither). */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready' }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 39504fe66a..49d852f349 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -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( - { ids: [], byId: {}, current: undefined, phase: 'ready' }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 7476de29f5..288e4fa58d 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -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, diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 1128434de7..02f559474f 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -29,6 +29,8 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial [item.id, item])), current: undefined, phase: 'ready', + subagentsByParent: {}, + currentAddress: undefined, ...overrides, }) const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 9ab2128073..e7788a3f63 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -28,7 +28,7 @@ function hook(snapshot: T) { return function select(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, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 27a1434e60..10e708ab2c 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 3c5a83a468..abca1e9b20 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index f853356457..dca3924af9 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -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 模式使用。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index e2d937d0cf..03f31c6a49 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -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:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 4e506ed262..f1612bc2da 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -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 { + 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, { diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 87aa1036bf..7f2f55cba4 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -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' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 88e2c05575..efcbb87d19 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.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'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index f0d5fb6840..de6f4b54b1 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -56,6 +56,16 @@ export const rpcErrorSchema: z.ZodType = 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 diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 34cd80d5da..54beecf64b 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -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': {} } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index bb044d4428..8816ba9359 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -194,10 +194,10 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [ ]) as unknown as z.ZodType /** One session.history item: the session event plus its optional host-computed tool view. */ -export const historyEntrySchema = z.object({ +export const historyEntrySchema: z.ZodType> = z.object({ event: sessionEventSchema, view: toolEventViewSchema.optional(), -}) satisfies z.ZodType> +}) as unknown as z.ZodType> /** * Projection baseline passthrough: `values` stays a wide record — each value @@ -211,11 +211,11 @@ export const sessionProjectionsBlockSchema = z.object({ }) as unknown as z.ZodType /** session.history response value (projections rides the tail page only). */ -export const sessionHistoryValueSchema = z.object({ +export const sessionHistoryValueSchema: z.ZodType>> = z.object({ events: z.array(historyEntrySchema), hasMore: z.boolean(), projections: sessionProjectionsBlockSchema.optional(), -}) satisfies z.ZodType>> +}) /** session.models request payload. */ export const sessionModelsRequestSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/subagents.schema.ts b/packages/host/apiproxy/src/api/subagents.schema.ts new file mode 100644 index 0000000000..d89df0d8eb --- /dev/null +++ b/packages/host/apiproxy/src/api/subagents.schema.ts @@ -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> + +/** subagent.list request payload. */ +export const subagentListRequestSchema = z.object({ + parentSessionId: sessionIdSchema, +}) satisfies z.ZodType>> + +/** subagent.list response value. */ +export const subagentListValueSchema = z.object({ + entries: z.array(subagentListEntrySchema), + parentAvailable: z.boolean(), +}) satisfies z.ZodType>> + +/** 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>> + +/** subagent.history response value. */ +export const subagentHistoryValueSchema = z.object({ + events: z.array(historyEntrySchema), + hasMore: z.boolean(), +}) as unknown as z.ZodType>> + +/** subagent.prompt request payload. */ +export const subagentPromptRequestSchema = z.object({ + parentSessionId: sessionIdSchema, + childSessionId: sessionIdSchema, + content: z.array(contentBlockSchema), +}) as unknown as z.ZodType> + +const messageIdSchema = z.string() as unknown as z.ZodType + +/** subagent.prompt response value. */ +export const subagentPromptValueSchema = z.object({ + messageId: messageIdSchema, +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts new file mode 100644 index 0000000000..0e67b7832b --- /dev/null +++ b/packages/host/apiproxy/src/api/subagents.ts @@ -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> + + /** + * Reads one healthy catalog child's persisted raw log with ordinary + * message-aligned pagination and render intents, without Agent activation. + */ + history( + request: RpcRequest, + signal?: AbortSignal, + ): Promise> + + /** + * Delivers human content through the exact live parent's continuation + * owner. Success identifies the accepted inbox message. + */ + prompt( + request: RpcRequest, + signal?: AbortSignal, + ): Promise> +} diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 7b5c1545de..758fe638ca 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -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>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> } + subagents: { + list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise>> + history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise>> + prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise>> + } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> @@ -155,6 +165,9 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType 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 diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 6bc060e969..cf1ff16728 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -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) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 339b1e777d..21330079c2 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -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 = 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 diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts new file mode 100644 index 0000000000..9cd7d4acdf --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -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

(payload: P): RpcRequest

{ + 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' }, + }) + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 6307dfe8f9..7d73759f0c 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -19,6 +19,7 @@ function ok(request: RpcRequest, value: T): Promise> /** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */ function scriptedApi(overrides: { sessions?: Partial + subagents?: Partial host?: Partial commands?: Partial skills?: Partial @@ -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 }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index ef111afe12..2ba5b94246 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -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 } } } diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index f6bd093178..7e1c8cdb34 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -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({}) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 98782ff7fd..c648d7a30d 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -53,6 +53,12 @@ { "path": "../../session-title/session-title" }, + { + "path": "../../session-query/session-query" + }, + { + "path": "../../subagent/subagent" + }, { "path": "../../skill/skill" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3356a4f0c..7b3df548df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tsconfig.host.json b/tsconfig.host.json index 2e5c6ea08c..bbb28d5488 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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",