feat(web): add subagent conversation transport
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -532,7 +532,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c
|
||||
|
||||
## `@deepseek-ai/dsh-host-apiproxy`
|
||||
|
||||
Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace`
|
||||
Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace`
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
|
||||
|
||||
@@ -16,6 +16,7 @@ export type {
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
|
||||
@@ -1910,6 +1910,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
},
|
||||
subagents: {
|
||||
list: request => ok(request, { entries: [], parentAvailable: true }),
|
||||
history: (request) => {
|
||||
const log = logs.get(request.payload.childSessionId) ?? []
|
||||
return Promise.resolve(ok(
|
||||
request,
|
||||
pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50),
|
||||
))
|
||||
},
|
||||
prompt: request => Promise.resolve(ok(request, {
|
||||
messageId: `fixture-message-${request.payload.childSessionId}` as never,
|
||||
})),
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
// Deterministic native pick: the keyless lanes drive the full
|
||||
@@ -2420,6 +2433,9 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.prompt': return this.api.sessions.prompt(request)
|
||||
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'subagent.list': return this.api.subagents.list(request)
|
||||
case 'subagent.history': return this.api.subagents.history(request)
|
||||
case 'subagent.prompt': return this.api.subagents.prompt(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
|
||||
|
||||
@@ -18,6 +18,7 @@ export type {
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
|
||||
@@ -113,6 +113,20 @@ export class FakeApiClient implements IApiClient {
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({
|
||||
entries: [],
|
||||
parentAvailable: true,
|
||||
}))),
|
||||
history: (payload: unknown) => this.record('subagent.history', payload, Promise.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
}))),
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
|
||||
messageId: 'fake-message' as never,
|
||||
}))),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
|
||||
@@ -16,7 +16,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
|
||||
/** Empty global standard-kit hooks (the row reads neither). */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
|
||||
@@ -2,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
|
||||
@@ -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.
|
||||
|
||||
@@ -54,6 +54,10 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
|
||||
|
||||
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。
|
||||
|
||||
## 已寻址的 subagent 对话
|
||||
|
||||
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间把地址与所选会话一同持久化。目录读取为 single-flight;`host/session-status` 就地翻转已列 child 的粗粒度活跃状态,`host/session-added` 则只在对应 parent 目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
RpcResult, SessionId, SubagentAddress,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionSearchResultItem } from '../sessions/manager.ts'
|
||||
import type {
|
||||
@@ -34,6 +36,12 @@ export interface ISessions {
|
||||
* @param id - session id (must exist in the list; unknown ids fail loud).
|
||||
*/
|
||||
open(id: SessionId): void
|
||||
/**
|
||||
* Resolve an already discovered direct-parent address without opening it.
|
||||
* @param id - possible addressed child id.
|
||||
* @returns the retained address, when present.
|
||||
*/
|
||||
subagentAddress(id: SessionId): SubagentAddress | undefined
|
||||
/** Clear the current selection into the no-session view state. */
|
||||
clear(): void
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,8 @@ export type { IWorkspaces } from './contract/workspaces.ts'
|
||||
export type {
|
||||
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
|
||||
} from './sessions/service.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
|
||||
export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
|
||||
export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
InboxItemId, RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
export type { TodoItem }
|
||||
@@ -335,6 +335,11 @@ export interface ConversationSnapshot {
|
||||
/** Authoritative transient inbox snapshot, replaced after every host-side change. */
|
||||
queue: readonly QueuedMessage[]
|
||||
running: boolean
|
||||
/**
|
||||
* Catalog-discovered continuation address. Its parent availability controls
|
||||
* human input; null means ordinary session transport.
|
||||
*/
|
||||
subagent: { address: SubagentAddress; parentAvailable: boolean } | null
|
||||
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
|
||||
composerPhase: ComposerPhase
|
||||
/** Set after host/session-removed; the UI grays out and disables input. */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type {
|
||||
IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
|
||||
SessionSummary, WorkspaceId,
|
||||
SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -45,6 +45,14 @@ export interface SessionListSnapshot {
|
||||
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
|
||||
phase: SessionListPhase
|
||||
error: RpcError | null
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
|
||||
/** One parent-addressed durable catalog projected through the sessions snapshot. */
|
||||
export interface SubagentCatalogSnapshot extends SubagentCatalog {
|
||||
state: 'loading' | 'ready' | 'error'
|
||||
error: RpcError | null
|
||||
}
|
||||
|
||||
type SessionListMutation =
|
||||
@@ -84,6 +92,11 @@ export class SessionManager {
|
||||
private listInflight: Promise<void> | null = null
|
||||
/** Mutations arriving after a list request starts are replayed over its response. */
|
||||
private listMutations: SessionListMutation[] | null = null
|
||||
private readonly addresses = new Map<SessionId, SubagentAddress>()
|
||||
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
|
||||
private readonly catalogInflight = new Map<SessionId, Promise<void>>()
|
||||
private readonly openCatalogs = new Set<SessionId>()
|
||||
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
|
||||
|
||||
private selected: SessionId | undefined
|
||||
|
||||
@@ -104,8 +117,10 @@ export class SessionManager {
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
restoredSelection?: SessionId,
|
||||
restoredAddress?: SubagentAddress,
|
||||
) {
|
||||
this.selected = restoredSelection
|
||||
if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress)
|
||||
this.listSnapshotCache = this.buildListSnapshot()
|
||||
}
|
||||
|
||||
@@ -119,7 +134,27 @@ export class SessionManager {
|
||||
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
|
||||
throw new Error(`sessions.select: unknown session ${sessionId}`)
|
||||
}
|
||||
this.addresses.delete(sessionId)
|
||||
this.sessions.get(sessionId)?.configureSubagent(undefined)
|
||||
this.selected = sessionId
|
||||
void this.refreshSubagents(sessionId)
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a healthy child through its durable direct-parent address.
|
||||
* @param address - catalog-derived parent and child ids.
|
||||
*/
|
||||
selectSubagent(address: SubagentAddress): void {
|
||||
const catalog = this.catalogs.get(address.parentSessionId)
|
||||
const entry = catalog?.entries.find(candidate => candidate.id === address.childSessionId)
|
||||
if (entry === undefined || entry.kind !== 'child') {
|
||||
throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`)
|
||||
}
|
||||
this.addresses.set(address.childSessionId, address)
|
||||
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable ?? false)
|
||||
this.selected = address.childSessionId
|
||||
void this.refreshSubagents(address.childSessionId)
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
@@ -129,6 +164,15 @@ export class SessionManager {
|
||||
this.notifier.notifyNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the durable catalog address retained for one child.
|
||||
* @param sessionId - possible addressed child id.
|
||||
* @returns The direct-parent address, when navigation discovered one.
|
||||
*/
|
||||
subagentAddress(sessionId: SessionId): SubagentAddress | undefined {
|
||||
return this.addresses.get(sessionId)
|
||||
}
|
||||
|
||||
// ---- Instance management ----
|
||||
|
||||
/**
|
||||
@@ -168,13 +212,23 @@ export class SessionManager {
|
||||
if (summary !== undefined) {
|
||||
session.handleBlank(summary.blank)
|
||||
session.handleRunning(summary.running)
|
||||
} else {
|
||||
const address = this.addresses.get(sessionId)
|
||||
const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries
|
||||
.find(entry => entry.kind === 'child' && entry.id === sessionId)
|
||||
if (child?.kind === 'child') session.handleRunning(child.activity === 'running')
|
||||
}
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private createSession(sessionId: SessionId): Session {
|
||||
const address = this.addresses.get(sessionId)
|
||||
return new Session(sessionId, this.api, {
|
||||
...(address === undefined ? {} : {
|
||||
address,
|
||||
parentAvailable: this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false,
|
||||
}),
|
||||
// The sender's local first-send flip mirrors into the list row so the
|
||||
// session surfaces (lists filter on blank) before any host frame lands.
|
||||
onEngaged: (engaged) => {
|
||||
@@ -197,6 +251,78 @@ export class SessionManager {
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh one direct-child catalog, reusing its in-flight request.
|
||||
* @param parentSessionId - catalog owner.
|
||||
*/
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void> {
|
||||
const existing = this.catalogInflight.get(parentSessionId)
|
||||
if (existing !== undefined) return existing
|
||||
const previous = this.catalogs.get(parentSessionId)
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: previous?.entries ?? [],
|
||||
parentAvailable: previous?.parentAvailable ?? false,
|
||||
state: 'loading',
|
||||
error: null,
|
||||
})
|
||||
this.notifier.markDirty()
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.subagents.list({ parentSessionId })
|
||||
if (result.ok) {
|
||||
this.catalogs.set(parentSessionId, {
|
||||
...result.value,
|
||||
state: 'ready',
|
||||
error: null,
|
||||
})
|
||||
for (const [childId, address] of this.addresses) {
|
||||
if (address.parentSessionId !== parentSessionId) continue
|
||||
this.sessions.get(childId)?.handleSubagentParentAvailable(result.value.parentAvailable)
|
||||
}
|
||||
} else {
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: previous?.entries ?? [],
|
||||
parentAvailable: previous?.parentAvailable ?? false,
|
||||
state: 'error',
|
||||
error: result.error,
|
||||
})
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const folded = transportError<never>(error)
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: previous?.entries ?? [],
|
||||
parentAvailable: previous?.parentAvailable ?? false,
|
||||
state: 'error',
|
||||
error: folded.ok ? null : folded.error,
|
||||
})
|
||||
} finally {
|
||||
this.catalogInflight.delete(parentSessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
})()
|
||||
this.catalogInflight.set(parentSessionId, operation)
|
||||
return operation
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark whether a catalog menu is consuming live membership updates.
|
||||
* @param parentSessionId - catalog owner.
|
||||
* @param open - current menu state.
|
||||
*/
|
||||
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
|
||||
if (open) {
|
||||
this.openCatalogs.add(parentSessionId)
|
||||
void this.refreshSubagents(parentSessionId)
|
||||
} else {
|
||||
this.openCatalogs.delete(parentSessionId)
|
||||
const timer = this.catalogDebounce.get(parentSessionId)
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
this.catalogDebounce.delete(parentSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
@@ -484,11 +610,23 @@ export class SessionManager {
|
||||
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
|
||||
})
|
||||
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
|
||||
if (frame.parentSessionId !== undefined
|
||||
&& (this.selected === frame.parentSessionId || this.openCatalogs.has(frame.parentSessionId))) {
|
||||
this.scheduleCatalogRefresh(frame.parentSessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
if (this.addresses.has(frame.sessionId)) {
|
||||
// A continuable activation detaching is not durable child deletion:
|
||||
// keep the addressed conversation usable and return its catalog row
|
||||
// to the inactive state.
|
||||
this.sessions.get(frame.sessionId)?.handleRunning(false)
|
||||
this.updateCatalogActivity(frame.sessionId, false)
|
||||
} else {
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved()
|
||||
}
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
@@ -497,6 +635,7 @@ export class SessionManager {
|
||||
case 'host/session-status': {
|
||||
this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running })
|
||||
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
|
||||
this.updateCatalogActivity(frame.sessionId, frame.running)
|
||||
return
|
||||
}
|
||||
case 'host/agent-error': {
|
||||
@@ -535,9 +674,40 @@ export class SessionManager {
|
||||
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
|
||||
handleConnected(): void {
|
||||
void this.refreshList()
|
||||
const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected)
|
||||
if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId)
|
||||
if (this.selected !== undefined) void this.refreshSubagents(this.selected)
|
||||
for (const parentSessionId of this.openCatalogs) void this.refreshSubagents(parentSessionId)
|
||||
for (const session of this.sessions.values()) void session.resync()
|
||||
}
|
||||
|
||||
/** Debounce membership refetches while one parent catalog is open. */
|
||||
private scheduleCatalogRefresh(parentSessionId: SessionId): void {
|
||||
if (this.catalogDebounce.has(parentSessionId)) return
|
||||
const timer = setTimeout(() => {
|
||||
this.catalogDebounce.delete(parentSessionId)
|
||||
void this.refreshSubagents(parentSessionId)
|
||||
}, 50)
|
||||
this.catalogDebounce.set(parentSessionId, timer)
|
||||
}
|
||||
|
||||
/** Flip a listed child's coarse activity in place from the shared Host frame. */
|
||||
private updateCatalogActivity(childSessionId: SessionId, running: boolean): void {
|
||||
let changed = false
|
||||
for (const [parentSessionId, catalog] of this.catalogs) {
|
||||
const activity = running ? 'running' as const : 'inactive' as const
|
||||
if (!catalog.entries.some(entry =>
|
||||
entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity)) continue
|
||||
const entries = catalog.entries.map((entry) => {
|
||||
if (entry.kind !== 'child' || entry.id !== childSessionId) return entry
|
||||
return { ...entry, activity }
|
||||
})
|
||||
changed = true
|
||||
this.catalogs.set(parentSessionId, { ...catalog, entries })
|
||||
}
|
||||
if (changed) this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private buildListSnapshot(): SessionListSnapshot {
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
// List rows read the generic 'title' projection key (host-computed unit
|
||||
@@ -566,7 +736,8 @@ export class SessionManager {
|
||||
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
|
||||
if (!sameOrder) this.itemsCache = items
|
||||
const selected = this.selected
|
||||
const current = selected !== undefined && items.some(item => item.sessionId === selected)
|
||||
const current = selected !== undefined
|
||||
&& (items.some(item => item.sessionId === selected) || this.addresses.has(selected))
|
||||
? selected
|
||||
: undefined
|
||||
return {
|
||||
@@ -575,6 +746,8 @@ export class SessionManager {
|
||||
state: this.listState,
|
||||
phase: this.listPhase,
|
||||
error: this.listError,
|
||||
subagentsByParent: Object.fromEntries(this.catalogs),
|
||||
currentAddress: current === undefined ? undefined : this.addresses.get(current),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
|
||||
IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -31,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
|
||||
import type { ISessions } from '../contract/sessions.ts'
|
||||
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
|
||||
import { SessionManager } from './manager.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
|
||||
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
|
||||
import { SessionProvideChannel } from './provide.ts'
|
||||
import type { Session } from './session.ts'
|
||||
|
||||
@@ -68,6 +68,16 @@ export interface SessionListState {
|
||||
current: SessionId | undefined
|
||||
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
|
||||
phase: SessionListPhase
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
|
||||
/** Current session's catalog-derived address, absent on ordinary navigation. */
|
||||
currentAddress: SubagentAddress | undefined
|
||||
}
|
||||
|
||||
/** Persisted navigation cell: address survives refresh for correct history routing. */
|
||||
interface SessionSelection {
|
||||
sessionId?: SessionId
|
||||
subagentAddress?: SubagentAddress
|
||||
}
|
||||
|
||||
/** Structured session-create failure. */
|
||||
@@ -221,7 +231,7 @@ export class SessionsService implements ISessions {
|
||||
* selection survives transient list states (reconnect re-pull) and
|
||||
* resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
private readonly selection: SnapshotStore<SessionSelection>
|
||||
|
||||
private readonly scopes = new Map<SessionId, ScopeRecord>()
|
||||
/** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */
|
||||
@@ -244,12 +254,14 @@ export class SessionsService implements ISessions {
|
||||
private readonly rootCtx: Context,
|
||||
api: IApiClient,
|
||||
) {
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
this.selection = createSnapshotStore<SessionSelection>(
|
||||
{},
|
||||
{ persist: { name: 'dsh.sessions.current' } })
|
||||
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
|
||||
const restored = this.selection.getSnapshot()
|
||||
this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress)
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'pending',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
// The manager owns wire truth; the store is its projection. Manager
|
||||
// notifications are already microtask-batched.
|
||||
@@ -303,6 +315,41 @@ export class SessionsService implements ISessions {
|
||||
this.manager.select(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a healthy catalog child through its direct-parent address.
|
||||
* @param address - catalog-derived parent and child ids.
|
||||
*/
|
||||
openSubagent(address: SubagentAddress): void {
|
||||
this.manager.selectSubagent(address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an already discovered direct-parent address without opening it.
|
||||
* Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
|
||||
* @param id - possible addressed child id.
|
||||
* @returns The retained address, when present.
|
||||
*/
|
||||
subagentAddress(id: SessionId): SubagentAddress | undefined {
|
||||
return this.manager.subagentAddress(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inform the runtime whether a catalog menu is consuming membership updates.
|
||||
* @param parentSessionId - selected parent.
|
||||
* @param open - menu state.
|
||||
*/
|
||||
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
|
||||
this.manager.setSubagentCatalogOpen(parentSessionId, open)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh one direct-child catalog.
|
||||
* @param parentSessionId - catalog owner.
|
||||
*/
|
||||
refreshSubagents(parentSessionId: SessionId): Promise<void> {
|
||||
return this.manager.refreshSubagents(parentSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state (new-session affordance and the workspace preselection flow).
|
||||
@@ -509,6 +556,7 @@ export class SessionsService implements ISessions {
|
||||
* cannot miss; kept so a future current writer cannot crash the notify. */
|
||||
if (record !== undefined) {
|
||||
void record.session.open()
|
||||
void this.manager.refreshSubagents(current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,7 +614,9 @@ export class SessionsService implements ISessions {
|
||||
|
||||
/** Project the manager's list snapshot into the store (title derivation is display-only). */
|
||||
private projectList(): void {
|
||||
const { items, current, phase } = this.manager.getListSnapshot()
|
||||
const {
|
||||
items, current, phase, subagentsByParent, currentAddress,
|
||||
} = this.manager.getListSnapshot()
|
||||
const ids: SessionId[] = []
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const entry of items) {
|
||||
@@ -583,15 +633,36 @@ export class SessionsService implements ISessions {
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
}
|
||||
}
|
||||
if (current !== undefined && currentAddress !== undefined && byId[current] === undefined) {
|
||||
const child = subagentsByParent[currentAddress.parentSessionId]?.entries
|
||||
.find(entry => entry.kind === 'child' && entry.id === current)
|
||||
if (child?.kind === 'child') {
|
||||
byId[current] = {
|
||||
id: current,
|
||||
displayTitle: child.label,
|
||||
parentId: currentAddress.parentSessionId,
|
||||
running: child.activity === 'running',
|
||||
waitingApproval: false,
|
||||
blank: false,
|
||||
updatedAt: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
const persisted = this.selection.getSnapshot().sessionId
|
||||
// No current (cleared, or masked gap) wipes the persisted cell — a reload
|
||||
// stays on empty; the in-memory selection still resurfaces a masked id.
|
||||
if (current === undefined) {
|
||||
if (persisted !== undefined) this.selection.set({})
|
||||
} else if (byId[current] !== undefined && persisted !== current) {
|
||||
this.selection.set({ sessionId: current })
|
||||
} else if (byId[current] !== undefined
|
||||
&& (persisted !== current
|
||||
|| this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
|
||||
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId)) {
|
||||
this.selection.set({
|
||||
sessionId: current,
|
||||
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
|
||||
})
|
||||
}
|
||||
this.list.set({ ids, byId, current, phase })
|
||||
this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress })
|
||||
this.pruneScopes(byId)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
|
||||
RpcId, RpcResult, SessionId, ToolEventView,
|
||||
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Value import from the inline-safe wire layer (not the connection plugin):
|
||||
// plugin-to-plugin value imports are a bundle purity error.
|
||||
@@ -34,6 +34,10 @@ const MAX_RETRY_DELAY_MS = 2_147_483_647
|
||||
|
||||
/** Manager-owned observers of a Session object's local state edges. */
|
||||
export interface SessionOptions {
|
||||
/** Catalog-discovered address selecting non-activating subagent transport. */
|
||||
address?: SubagentAddress
|
||||
/** Whether the exact direct parent Agent was live at the latest catalog read. */
|
||||
parentAvailable?: boolean
|
||||
/**
|
||||
* First ACCEPTED prompt on a blank session (fires at most once, on the
|
||||
* prompt RPC's success response): the manager mirrors the blank→false flip
|
||||
@@ -119,6 +123,8 @@ export class Session implements SessionFace {
|
||||
private dispatchesRev = 0
|
||||
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
|
||||
private running = false
|
||||
private address: SubagentAddress | undefined
|
||||
private parentAvailable = false
|
||||
/**
|
||||
* Sticky send marker, private input of the composerPhase derivation: set
|
||||
* synchronously before prompt()'s first await, never reset — the blank →
|
||||
@@ -174,6 +180,8 @@ export class Session implements SessionFace {
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.projections = options.projections ?? new ProjectionValueStore()
|
||||
this.address = options.address
|
||||
this.parentAvailable = options.parentAvailable ?? false
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
@@ -213,7 +221,12 @@ export class Session implements SessionFace {
|
||||
this.notifier.markDirty()
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
|
||||
if (this.address === undefined) {
|
||||
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
|
||||
} else {
|
||||
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
}
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
@@ -253,6 +266,19 @@ export class Session implements SessionFace {
|
||||
* @returns the cancel result.
|
||||
*/
|
||||
async cancel(): Promise<RpcResult<{ accepted: true }>> {
|
||||
if (this.address !== undefined) {
|
||||
const result: RpcResult<{ accepted: true }> = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-not-delivered',
|
||||
message: 'subagent activation cancellation is unavailable',
|
||||
details: { childSessionId: this.address.childSessionId },
|
||||
},
|
||||
}
|
||||
this.promptError = { op: 'stop', error: result.error }
|
||||
this.notifier.markDirty()
|
||||
return result
|
||||
}
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
|
||||
@@ -318,9 +344,7 @@ export class Session implements SessionFace {
|
||||
this.loadingOlder = true
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
|
||||
})
|
||||
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
|
||||
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
@@ -479,6 +503,31 @@ export class Session implements SessionFace {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install or clear the catalog-discovered transport address. A changed
|
||||
* address rebuilds an already-open window through its new history route.
|
||||
* @param address - direct parent/child address, or undefined for ordinary transport.
|
||||
* @param parentAvailable - latest exact-parent availability hint.
|
||||
*/
|
||||
configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
|
||||
const same = this.address?.parentSessionId === address?.parentSessionId
|
||||
&& this.address?.childSessionId === address?.childSessionId
|
||||
this.address = address
|
||||
this.parentAvailable = parentAvailable
|
||||
if (!same && this.openState !== 'cold') void this.resync()
|
||||
else this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the parent availability hint from a catalog refresh.
|
||||
* @param available - whether the exact direct parent is live.
|
||||
*/
|
||||
handleSubagentParentAvailable(available: boolean): void {
|
||||
if (this.parentAvailable === available) return
|
||||
this.parentAvailable = available
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank-bit relay from the authoritative summary source (list baseline and
|
||||
* the session-added frame). Monotone: once any signal (local first send,
|
||||
@@ -533,7 +582,7 @@ export class Session implements SessionFace {
|
||||
this.openError = null
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
let { result } = await this.history({ maxMessages: PAGE_MESSAGES })
|
||||
if (generation !== this.openGeneration) return
|
||||
if (!result.ok) {
|
||||
this.openState = 'error'
|
||||
@@ -544,7 +593,7 @@ export class Session implements SessionFace {
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||
result = (await this.history({ maxMessages: PAGE_MESSAGES })).result
|
||||
if (generation !== this.openGeneration) return
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
@@ -621,7 +670,7 @@ export class Session implements SessionFace {
|
||||
this.stitching = true
|
||||
const generation = this.openGeneration
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
const { result } = await this.history({ maxMessages: PAGE_MESSAGES })
|
||||
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
|
||||
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
@@ -888,6 +937,9 @@ export class Session implements SessionFace {
|
||||
codeDispatches: this.dispatchesCache.value,
|
||||
queue: this.queueCache.value,
|
||||
running: this.running,
|
||||
subagent: this.address === undefined
|
||||
? null
|
||||
: { address: this.address, parentAvailable: this.parentAvailable },
|
||||
composerPhase: derivePhase(
|
||||
// Command lifecycle nodes are not conversation: running /permission
|
||||
// or /plan on a fresh session keeps the hero (the client mirror of
|
||||
@@ -905,6 +957,17 @@ export class Session implements SessionFace {
|
||||
lastAgentError: this.lastAgentError,
|
||||
}
|
||||
}
|
||||
|
||||
/** Select ordinary or addressed history transport from the stored browser fact. */
|
||||
private history(payload: { beforeSeq?: number; maxMessages?: number }): Promise<RpcResponse<{
|
||||
events: HistoryEntry[]
|
||||
hasMore: boolean
|
||||
projections?: ProjectionsBaseline
|
||||
}>> {
|
||||
return this.address === undefined
|
||||
? this.api.sessions.history({ sessionId: this.sessionId, ...payload })
|
||||
: this.api.subagents.history({ ...this.address, ...payload })
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the plugin-owned payload at the session-event wire boundary. */
|
||||
|
||||
@@ -132,6 +132,19 @@ export class FakeApiClient implements IApiClient {
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
|
||||
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
|
||||
onSubagentHistory: (payload: unknown) => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>>
|
||||
= () => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
|
||||
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
|
||||
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
|
||||
@@ -272,6 +272,87 @@ describe('host frame routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent catalogs', () => {
|
||||
it('selects only a catalog-discovered child and keeps its durable address across status frames', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [
|
||||
summary(S1),
|
||||
] as never[] }))
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{ kind: 'child', id: S2, label: 'worker', activity: 'running' }] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshList()
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2 })
|
||||
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual({
|
||||
parentSessionId: S1, childSessionId: S2,
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: S1, childSessionId: S2 },
|
||||
parentAvailable: true,
|
||||
})
|
||||
const listCalls = api.callsOf('subagent.list').length
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-complete' as never,
|
||||
payload: { type: 'host/session-status', sessionId: S2, running: false },
|
||||
})
|
||||
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
|
||||
kind: 'child', id: S2, activity: 'inactive',
|
||||
})
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(listCalls)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-detached' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S2 },
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot()).toMatchObject({
|
||||
removed: false,
|
||||
subagent: { address: { parentSessionId: S1, childSessionId: S2 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('refetches debounced membership only while the parent catalog is open', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshSubagents(S1)
|
||||
manager.setSubagentCatalogOpen(S1, true)
|
||||
await Promise.resolve()
|
||||
const baseline = api.callsOf('subagent.list').length
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: S2, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added-again' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-m3' as SessionId, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
|
||||
|
||||
manager.setSubagentCatalogOpen(S1, false)
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-added-closed' as never,
|
||||
payload: {
|
||||
type: 'host/session-added', sessionId: 'fk-m4' as SessionId, parentSessionId: S1, blank: false,
|
||||
},
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('remaining branches', () => {
|
||||
it('refreshList folds a transport throw into the error state', async () => {
|
||||
const api = new FakeApiClient()
|
||||
@@ -435,6 +516,19 @@ describe('connected generation', () => {
|
||||
expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1)
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads the durable parent address for a restored child selection', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const address = { parentSessionId: S1, childSessionId: S2 }
|
||||
const manager = new SessionManager(api, S2, address)
|
||||
|
||||
manager.handleConnected()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 })
|
||||
})
|
||||
expect(manager.getListSnapshot().currentAddress).toEqual(address)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waiting-approval list bit', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
|
||||
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
|
||||
|
||||
const SID = 'fk-s1' as SessionId
|
||||
const PARENT = 'fk-parent' as SessionId
|
||||
|
||||
function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
|
||||
return { api, session: new Session(SID, api) }
|
||||
@@ -580,6 +581,33 @@ describe('paging', () => {
|
||||
})
|
||||
|
||||
describe('prompt and cancel errors', () => {
|
||||
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await session.open()
|
||||
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
|
||||
const cancelled = await session.cancel()
|
||||
|
||||
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-not-delivered' } })
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, content: [{ type: 'text', text: '继续' }] },
|
||||
])
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
expect(api.callsOf('session.cancel')).toEqual([])
|
||||
expect(session.getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: PARENT, childSessionId: SID },
|
||||
parentAvailable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
|
||||
const { api, session } = makeSession()
|
||||
// The blank → engaging edge fires before the RPC settles: the first-send
|
||||
|
||||
@@ -52,6 +52,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
|
||||
pending: [],
|
||||
queue: [],
|
||||
running: false,
|
||||
subagent: null,
|
||||
composerPhase: 'active',
|
||||
removed: false,
|
||||
openState: 'open',
|
||||
|
||||
@@ -187,6 +187,7 @@ export class TestSessions implements ISessions {
|
||||
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
|
||||
this.list = createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
this.channel = new SessionProvideChannel({
|
||||
rebuildBundles: () => {
|
||||
@@ -395,6 +396,11 @@ export class TestSessions implements ISessions {
|
||||
this.list.update((draft) => { draft.current = id })
|
||||
}
|
||||
|
||||
/** Test fixtures do not synthesize catalog addresses. */
|
||||
subagentAddress(_id: SessionId): undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Clear the current selection (recorded; the production no-session flow). */
|
||||
clear(): void {
|
||||
this.calls.push({ method: 'clear', args: [] })
|
||||
|
||||
@@ -2,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
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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` 绑定的草稿表层。
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export class CommandService extends Service implements CommandServiceContract {
|
||||
const connection = ctx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error('ui-command: connection service unavailable')
|
||||
this.directory = new CommandDirectory(async (sessionId) => {
|
||||
if (this.sessions().subagentAddress(sessionId) !== undefined) return []
|
||||
const { result } = await connection.api.commands.list({ sessionId })
|
||||
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
|
||||
return result.value.commands
|
||||
|
||||
@@ -37,6 +37,7 @@ interface BenchOptions {
|
||||
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
|
||||
commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }>
|
||||
execute?: (payload: { sessionId: SessionId; line: string }) => Promise<ExecuteValue>
|
||||
addressed?: SessionId
|
||||
}
|
||||
|
||||
async function bench(opts: BenchOptions = {}) {
|
||||
@@ -67,11 +68,14 @@ async function bench(opts: BenchOptions = {}) {
|
||||
return () => { registered.delete(key) }
|
||||
},
|
||||
})
|
||||
// Real scope tags behind a fake sessions face (scope/scopeOf are all the service reads).
|
||||
// Real scope tags behind a fake sessions face.
|
||||
const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
|
||||
ctx.provide('sessions', {
|
||||
scope: (id: SessionId) => scopes.get(id)?.ctx,
|
||||
scopeOf: (c: Context) => scopeOf(c),
|
||||
subagentAddress: (id: SessionId) => id === opts.addressed
|
||||
? { parentSessionId: sid('parent'), childSessionId: id }
|
||||
: undefined,
|
||||
})
|
||||
ctx.provide('connection', { api })
|
||||
/** Notices the fake conversation face collected (runDetached routing). */
|
||||
@@ -154,6 +158,12 @@ describe('registration', () => {
|
||||
})
|
||||
|
||||
describe('candidates', () => {
|
||||
it('does not fetch Agent-bound commands for an addressed child', async () => {
|
||||
const b = await bench({ addressed: sid('child') })
|
||||
await expect(b.warm(proj('child'))).resolves.toBeUndefined()
|
||||
expect(b.listCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('pulls the session catalog; prefix filter and hint mapping apply', async () => {
|
||||
const { source, listCalls } = await bench()
|
||||
const list = await source.candidates(proj('s1'), req('g'))
|
||||
|
||||
@@ -2,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
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path lets a user insert a model reference rather than loading the body directly. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
|
||||
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path inserts a model reference rather than loading the body directly. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
|
||||
|
||||
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent(智能体)支撑,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径让用户插入模型引用,而不是直接加载正文。目录按会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
|
||||
skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续子代理在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
|
||||
|
||||
`skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* with an aborted signal just returns early.
|
||||
*/
|
||||
import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
|
||||
/** One session's catalog fetch: the shared promise plus its own abort handle. */
|
||||
@@ -32,8 +32,8 @@ interface CatalogFetch {
|
||||
settled?: readonly SkillEntry[]
|
||||
}
|
||||
|
||||
/** Required services: the slash registry + the wire face the source closes over. */
|
||||
export const inject = ['slash', 'connection']
|
||||
/** Required services: slash registry, routed sessions, and the wire face. */
|
||||
export const inject = ['slash', 'connection', 'sessions']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the '/' skill source over the root wire face.
|
||||
@@ -41,6 +41,7 @@ export const inject = ['slash', 'connection']
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const skills = (ctx.get('connection') as ConnectionHandle).api.skills
|
||||
const sessions = ctx.get('sessions') as ISessions
|
||||
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
|
||||
// the fiber effect below is its teardown boundary.
|
||||
const fetches = new Map<SessionId, CatalogFetch>()
|
||||
@@ -61,6 +62,7 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
|
||||
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
|
||||
if (sessions.subagentAddress(sessionId) !== undefined) return Promise.resolve([])
|
||||
const existing = fetches.get(sessionId)
|
||||
if (existing !== undefined) return existing.promise
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -24,11 +24,16 @@ type ListResult =
|
||||
type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }>
|
||||
|
||||
/** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */
|
||||
async function bench(list: ListFn) {
|
||||
async function bench(list: ListFn, addressed?: SessionId) {
|
||||
const ctx = new Context()
|
||||
let captured: SlashSource | undefined
|
||||
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||
ctx.provide('connection', { api: { skills: { list } } })
|
||||
ctx.provide('sessions', {
|
||||
subagentAddress: (id: SessionId) => id === addressed
|
||||
? { parentSessionId: sid('parent'), childSessionId: id }
|
||||
: undefined,
|
||||
})
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
return { ctx, source: captured! }
|
||||
}
|
||||
@@ -60,7 +65,7 @@ const req = (query: string, signal?: AbortSignal) =>
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slash', 'connection'])
|
||||
expect(inject).toEqual(['slash', 'connection', 'sessions'])
|
||||
})
|
||||
|
||||
it('registers the "/" skill source; disposal frees the name (HMR safety)', async () => {
|
||||
@@ -106,6 +111,14 @@ describe('candidates: sessionId addressing', () => {
|
||||
await expect(source.candidates(proj('s1'), req('co')))
|
||||
.rejects.toThrow('skill.list failed: internal: boom')
|
||||
})
|
||||
|
||||
it('does not fetch Agent-bound skills for an addressed child', async () => {
|
||||
const { list, payloads } = countingList()
|
||||
const { source } = await bench(list, sid('child'))
|
||||
await expect(source.candidates(proj('child'), req(''))).resolves.toEqual([])
|
||||
source.warm!(proj('child'))
|
||||
expect(payloads).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('catalog cache', () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ const COPY: Record<string, string> = {
|
||||
/** Empty global standard-kit hooks (the row reads neither). */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
|
||||
@@ -112,7 +112,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
|
||||
@@ -29,6 +29,8 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial<Sessi
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
subagentsByParent: {},
|
||||
currentAddress: undefined,
|
||||
...overrides,
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
|
||||
|
||||
@@ -28,7 +28,7 @@ function hook<T>(snapshot: T) {
|
||||
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
|
||||
}
|
||||
const sessions: SessionListState = {
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined,
|
||||
}
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
|
||||
@@ -2,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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 模式使用。
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -28,7 +28,8 @@ import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
|
||||
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
||||
SessionSummary, SettingsNamespaceView, ToolEventView, WorkspaceId, WorkspaceView,
|
||||
SessionSummary, SettingsNamespaceView, SubagentListEntry as SubagentCatalogEntry, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
@@ -68,6 +69,8 @@ import type {
|
||||
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import { openNativePath } from './native-path-opener.ts'
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentListEntry as CoreSubagentListEntry } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
@@ -462,6 +465,109 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Render one detached history page through the same presenter path as ordinary history. */
|
||||
function historyPage(
|
||||
ctx: Context,
|
||||
events: readonly SessionEvent[],
|
||||
beforeSeq: number | undefined,
|
||||
maxMessages: number | undefined,
|
||||
): { events: HistoryEntry[]; hasMore: boolean } {
|
||||
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
|
||||
return {
|
||||
events: page.events.map((event) => {
|
||||
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
|
||||
return { event, ...view === undefined ? {} : { view } }
|
||||
}),
|
||||
hasMore: page.hasMore,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a continuation failure without exposing descriptor or provider details. */
|
||||
function subagentPromptError(
|
||||
request: RpcRequest<{ childSessionId: SessionId }>,
|
||||
error: unknown,
|
||||
signal?: AbortSignal,
|
||||
): RpcResponse<never> {
|
||||
const childSessionId = request.payload.childSessionId
|
||||
if (signal?.aborted) {
|
||||
return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} })
|
||||
}
|
||||
if (error instanceof SubagentError) {
|
||||
switch (error.code) {
|
||||
case 'NOT_RESUMABLE':
|
||||
return err(request, { code: 'subagent-not-resumable', message: 'subagent cannot be resumed', details: { childSessionId } })
|
||||
case 'UNAUTHORIZED':
|
||||
return err(request, { code: 'subagent-unauthorized', message: 'subagent does not belong to this parent', details: { childSessionId } })
|
||||
case 'ACTIVATION_CLOSING':
|
||||
case 'DRAINING':
|
||||
return err(request, { code: 'subagent-not-delivered', message: 'message was not delivered', details: { childSessionId } })
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
|
||||
}
|
||||
|
||||
/** Verify one address against the complete durable direct-child catalog. */
|
||||
async function healthyCatalogChild(
|
||||
ctx: Context,
|
||||
parentSessionId: SessionId,
|
||||
childSessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ error?: RpcError }> {
|
||||
try {
|
||||
const entries = await ctx.subagents.listChildren(parentSessionId, signal)
|
||||
const entry = entries.find(candidate => candidate.id === childSessionId)
|
||||
if (entry === undefined || (entry.kind === 'child' && entry.mode !== 'continuable')) {
|
||||
return {
|
||||
error: {
|
||||
code: 'subagent-not-found',
|
||||
message: `session "${childSessionId}" is not a continuable direct child of "${parentSessionId}"`,
|
||||
details: { parentSessionId, childSessionId },
|
||||
},
|
||||
}
|
||||
}
|
||||
if (entry.kind === 'diagnostic') {
|
||||
return {
|
||||
error: {
|
||||
code: 'subagent-catalog-diagnostic',
|
||||
message: `subagent "${childSessionId}" is ${entry.reason}`,
|
||||
details: { parentSessionId, childSessionId, reason: entry.reason },
|
||||
},
|
||||
}
|
||||
}
|
||||
return {}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SubagentError && error.code === 'CANCELLED') {
|
||||
return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
|
||||
}
|
||||
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
||||
return {
|
||||
error: {
|
||||
code: 'subagent-not-found',
|
||||
message: `parent session "${parentSessionId}" is unavailable`,
|
||||
details: { parentSessionId, childSessionId },
|
||||
},
|
||||
}
|
||||
}
|
||||
return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Project the durable catalog onto the continuable-only browser surface. */
|
||||
function continuableCatalog(entries: readonly CoreSubagentListEntry[]): SubagentCatalogEntry[] {
|
||||
return entries.flatMap((entry): SubagentCatalogEntry[] => {
|
||||
if (entry.kind === 'diagnostic') return [entry]
|
||||
if (entry.mode !== 'continuable') return []
|
||||
return [{
|
||||
kind: 'child',
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
activity: entry.activity,
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection baseline for one history tail page: the registry's
|
||||
* watermark-cache snapshot — one fully synchronous read (no await between the
|
||||
@@ -1390,19 +1496,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
if ('error' in found) return err(request, found.error)
|
||||
// Everything below the resume above is synchronous: the page slice,
|
||||
// the seq read, and the projection walk see one un-torn session state.
|
||||
const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
|
||||
// Views are computed against the registry at pagination time; result
|
||||
// pairing scans within the page only (message-boundary pagination keeps
|
||||
// a call and its result on one page — a cross-page miss soft-falls).
|
||||
const entries: HistoryEntry[] = page.events.map((event) => {
|
||||
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
|
||||
return { event, ...view === undefined ? {} : { view } }
|
||||
})
|
||||
const page = historyPage(ctx, found.agent.session.events, beforeSeq, maxMessages)
|
||||
// Baseline rider: tail page only — loadOlder (beforeSeq present) is
|
||||
// the one path that never needs a fresh projection baseline.
|
||||
const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
|
||||
return ok(request, {
|
||||
events: entries,
|
||||
events: page.events,
|
||||
hasMore: page.hasMore,
|
||||
...projections === undefined ? {} : { projections },
|
||||
})
|
||||
@@ -1592,6 +1691,84 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
},
|
||||
|
||||
subagents: {
|
||||
async list(request, signal) {
|
||||
try {
|
||||
const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal)
|
||||
return ok(request, {
|
||||
entries: continuableCatalog(entries),
|
||||
parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SubagentError && error.code === 'CANCELLED') {
|
||||
return err(request, { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} })
|
||||
}
|
||||
return err(request, { code: 'internal', message: 'subagent catalog read failed', details: {} })
|
||||
}
|
||||
},
|
||||
|
||||
async history(request, signal) {
|
||||
const { parentSessionId, childSessionId, beforeSeq, maxMessages } = request.payload
|
||||
const verified = await healthyCatalogChild(ctx, parentSessionId, childSessionId, signal)
|
||||
if (verified.error !== undefined) return err(request, verified.error)
|
||||
try {
|
||||
const snapshot = await ctx.sessionQuery.readSession(childSessionId)
|
||||
signal?.throwIfAborted()
|
||||
if (snapshot.session.parentSession !== parentSessionId) {
|
||||
return err(request, {
|
||||
code: 'subagent-unauthorized',
|
||||
message: 'subagent parent changed during history read',
|
||||
details: { childSessionId },
|
||||
})
|
||||
}
|
||||
return ok(request, historyPage(ctx, snapshot.events, beforeSeq, maxMessages))
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) {
|
||||
return err(request, { code: 'cancelled', message: 'subagent history read was cancelled', details: {} })
|
||||
}
|
||||
if (error instanceof SessionQueryError) {
|
||||
if (error.code === 'SESSION_QUERY_ABORTED') {
|
||||
return err(request, { code: 'cancelled', message: 'subagent history read was cancelled', details: {} })
|
||||
}
|
||||
if (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
||||
return err(request, {
|
||||
code: 'subagent-not-found',
|
||||
message: 'subagent disappeared during history read',
|
||||
details: { parentSessionId, childSessionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
return err(request, { code: 'internal', message: 'subagent history read failed', details: {} })
|
||||
}
|
||||
},
|
||||
|
||||
async prompt(request, signal) {
|
||||
const { parentSessionId, childSessionId, content } = request.payload
|
||||
const parent = ctx.agents.get(parentSessionId)
|
||||
if (parent === undefined) {
|
||||
return err(request, {
|
||||
code: 'subagent-parent-unavailable',
|
||||
message: `parent session "${parentSessionId}" is not live`,
|
||||
details: { parentSessionId },
|
||||
})
|
||||
}
|
||||
const verified = await healthyCatalogChild(ctx, parentSessionId, childSessionId, signal)
|
||||
if (verified.error !== undefined) return err(request, verified.error)
|
||||
const operationSignal = signal ?? new AbortController().signal
|
||||
try {
|
||||
const messageId = await ctx.subagents.followup(
|
||||
parent,
|
||||
childSessionId,
|
||||
content,
|
||||
{ source: { kind: 'user', rpcId: request.rpcId }, signal: operationSignal },
|
||||
)
|
||||
return ok(request, { messageId })
|
||||
} catch (error: unknown) {
|
||||
return subagentPromptError(request, error, operationSignal)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
workspace: {
|
||||
list(request) {
|
||||
return Promise.resolve(ok(request, {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { SubagentsApi } from './subagents.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
@@ -19,6 +20,7 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
|
||||
export interface ApiProxy {
|
||||
sessions: SessionsApi
|
||||
subagents: SubagentsApi
|
||||
host: HostApi
|
||||
workspace: WorkspaceApi
|
||||
commands: CommandsApi
|
||||
@@ -39,6 +41,9 @@ export type {
|
||||
SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||
export type {
|
||||
SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi,
|
||||
} from './subagents.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { GoalsApi } from './goals.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
import type { CredentialsApi } from './credentials.ts'
|
||||
import type { LlmApi } from './llm.ts'
|
||||
import type { SubagentsApi } from './subagents.ts'
|
||||
import type { RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,9 @@ export interface RpcMethodMap {
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.updateQueue': SessionsApi['updateQueue']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
'subagent.list': SubagentsApi['list']
|
||||
'subagent.history': SubagentsApi['history']
|
||||
'subagent.prompt': SubagentsApi['prompt']
|
||||
'host.describe': HostApi['describe']
|
||||
'host.pickDirectory': HostApi['pickDirectory']
|
||||
'host.listDirectory': HostApi['listDirectory']
|
||||
|
||||
@@ -56,6 +56,16 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
|
||||
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({
|
||||
parentSessionId: z.string(),
|
||||
childSessionId: z.string(),
|
||||
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
|
||||
}) }),
|
||||
z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('subagent-not-delivered'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
|
||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||
]) as unknown as z.ZodType<RpcError>
|
||||
|
||||
|
||||
@@ -71,6 +71,16 @@ export interface RpcErrorDetailsMap {
|
||||
'credential-rejected': { ref: string }
|
||||
'title-invalid': { sessionId: SessionId }
|
||||
'fork-unavailable': { sessionId: SessionId }
|
||||
'subagent-parent-unavailable': { parentSessionId: SessionId }
|
||||
'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId }
|
||||
'subagent-catalog-diagnostic': {
|
||||
parentSessionId: SessionId
|
||||
childSessionId: SessionId
|
||||
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
'subagent-not-resumable': { childSessionId: SessionId }
|
||||
'subagent-unauthorized': { childSessionId: SessionId }
|
||||
'subagent-not-delivered': { childSessionId: SessionId }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
|
||||
@@ -194,10 +194,10 @@ export const toolEventViewSchema = z.discriminatedUnion('for', [
|
||||
]) as unknown as z.ZodType<ToolEventView>
|
||||
|
||||
/** One session.history item: the session event plus its optional host-computed tool view. */
|
||||
export const historyEntrySchema = z.object({
|
||||
export const historyEntrySchema: z.ZodType<Wire<HistoryEntry>> = z.object({
|
||||
event: sessionEventSchema,
|
||||
view: toolEventViewSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<HistoryEntry>>
|
||||
}) as unknown as z.ZodType<Wire<HistoryEntry>>
|
||||
|
||||
/**
|
||||
* Projection baseline passthrough: `values` stays a wide record — each value
|
||||
@@ -211,11 +211,11 @@ export const sessionProjectionsBlockSchema = z.object({
|
||||
}) as unknown as z.ZodType<SessionProjectionsBlock>
|
||||
|
||||
/** session.history response value (projections rides the tail page only). */
|
||||
export const sessionHistoryValueSchema = z.object({
|
||||
export const sessionHistoryValueSchema: z.ZodType<Wire<ResponseValue<'session.history'>>> = z.object({
|
||||
events: z.array(historyEntrySchema),
|
||||
hasMore: z.boolean(),
|
||||
projections: sessionProjectionsBlockSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
|
||||
})
|
||||
|
||||
/** session.models request payload. */
|
||||
export const sessionModelsRequestSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/** Zod schemas for the browser-safe subagent domain. */
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { contentBlockSchema, historyEntrySchema, sessionIdSchema } from './sessions.schema.ts'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SubagentListEntry } from './subagents.ts'
|
||||
|
||||
/** Healthy and diagnostic durable catalog rows. */
|
||||
export const subagentListEntrySchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('child'),
|
||||
id: sessionIdSchema,
|
||||
label: z.string(),
|
||||
activity: z.union([z.literal('running'), z.literal('inactive')]),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('diagnostic'),
|
||||
id: sessionIdSchema,
|
||||
reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
|
||||
}),
|
||||
]) satisfies z.ZodType<Wire<SubagentListEntry>>
|
||||
|
||||
/** subagent.list request payload. */
|
||||
export const subagentListRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.list'>>>
|
||||
|
||||
/** subagent.list response value. */
|
||||
export const subagentListValueSchema = z.object({
|
||||
entries: z.array(subagentListEntrySchema),
|
||||
parentAvailable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.list'>>>
|
||||
|
||||
/** subagent.history request payload. */
|
||||
export const subagentHistoryRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
childSessionId: sessionIdSchema,
|
||||
beforeSeq: z.number().int().nonnegative().optional(),
|
||||
maxMessages: z.number().int().positive().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'subagent.history'>>>
|
||||
|
||||
/** subagent.history response value. */
|
||||
export const subagentHistoryValueSchema = z.object({
|
||||
events: z.array(historyEntrySchema),
|
||||
hasMore: z.boolean(),
|
||||
}) as unknown as z.ZodType<Wire<ResponseValue<'subagent.history'>>>
|
||||
|
||||
/** subagent.prompt request payload. */
|
||||
export const subagentPromptRequestSchema = z.object({
|
||||
parentSessionId: sessionIdSchema,
|
||||
childSessionId: sessionIdSchema,
|
||||
content: z.array(contentBlockSchema),
|
||||
}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
|
||||
|
||||
const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
|
||||
|
||||
/** subagent.prompt response value. */
|
||||
export const subagentPromptValueSchema = z.object({
|
||||
messageId: messageIdSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'subagent.prompt'>>>
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Browser-safe subagent domain contract. Persisted transcript reads never
|
||||
* activate an Agent, while prompts route through the direct parent's
|
||||
* Activation-backed continuation owner.
|
||||
*/
|
||||
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
import type { HistoryEntry } from './sessions.ts'
|
||||
|
||||
/** Complete durable direct-child catalog row. */
|
||||
export type SubagentListEntry =
|
||||
| {
|
||||
kind: 'child'
|
||||
id: SessionId
|
||||
label: string
|
||||
activity: 'running' | 'inactive'
|
||||
}
|
||||
| {
|
||||
kind: 'diagnostic'
|
||||
id: SessionId
|
||||
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/** Inbox identity returned once the continuation accepts one human message. */
|
||||
export interface SubagentPromptReceipt {
|
||||
messageId: MessageId
|
||||
}
|
||||
|
||||
/** Durable parent/child address that selects subagent transport in the client. */
|
||||
export interface SubagentAddress {
|
||||
parentSessionId: SessionId
|
||||
childSessionId: SessionId
|
||||
}
|
||||
|
||||
/** Complete direct-child catalog plus the delivery-time parent availability hint. */
|
||||
export interface SubagentCatalog {
|
||||
entries: SubagentListEntry[]
|
||||
parentAvailable: boolean
|
||||
}
|
||||
|
||||
/** Subagent-domain unary methods. */
|
||||
export interface SubagentsApi {
|
||||
/**
|
||||
* Lists direct continuable children without loading either side. Parent
|
||||
* availability is a hint; prompt performs the authoritative check.
|
||||
*/
|
||||
list(
|
||||
request: RpcRequest<{ parentSessionId: SessionId }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<SubagentCatalog>>
|
||||
|
||||
/**
|
||||
* Reads one healthy catalog child's persisted raw log with ordinary
|
||||
* message-aligned pagination and render intents, without Agent activation.
|
||||
*/
|
||||
history(
|
||||
request: RpcRequest<SubagentAddress & { beforeSeq?: number; maxMessages?: number }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
|
||||
|
||||
/**
|
||||
* Delivers human content through the exact live parent's continuation
|
||||
* owner. Success identifies the accepted inbox message.
|
||||
*/
|
||||
prompt(
|
||||
request: RpcRequest<SubagentAddress & { content: ContentBlock[] }>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<SubagentPromptReceipt>>
|
||||
}
|
||||
@@ -55,6 +55,11 @@ import {
|
||||
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
|
||||
} from '../api/credentials.schema.ts'
|
||||
import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
|
||||
import {
|
||||
subagentHistoryValueSchema,
|
||||
subagentListValueSchema,
|
||||
subagentPromptValueSchema,
|
||||
} from '../api/subagents.schema.ts'
|
||||
|
||||
/**
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
@@ -86,6 +91,11 @@ export interface IApiClient {
|
||||
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
|
||||
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
||||
}
|
||||
subagents: {
|
||||
list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
|
||||
history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
|
||||
prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
|
||||
}
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
|
||||
@@ -155,6 +165,9 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.updateQueue': sessionUpdateQueueValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
'subagent.list': subagentListValueSchema,
|
||||
'subagent.history': subagentHistoryValueSchema,
|
||||
'subagent.prompt': subagentPromptValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'host.pickDirectory': hostPickDirectoryValueSchema,
|
||||
'host.listDirectory': hostListDirectoryValueSchema,
|
||||
@@ -385,6 +398,12 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
|
||||
}
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
|
||||
history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
|
||||
// A native system dialog is user-paced and may legitimately stay open
|
||||
|
||||
@@ -57,6 +57,11 @@ import {
|
||||
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
|
||||
} from '../api/credentials.schema.ts'
|
||||
import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
|
||||
import {
|
||||
subagentHistoryRequestSchema,
|
||||
subagentListRequestSchema,
|
||||
subagentPromptRequestSchema,
|
||||
} from '../api/subagents.schema.ts'
|
||||
|
||||
/**
|
||||
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
||||
@@ -87,6 +92,9 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
|
||||
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
|
||||
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
|
||||
'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
|
||||
'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
|
||||
'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
|
||||
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
|
||||
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
|
||||
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },
|
||||
|
||||
@@ -45,7 +45,10 @@ export interface Config {
|
||||
* project directory and the fallback parent for name-created Workspaces.
|
||||
*/
|
||||
export class ApiProxyService extends Service implements ApiProxy {
|
||||
static inject = ['agents', 'directoryPicker', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
|
||||
static inject = [
|
||||
'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
|
||||
'tools', 'userInteraction', 'workspace',
|
||||
]
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
@@ -54,6 +57,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
readonly subagents: ApiProxy['subagents']
|
||||
readonly workspace: ApiProxy['workspace']
|
||||
readonly host: ApiProxy['host']
|
||||
readonly commands: ApiProxy['commands']
|
||||
@@ -75,6 +79,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
this.subagents = api.subagents
|
||||
this.workspace = api.workspace
|
||||
this.host = api.host
|
||||
this.commands = api.commands
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import { SubagentError } from '@deepseek-ai/dsh-subagent'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import type { RpcRequest } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const sid = (value: string): SessionId => value as SessionId
|
||||
const PARENT = sid('parent')
|
||||
const CHILD = sid('child')
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId('subagent-rpc'), payload }
|
||||
}
|
||||
|
||||
function bench(options: {
|
||||
parentLive?: boolean
|
||||
entries?: object[]
|
||||
followupError?: Error
|
||||
listError?: Error
|
||||
readError?: Error
|
||||
historyParent?: SessionId
|
||||
} = {}) {
|
||||
const parent = { id: PARENT }
|
||||
const getAgent = vi.fn((id: SessionId) =>
|
||||
options.parentLive !== false && id === PARENT ? parent : undefined)
|
||||
const listChildren = vi.fn(() => options.listError === undefined
|
||||
? Promise.resolve(options.entries ?? [
|
||||
{ kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive' },
|
||||
])
|
||||
: Promise.reject(options.listError))
|
||||
const followup = vi.fn((
|
||||
_parent: unknown,
|
||||
_childId: SessionId,
|
||||
_content: unknown,
|
||||
_delivery: { source: { kind: string; rpcId: RpcId }; signal: AbortSignal },
|
||||
) => options.followupError === undefined
|
||||
? Promise.resolve('message-1')
|
||||
: Promise.reject(options.followupError))
|
||||
const readSession = vi.fn(() => options.readError === undefined
|
||||
? Promise.resolve({
|
||||
session: {
|
||||
version: 0, id: CHILD, createdAt: 1, parentSession: options.historyParent ?? PARENT,
|
||||
} satisfies SessionHeader,
|
||||
events: [
|
||||
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
|
||||
] as unknown as SessionEvent[],
|
||||
})
|
||||
: Promise.reject(options.readError))
|
||||
const ctx = new Context()
|
||||
ctx.provide('agents', { get: getAgent })
|
||||
ctx.provide('subagents', { listChildren, followup })
|
||||
ctx.provide('sessionQuery', { readSession })
|
||||
ctx.provide('userInteraction', { registerProvider: () => () => {} })
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp',
|
||||
})
|
||||
return { api, getAgent, listChildren, readSession, followup, parent }
|
||||
}
|
||||
|
||||
describe('subagent gateway', () => {
|
||||
it('lists the complete catalog and reports exact live-parent availability', async () => {
|
||||
const { api, listChildren } = bench({ parentLive: false, entries: [
|
||||
{ kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive' },
|
||||
{ kind: 'child', id: sid('one-shot'), mode: 'one-shot', activity: 'inactive' },
|
||||
{ kind: 'diagnostic', id: sid('bad'), reason: 'corrupt' },
|
||||
] })
|
||||
const response = await api.subagents.list(request({ parentSessionId: PARENT }))
|
||||
expect(response.rpcId).toBe('subagent-rpc')
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { parentAvailable: false, entries: [{ kind: 'child' }, { kind: 'diagnostic' }] },
|
||||
})
|
||||
expect(listChildren).toHaveBeenCalledWith(PARENT, undefined)
|
||||
})
|
||||
|
||||
it('reads a healthy direct child without looking up or activating any Agent', async () => {
|
||||
const { api, getAgent, readSession } = bench()
|
||||
const response = await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, maxMessages: 10,
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||
})
|
||||
expect(readSession).toHaveBeenCalledWith(CHILD)
|
||||
expect(getAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a diagnostic address before reading history', async () => {
|
||||
const { api, readSession } = bench({ entries: [
|
||||
{ kind: 'diagnostic', id: CHILD, reason: 'unsupported' },
|
||||
] })
|
||||
const response = await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD,
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-catalog-diagnostic',
|
||||
details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' },
|
||||
},
|
||||
})
|
||||
expect(readSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes human content through the exact live parent with rpc attribution', async () => {
|
||||
const { api, parent, followup } = bench()
|
||||
const content = [{ type: 'text' as const, text: '继续' }]
|
||||
const response = await api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, content,
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true, value: { messageId: 'message-1' },
|
||||
})
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
const [actualParent, actualChild, actualContent, delivery] = followup.mock.calls[0]!
|
||||
expect([actualParent, actualChild, actualContent]).toEqual([parent, CHILD, content])
|
||||
expect(delivery.source).toEqual({ kind: 'user', rpcId: RpcId('subagent-rpc') })
|
||||
expect(delivery.signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
it('fails before delivery when the parent is absent and maps continuation failures', async () => {
|
||||
const absent = bench({ parentLive: false })
|
||||
expect((await absent.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, content: [],
|
||||
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-parent-unavailable' } })
|
||||
expect(absent.listChildren).not.toHaveBeenCalled()
|
||||
|
||||
const failed = bench({ followupError: new SubagentError('not delivered', 'DRAINING') })
|
||||
expect((await failed.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, content: [],
|
||||
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-delivered' } })
|
||||
})
|
||||
|
||||
it('maps history disappearance and hides unexpected backend details', async () => {
|
||||
const disappeared = bench({
|
||||
readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'),
|
||||
})
|
||||
expect((await disappeared.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD,
|
||||
}))).result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-not-found',
|
||||
message: 'subagent disappeared during history read',
|
||||
details: { parentSessionId: PARENT, childSessionId: CHILD },
|
||||
},
|
||||
})
|
||||
|
||||
const catalog = bench({ listError: new Error('secret descriptor') })
|
||||
expect((await catalog.api.subagents.list(request({
|
||||
parentSessionId: PARENT,
|
||||
}))).result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'subagent catalog read failed' },
|
||||
})
|
||||
|
||||
const prompt = bench({ followupError: new Error('secret provider') })
|
||||
expect((await prompt.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, content: [],
|
||||
}))).result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'subagent prompt failed' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,7 @@ function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>>
|
||||
/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
|
||||
function scriptedApi(overrides: {
|
||||
sessions?: Partial<ApiProxy['sessions']>
|
||||
subagents?: Partial<ApiProxy['subagents']>
|
||||
host?: Partial<ApiProxy['host']>
|
||||
commands?: Partial<ApiProxy['commands']>
|
||||
skills?: Partial<ApiProxy['skills']>
|
||||
@@ -57,6 +58,12 @@ function scriptedApi(overrides: {
|
||||
cancel: r => ok(r, { accepted: true as const }),
|
||||
...overrides.sessions,
|
||||
},
|
||||
subagents: {
|
||||
list: r => ok(r, { entries: [], parentAvailable: false }),
|
||||
history: r => ok(r, { events: [], hasMore: false }),
|
||||
prompt: r => ok(r, { messageId: 'message-1' as never }),
|
||||
...overrides.subagents,
|
||||
},
|
||||
host: {
|
||||
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
|
||||
pickDirectory: r => ok(r, { path: null }),
|
||||
|
||||
@@ -103,6 +103,20 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
},
|
||||
subagents: {
|
||||
async list(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { entries: [], parentAvailable: false } } }
|
||||
},
|
||||
async history(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { events: [], hasMore: false } } }
|
||||
},
|
||||
async prompt(request) {
|
||||
return {
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { messageId: 'message-1' as never } },
|
||||
}
|
||||
},
|
||||
},
|
||||
host: {
|
||||
async describe(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
|
||||
|
||||
@@ -36,6 +36,11 @@ import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../s
|
||||
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
|
||||
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
|
||||
import { goalEditRequestSchema } from '../src/api/goals.schema.ts'
|
||||
import {
|
||||
subagentHistoryRequestSchema, subagentHistoryValueSchema, subagentListEntrySchema,
|
||||
subagentListRequestSchema, subagentListValueSchema, subagentPromptRequestSchema,
|
||||
subagentPromptValueSchema,
|
||||
} from '../src/api/subagents.schema.ts'
|
||||
|
||||
describe('RpcId', () => {
|
||||
it('brands a raw string at zero runtime cost', () => {
|
||||
@@ -75,6 +80,12 @@ describe('rpcErrorSchema', () => {
|
||||
expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error')
|
||||
expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command')
|
||||
expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-parent-unavailable', message: 'm', details: { parentSessionId: 'p' } }).code).toBe('subagent-parent-unavailable')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-not-found', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c' } }).code).toBe('subagent-not-found')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-catalog-diagnostic', message: 'm', details: { parentSessionId: 'p', childSessionId: 'c', reason: 'corrupt' } }).code).toBe('subagent-catalog-diagnostic')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-not-resumable', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-resumable')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-unauthorized', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-unauthorized')
|
||||
expect(rpcErrorSchema.parse({ code: 'subagent-not-delivered', message: 'm', details: { childSessionId: 'c' } }).code).toBe('subagent-not-delivered')
|
||||
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
|
||||
})
|
||||
|
||||
@@ -272,6 +283,34 @@ describe('sessions domain schemas', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent domain schemas', () => {
|
||||
it('validates the direct catalog and addressed history pair', () => {
|
||||
const child = { kind: 'child', id: 'c', label: 'worker', activity: 'running' }
|
||||
const diagnostic = { kind: 'diagnostic', id: 'bad', reason: 'unsupported' }
|
||||
expect(subagentListEntrySchema.parse(child)).toEqual(child)
|
||||
expect(subagentListEntrySchema.parse(diagnostic)).toEqual(diagnostic)
|
||||
expect(subagentListRequestSchema.parse({ parentSessionId: 'p' })).toEqual({ parentSessionId: 'p' })
|
||||
expect(subagentListValueSchema.parse({
|
||||
entries: [child, diagnostic], parentAvailable: true,
|
||||
}).entries).toHaveLength(2)
|
||||
expect(subagentHistoryRequestSchema.parse({
|
||||
parentSessionId: 'p', childSessionId: 'c', beforeSeq: 4, maxMessages: 2,
|
||||
}).beforeSeq).toBe(4)
|
||||
expect(() => subagentHistoryRequestSchema.parse({
|
||||
parentSessionId: 'p', childSessionId: 'c', maxMessages: 0,
|
||||
})).toThrow()
|
||||
expect(subagentHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('validates prompt content and the accepted inbox identity', () => {
|
||||
expect(subagentPromptRequestSchema.parse({
|
||||
parentSessionId: 'p', childSessionId: 'c', content: [{ type: 'text', text: '继续' }],
|
||||
}).childSessionId).toBe('c')
|
||||
expect(subagentPromptValueSchema.parse({ messageId: 'm1' }).messageId).toBe('m1')
|
||||
expect(() => subagentPromptValueSchema.parse({ taskId: 't1' })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('host domain schemas', () => {
|
||||
it('validates describe request/value', () => {
|
||||
expect(hostDescribeRequestSchema.parse({})).toEqual({})
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user