Merge branch 'master' into fix/update-explore-unknown-copy
This commit is contained in:
@@ -100,6 +100,12 @@
|
||||
path: ':memory:'
|
||||
openAt: first-search
|
||||
|
||||
# Shared projection registry: subagent catalog identity (mode/label) folds
|
||||
# through its registered units, so the `list_agents` surface below fails
|
||||
# loud without it; web layers reuse this same mount for list rows.
|
||||
- id: session-projection
|
||||
name: '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
# Session telemetry, on for every dsh mode: mirrors every session-log
|
||||
# event (assistant/chunk projected to first-of-step) plus ops markers onto
|
||||
# OTLP/HTTP log records, streaming on the batch processor's cadence
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
|
||||
@@ -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/bundle/web-app/README.md
|
||||
README.md: a835497982e41c29c5dbc287c43b0515a61f77c9
|
||||
README.zh.md: 00d8636e848a7927792dfc8ad3a460015c9e7fef
|
||||
README.md: dc35cb4b596b265b70cea81aa5d6784fc1eff65b
|
||||
README.zh.md: 0ffc5cdaf1a98e5df11ef042c7d15a994515c170
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context.
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's own `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin owns what used to be launcher code: it resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports (workspace knowledge of this bundle, never user config), mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows; [`dsh-headless`](../headless/README.md) layers on top, silences the URL line, and disables the surface context.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包自己的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件接管了原先属于启动器的代码:它通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist(这是本组合包的 workspace 知识,绝不是用户配置),在其上挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上;[`dsh-headless`](../headless/README.md) 再叠加一层,关闭 URL 行并禁用表层上下文。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -45,9 +45,6 @@
|
||||
# `dshClient` rows are the browser roster the modules node half scans into
|
||||
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
|
||||
- insert:
|
||||
- id: session-projection
|
||||
name: '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
- id: code-runtime
|
||||
name: '@deepseek-ai/dsh-code-runtime-worker'
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
|
||||
@@ -191,6 +191,14 @@
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
/* The column scrolls on ONE axis. Stating `hidden` rather than leaving the
|
||||
initial `visible` is what removes the horizontal bar: a box that scrolls in
|
||||
one axis computes `visible` to `auto` in the other, so any bleed becomes
|
||||
user-scrollable. `.heroGlow` bleeds by construction (1051/776 of the hero
|
||||
box), which put a horizontal scrollbar under every center column narrower
|
||||
than the glow. Clipping is unchanged — `overflow-y: auto` already made this
|
||||
a scroll container that clips both axes, so this only takes away the bar. */
|
||||
overflow-x: hidden;
|
||||
/* Reserved unconditionally: the composer seat rides this box's content box in
|
||||
Chat and its padding box under a view's composer overlay, so an `auto`
|
||||
gutter moves the input card sideways by the bar's width whenever the two
|
||||
|
||||
@@ -938,7 +938,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>',
|
||||
jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents from the\n * live-preferred session corpus without loading or resuming an Agent. Session\n * query supplies lineage, candidate order, event reads, and live state; this\n * service interprets descriptor mode, activity, and per-child diagnostics\n * without consulting Agent registrations, Activations, or providers.\n *\n * The trace and exact descriptor read receive `signal`; the full event-list\n * read has no signal parameter, so the scan rechecks cancellation around\n * every await and between candidates. Query rejections that settle after an\n * abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded where supported and\n * observed around every query await.\n * @returns children and per-child diagnostics in stable trace order.\n * @throws {@link SubagentError} when session query is unavailable or the\n * caller cancels the scan.\n */',
|
||||
jsDoc: '/**\n * Enumerate the parent\'s direct session-backed subagents without loading or\n * resuming an Agent and without any query seam: the listing merges the live\n * session store with optional session persistence (live-preferred) and\n * serves each child\'s durable mode/label from the registered `subagent`\n * projection unit down a three-rung ladder — the registry\'s watermark\n * snapshot for a live child; for a cold one, a durable projection-cache\n * row when the optional cache serves an own-suffix identity (its `seq`\n * gate proves the value postdates the fork seed, where a child\'s own\n * descriptor is immutable once appended), else one persistence inspection\n * folded through the registry. The\n * projection fold is the single classification authority; per-child\n * diagnostics relay a fold that served no identity or a failed inspection,\n * never a list-time descriptor parse. Absent persistence, enumeration is\n * live-only (a cold child cannot be resumed then either, so its absence is\n * capability absence, not an error). This service consults no Agent\n * registrations, Activations, or providers.\n *\n * Every persistence read receives `signal`, and the listing rechecks\n * cancellation around each of those awaits. Read rejections that settle\n * after an abort become a stable `SubagentError` with code `CANCELLED`.\n * @param parentSessionId - parent session whose direct children are listed.\n * @param signal - caller-owned cancellation forwarded to persistence reads\n * and observed around every read await.\n * @returns children and per-child diagnostics ordered by `createdAt`, then id.\n * @throws {@link SubagentError} when the projection registry or the session\n * store is not mounted, or the caller cancels the listing.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'registerProvider(provider: SubagentProvider): () => void',
|
||||
|
||||
@@ -527,6 +527,28 @@ function detachedProjectionsFor(
|
||||
return registry.restore({}, events, 0).snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort projections for one subagent history page, fail-soft like
|
||||
* {@link listProjectionsFor}: a registered unit throwing on a corrupt payload
|
||||
* never blocks transcript reading — the page is served without the block.
|
||||
* @param ctx - context carrying the logger for the degradation warning.
|
||||
* @param childSessionId - the child whose page is being decorated.
|
||||
* @param compute - the arm-specific fold (live watermark or detached restore).
|
||||
* @returns the projections block, or undefined when the fold failed.
|
||||
*/
|
||||
function subagentHistoryProjections(
|
||||
ctx: Context,
|
||||
childSessionId: SessionId,
|
||||
compute: () => SessionProjectionsBlock | undefined,
|
||||
): SessionProjectionsBlock | undefined {
|
||||
try {
|
||||
return compute()
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Map continuation admission failures without exposing provider details. */
|
||||
function subagentPromptError(
|
||||
request: RpcRequest<{ childSessionId: SessionId }>,
|
||||
@@ -567,6 +589,15 @@ function subagentPromptError(
|
||||
return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
|
||||
}
|
||||
|
||||
/** Stable RPC face of the missing projections capability, shared by every catalog read path. */
|
||||
function projectionsUnavailableError(): RpcError {
|
||||
return {
|
||||
code: 'internal',
|
||||
message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
details: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify one address and mode against the complete direct-child catalog. */
|
||||
async function catalogChild(
|
||||
ctx: Context,
|
||||
@@ -600,19 +631,11 @@ async function catalogChild(
|
||||
}
|
||||
return { entry }
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted
|
||||
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
||||
if (signal?.aborted || (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}" was not found`,
|
||||
details: { parentSessionId, childSessionId },
|
||||
},
|
||||
}
|
||||
if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
|
||||
return { error: projectionsUnavailableError() }
|
||||
}
|
||||
return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
|
||||
}
|
||||
@@ -925,28 +948,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the session's own suffix carries the durable subagent discriminator. */
|
||||
function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean {
|
||||
const events = session.events
|
||||
// Indexed scan from the own-suffix start: slicing copies the whole suffix
|
||||
// on every Agent-bound RPC, including each `session.prompt` on long
|
||||
// transcripts.
|
||||
for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) {
|
||||
if (events[index]?.type === 'subagent/descriptor') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Host interaction cannot claim a durably classified subagent or an
|
||||
* Agent created through its live parent. The runtime-owner arm also covers
|
||||
* descriptor-less child publication windows and older stored headers.
|
||||
* Generic Host interaction cannot claim a durably classified subagent
|
||||
* (`origin: 'subagent'` in the header) or an Agent runtime-owned by its
|
||||
* live parent.
|
||||
*/
|
||||
function hasSubagentOwner(
|
||||
session: Pick<Session, 'events' | 'header'>,
|
||||
session: Pick<Session, 'header'>,
|
||||
agent: Agent | undefined,
|
||||
): boolean {
|
||||
if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true
|
||||
if (session.header.origin === 'subagent') return true
|
||||
const parentId = session.header.parentSession
|
||||
if (parentId === undefined || agent === undefined) return false
|
||||
const parent = ctx.agents.get(parentId)
|
||||
@@ -1002,7 +1013,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
resume = (async () => {
|
||||
try {
|
||||
const inspected = await inspectServable(sessionId)
|
||||
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
|
||||
if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
|
||||
throw new SubagentSessionOwnership(sessionId)
|
||||
}
|
||||
const publishedSession = ctx.sessions.get(sessionId)
|
||||
@@ -1121,7 +1132,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Ownership first: explicit-id adoption of a session-backed
|
||||
// subagent must answer `agent-busy` regardless of the requested
|
||||
// cwd (the api/commands.ts contract), not a cwd conflict.
|
||||
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
|
||||
if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
|
||||
throw new SubagentSessionOwnership(sessionId)
|
||||
}
|
||||
if (inspected.meta.cwd !== cwd) {
|
||||
@@ -1903,15 +1914,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted
|
||||
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
||||
if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'subagent catalog read was cancelled',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
|
||||
return err(request, projectionsUnavailableError())
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'subagent catalog read failed',
|
||||
@@ -1928,44 +1940,65 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
parentSessionId, childSessionId, mode,
|
||||
}, 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 },
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages)
|
||||
const projections = beforeSeq === undefined
|
||||
? detachedProjectionsFor(ctx, snapshot.events)
|
||||
// The generic-history data plane: an attached child serves its
|
||||
// in-memory snapshot and the registry's live watermark projections; a
|
||||
// cold child is one persistence inspection plus a detached fold.
|
||||
let header: SessionHeader
|
||||
let events: SessionEvent[]
|
||||
let projections: SessionProjectionsBlock | undefined
|
||||
const attached = ctx.sessions.get(childSessionId)
|
||||
if (attached !== undefined) {
|
||||
header = attached.header
|
||||
events = [...attached.events]
|
||||
projections = beforeSeq === undefined
|
||||
? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached))
|
||||
: undefined
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
||||
} else {
|
||||
try {
|
||||
const inspected = await inspectServable(childSessionId)
|
||||
header = inspected.meta
|
||||
events = inspected.events
|
||||
projections = beforeSeq === undefined
|
||||
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
|
||||
: undefined
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'subagent history read was cancelled',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (error instanceof SessionNotFound) {
|
||||
return err(request, {
|
||||
code: 'subagent-not-found',
|
||||
message: 'subagent disappeared during history read',
|
||||
details: { parentSessionId, childSessionId },
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'subagent history read was cancelled',
|
||||
code: 'internal',
|
||||
message: 'subagent history read failed',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (error instanceof SessionQueryError
|
||||
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
||||
return err(request, {
|
||||
code: 'subagent-not-found',
|
||||
message: 'subagent disappeared during history read',
|
||||
details: { parentSessionId, childSessionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'subagent history read failed',
|
||||
code: 'cancelled',
|
||||
message: 'subagent history read was cancelled',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (header.parentSession !== parentSessionId) {
|
||||
return err(request, {
|
||||
code: 'subagent-unauthorized',
|
||||
message: 'subagent parent changed during history read',
|
||||
details: { childSessionId },
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, events, beforeSeq, maxMessages)
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
},
|
||||
|
||||
async prompt(request, signal) {
|
||||
|
||||
@@ -70,7 +70,8 @@ export interface SubagentsApi {
|
||||
): Promise<RpcResponse<SubagentCatalog>>
|
||||
|
||||
/**
|
||||
* Reads one healthy catalog child's persisted raw log with ordinary
|
||||
* Reads one healthy catalog child's transcript — the in-memory snapshot of
|
||||
* a live child, the persisted log of a cold one — with ordinary
|
||||
* message-aligned pagination and render intents, without Agent activation.
|
||||
*/
|
||||
history(
|
||||
|
||||
@@ -190,6 +190,7 @@ describe('subagent ownership fence', () => {
|
||||
const meta = header('session-child', 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
seedLength: 0,
|
||||
origin: 'subagent',
|
||||
})
|
||||
const events = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
@@ -245,6 +246,47 @@ describe('subagent ownership fence', () => {
|
||||
expect(inspect).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const sessionId = sid('session-legacy-child')
|
||||
const meta = header('session-legacy-child', 1000, {
|
||||
parentSession: sid('session-parent'),
|
||||
seedLength: 0,
|
||||
})
|
||||
const events = [
|
||||
{
|
||||
type: 'subagent/descriptor',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
|
||||
},
|
||||
] as SessionEvent[]
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve([meta]),
|
||||
inspect: () => Promise.resolve({ meta, events }),
|
||||
locate: () => undefined,
|
||||
} as never)
|
||||
// Pre-#1569 stores classify a child only through the descriptor event and
|
||||
// carry no header `origin`; the pre-release decision stops recognizing
|
||||
// them, so the ownership fence lets generic resume reach the registry
|
||||
// instead of answering `agent-busy`.
|
||||
const resume = vi.spyOn(ctx.agents, 'resume')
|
||||
.mockRejectedValue(new Error('registry unavailable in this bench'))
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const prompt = await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'follow up' }],
|
||||
}))
|
||||
expect(resume).toHaveBeenCalledTimes(1)
|
||||
expect(prompt.result.ok).toBe(false)
|
||||
if (!prompt.result.ok) expect(prompt.result.error.code).toBe('internal')
|
||||
})
|
||||
|
||||
it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -21,7 +20,12 @@ function bench(options: {
|
||||
entries?: object[]
|
||||
followupError?: Error
|
||||
listError?: Error
|
||||
readError?: Error
|
||||
/** Persistence forgets the child entirely (the vanished-mid-read race). */
|
||||
storedChild?: false
|
||||
/** Attach the child to the live session store instead of persistence only. */
|
||||
liveChild?: true
|
||||
/** Every registered projection unit throws on this child's payloads. */
|
||||
projectionsThrow?: true
|
||||
historyParent?: SessionId
|
||||
} = {}) {
|
||||
const parent = { id: PARENT }
|
||||
@@ -49,25 +53,44 @@ function bench(options: {
|
||||
) => 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 childHeader = {
|
||||
version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
|
||||
} satisfies SessionHeader
|
||||
const childEvents = [
|
||||
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
|
||||
] as unknown as SessionEvent[]
|
||||
const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents }))
|
||||
const liveBlock = { values: {}, asOfSeq: 3 }
|
||||
const coldBlock = { values: {}, asOfSeq: 0 }
|
||||
const snapshot = vi.fn(() => {
|
||||
if (options.projectionsThrow === true) throw new Error('hostile unit')
|
||||
return liveBlock
|
||||
})
|
||||
const restore = vi.fn(() => {
|
||||
if (options.projectionsThrow === true) throw new Error('hostile unit')
|
||||
return { snapshot: coldBlock }
|
||||
})
|
||||
const ctx = new Context()
|
||||
ctx.provide('agents', { get: getAgent })
|
||||
ctx.provide('subagents', { listChildren, followup })
|
||||
ctx.provide('sessionQuery', { readSession })
|
||||
ctx.provide('sessions', {
|
||||
get: (id: SessionId) => options.liveChild === true && id === CHILD
|
||||
? { id: CHILD, header: childHeader, events: childEvents }
|
||||
: undefined,
|
||||
})
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve(options.storedChild === false ? [] : [childHeader]),
|
||||
inspect,
|
||||
locate: () => undefined,
|
||||
})
|
||||
// The gateway's own projection push feed subscribes at construction; the
|
||||
// no-op disposer keeps that seam quiet while these tests pin history reads.
|
||||
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
|
||||
ctx.provide('userInteraction', { registerProvider: () => () => {} })
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp',
|
||||
})
|
||||
return { api, getAgent, listChildren, readSession, followup, parent }
|
||||
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
|
||||
}
|
||||
|
||||
describe('subagent gateway', () => {
|
||||
@@ -113,7 +136,7 @@ describe('subagent gateway', () => {
|
||||
})
|
||||
|
||||
it('reads a healthy direct child without looking up or activating any Agent', async () => {
|
||||
const { api, getAgent, readSession } = bench()
|
||||
const { api, getAgent, inspect, restore } = bench()
|
||||
const response = await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
|
||||
}))
|
||||
@@ -121,27 +144,65 @@ describe('subagent gateway', () => {
|
||||
ok: true,
|
||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||
})
|
||||
expect(readSession).toHaveBeenCalledWith(CHILD)
|
||||
expect(inspect).toHaveBeenCalledWith(CHILD)
|
||||
expect(restore).toHaveBeenCalledTimes(1)
|
||||
expect(getAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves a live child from the in-memory snapshot and the watermark projections', async () => {
|
||||
const { api, inspect, snapshot, restore } = bench({ liveChild: true })
|
||||
const response = await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))
|
||||
expect(response.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: false, projections: { asOfSeq: 3 } },
|
||||
})
|
||||
expect(snapshot).toHaveBeenCalledTimes(1)
|
||||
expect(restore).not.toHaveBeenCalled()
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves the page without projections when a hostile unit breaks the fold', async () => {
|
||||
const cold = bench({ projectionsThrow: true })
|
||||
const coldResponse = await cold.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))
|
||||
expect(coldResponse.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||
})
|
||||
if (coldResponse.result.ok) expect('projections' in coldResponse.result.value).toBe(false)
|
||||
|
||||
const live = bench({ projectionsThrow: true, liveChild: true })
|
||||
const liveResponse = await live.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))
|
||||
expect(liveResponse.result).toMatchObject({
|
||||
ok: true,
|
||||
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
|
||||
})
|
||||
if (liveResponse.result.ok) expect('projections' in liveResponse.result.value).toBe(false)
|
||||
expect(live.snapshot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads one-shot history and rejects an address with the wrong mode', async () => {
|
||||
const oneShot = {
|
||||
kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}
|
||||
const { api, readSession } = bench({ entries: [oneShot] })
|
||||
const { api, inspect } = bench({ entries: [oneShot] })
|
||||
expect((await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot',
|
||||
}))).result).toMatchObject({ ok: true })
|
||||
expect((await api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } })
|
||||
expect(readSession).toHaveBeenCalledTimes(1)
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a diagnostic address before reading history', async () => {
|
||||
const { api, readSession } = bench({ entries: [
|
||||
const { api, inspect } = bench({ entries: [
|
||||
{ kind: 'diagnostic', id: CHILD, reason: 'unsupported' },
|
||||
] })
|
||||
const response = await api.subagents.history(request({
|
||||
@@ -154,7 +215,34 @@ describe('subagent gateway', () => {
|
||||
details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' },
|
||||
},
|
||||
})
|
||||
expect(readSession).not.toHaveBeenCalled()
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps the missing projections capability to one wire face on list, history, and prompt', async () => {
|
||||
const listError = () => new SubagentError(
|
||||
'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
|
||||
)
|
||||
const expected = {
|
||||
code: 'internal',
|
||||
message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
}
|
||||
|
||||
const list = bench({ listError: listError() })
|
||||
expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result)
|
||||
.toMatchObject({ ok: false, error: expected })
|
||||
|
||||
const history = bench({ listError: listError() })
|
||||
expect((await history.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))).result).toMatchObject({ ok: false, error: expected })
|
||||
expect(history.inspect).not.toHaveBeenCalled()
|
||||
|
||||
const prompt = bench({ listError: listError() })
|
||||
expect((await prompt.api.subagents.prompt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
|
||||
}), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected })
|
||||
expect(prompt.followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes human content through the exact live parent with rpc attribution', async () => {
|
||||
@@ -193,9 +281,7 @@ describe('subagent gateway', () => {
|
||||
})
|
||||
|
||||
it('maps history disappearance and hides unexpected backend details', async () => {
|
||||
const disappeared = bench({
|
||||
readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'),
|
||||
})
|
||||
const disappeared = bench({ storedChild: false })
|
||||
expect((await disappeared.api.subagents.history(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
|
||||
}))).result).toMatchObject({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionQueryService, {
|
||||
SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
|
||||
type SessionEventSurface,
|
||||
@@ -1114,6 +1114,24 @@ describe('session-query exact reads', () => {
|
||||
await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
})
|
||||
|
||||
it('wraps persisted corruption as SESSION_QUERY_CORRUPT_SESSION with its cause preserved', async () => {
|
||||
const durable = header('durable-corrupt')
|
||||
TestPersistence.reset([{ meta: durable, events: eventLog() }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
const corruption = new SessionPersistenceCorruptionError(
|
||||
'stored prefix failed validation',
|
||||
{ cause: new Error('torn final record') },
|
||||
)
|
||||
TestPersistence.inspectFailure = corruption
|
||||
|
||||
await expect(ctx.sessionQuery.readSession(durable.id)).rejects.toMatchObject({
|
||||
code: 'SESSION_QUERY_CORRUPT_SESSION',
|
||||
message: `stored session "${durable.id}" is corrupt: stored prefix failed validation`,
|
||||
cause: corruption,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports absent sessions, persisted load failures, and persisted header conflicts', async () => {
|
||||
const durable = header('durable')
|
||||
TestPersistence.reset([{ meta: durable, events: eventLog() }])
|
||||
|
||||
@@ -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/subagent/subagent/README.md
|
||||
README.md: 08b6175e018db072b99490a25b8df887bb89eb47
|
||||
README.zh.md: 435be7660b3f004a1f0bcb59a8d9a74ac8e8aae3
|
||||
README.md: 9d2e38c8730f7b7f26e690aa878a4466fa7c2829
|
||||
README.zh.md: 341c18617af4d040ec44814fac1ec4502d9b8902
|
||||
|
||||
@@ -21,7 +21,7 @@ The [subagent family overview](../README.md) maps implementations and model-faci
|
||||
| `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. |
|
||||
| `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. |
|
||||
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
|
||||
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
|
||||
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, ordered by `createdAt` then id, without loading or resuming them. Reads the live session store and optional session persistence directly (live-only enumeration when persistence is absent) and requires the mounted `sessionProjections` registry; it does not require `ctx.agents`, the continuation manager, or any query service. |
|
||||
|
||||
`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
|
||||
|
||||
@@ -78,13 +78,13 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
|
||||
|
||||
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority.
|
||||
|
||||
When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn. While that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata.
|
||||
When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws.
|
||||
|
||||
`registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately.
|
||||
|
||||
## Collection model
|
||||
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Each healthy row derives its read-time `hasChildren` hint from traced direct-descendant headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
|
||||
|
||||
Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
| `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 |
|
||||
| `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 |
|
||||
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
|
||||
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
|
||||
| `listChildren(parentSessionId, signal?)` | 按 `createdAt` 再按 id 的顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic,且不会加载或恢复它们。直接读取在线会话存储与可选的会话持久化(持久化缺席时仅枚举在线 child),并要求已挂载 `sessionProjections` 注册表;不要求 `ctx.agents`、继续执行管理器或任何查询服务。 |
|
||||
|
||||
`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose(资源释放)子 agent。
|
||||
|
||||
@@ -78,13 +78,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。
|
||||
|
||||
当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界。在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。
|
||||
当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中过时的身份而非永久滞留——绝不抛错。
|
||||
|
||||
`registerContinuableSetup()` 允许可选包添加子级作用域能力,而无需让继续执行管理器知道这些能力的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。
|
||||
|
||||
## 收集模型
|
||||
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个健康条目都会根据追踪结果中携带持久化 `origin: 'subagent'` 的直接后代 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
|
||||
|
||||
可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -50,10 +50,10 @@
|
||||
"@deepseek-ai/dsh-session-persistence": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-query": {
|
||||
"@deepseek-ai/dsh-session-projection": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-projection": {
|
||||
"@deepseek-ai/dsh-session-projection-cache": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-tasks": {
|
||||
@@ -68,8 +68,10 @@
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
* @module @deepseek-ai/dsh-subagent/client
|
||||
*/
|
||||
|
||||
export type { SubagentTimingProjection } from './projection-types.ts'
|
||||
export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
* continuation manager holds their `AgentHandle` directly and orders every turn
|
||||
* through the child's own inbox, so providers contribute only the detached
|
||||
* creation spec and see no handle, turn, or teardown. Direct-child discovery
|
||||
* independently interprets the optional session-query corpus and does not
|
||||
* require that continuation runtime.
|
||||
* reads the live session store and optional session persistence directly and
|
||||
* does not require that continuation runtime.
|
||||
*
|
||||
* Same-process providers are trusted typed collaborators. Requests, provider
|
||||
* descriptors, results, and lifecycle payloads are borrowed immutable values;
|
||||
@@ -65,7 +65,7 @@ import type { ContinuableSetupContribution } from './activation-setup-registry.t
|
||||
import { listChildren as listSubagentChildren } from './list-children.ts'
|
||||
import type { SubagentListEntry } from './list-children.ts'
|
||||
import { snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
import { subagentTimingProjectionDefinition } from './projection.ts'
|
||||
import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
|
||||
|
||||
export * from './out-of-process.ts'
|
||||
export { SubagentRunId } from './types.ts'
|
||||
@@ -118,7 +118,7 @@ export type {
|
||||
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
export type { SubagentListEntry } from './list-children.ts'
|
||||
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
export type { SubagentTimingProjection } from './projection-types.ts'
|
||||
export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -190,6 +190,7 @@ export class SubagentService extends Service {
|
||||
})
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition)
|
||||
projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -283,22 +284,32 @@ export class SubagentService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate the parent's direct session-backed subagents from the
|
||||
* live-preferred session corpus without loading or resuming an Agent. Session
|
||||
* query supplies lineage, candidate order, event reads, and live state; this
|
||||
* service interprets descriptor mode, activity, and per-child diagnostics
|
||||
* without consulting Agent registrations, Activations, or providers.
|
||||
* Enumerate the parent's direct session-backed subagents without loading or
|
||||
* resuming an Agent and without any query seam: the listing merges the live
|
||||
* session store with optional session persistence (live-preferred) and
|
||||
* serves each child's durable mode/label from the registered `subagent`
|
||||
* projection unit down a three-rung ladder — the registry's watermark
|
||||
* snapshot for a live child; for a cold one, a durable projection-cache
|
||||
* row when the optional cache serves an own-suffix identity (its `seq`
|
||||
* gate proves the value postdates the fork seed, where a child's own
|
||||
* descriptor is immutable once appended), else one persistence inspection
|
||||
* folded through the registry. The
|
||||
* projection fold is the single classification authority; per-child
|
||||
* diagnostics relay a fold that served no identity or a failed inspection,
|
||||
* never a list-time descriptor parse. Absent persistence, enumeration is
|
||||
* live-only (a cold child cannot be resumed then either, so its absence is
|
||||
* capability absence, not an error). This service consults no Agent
|
||||
* registrations, Activations, or providers.
|
||||
*
|
||||
* The trace and exact descriptor read receive `signal`; the full event-list
|
||||
* read has no signal parameter, so the scan rechecks cancellation around
|
||||
* every await and between candidates. Query rejections that settle after an
|
||||
* abort become a stable `SubagentError` with code `CANCELLED`.
|
||||
* Every persistence read receives `signal`, and the listing rechecks
|
||||
* cancellation around each of those awaits. Read rejections that settle
|
||||
* after an abort become a stable `SubagentError` with code `CANCELLED`.
|
||||
* @param parentSessionId - parent session whose direct children are listed.
|
||||
* @param signal - caller-owned cancellation forwarded where supported and
|
||||
* observed around every query await.
|
||||
* @returns children and per-child diagnostics in stable trace order.
|
||||
* @throws {@link SubagentError} when session query is unavailable or the
|
||||
* caller cancels the scan.
|
||||
* @param signal - caller-owned cancellation forwarded to persistence reads
|
||||
* and observed around every read await.
|
||||
* @returns children and per-child diagnostics ordered by `createdAt`, then id.
|
||||
* @throws {@link SubagentError} when the projection registry or the session
|
||||
* store is not mounted, or the caller cancels the listing.
|
||||
*/
|
||||
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]> {
|
||||
return listSubagentChildren(this.ctx, parentSessionId, signal)
|
||||
|
||||
@@ -1,33 +1,47 @@
|
||||
/**
|
||||
* Read-only interpretation of session-query lineage as durable subagent
|
||||
* children. Only descendants with durable `origin: 'subagent'` enter per-child
|
||||
* inspection. The module owns no catalog state and does not consult Activation,
|
||||
* Agent-registry, continuation-manager, or provider state. A child's descriptor
|
||||
* distinguishes one-shot work from a continuable conversation.
|
||||
* Read-only enumeration of one parent's durable subagent children straight
|
||||
* from the live session store and optional session persistence — no query
|
||||
* seam. Candidates are the live-preferred merge of both listings filtered to
|
||||
* durable `origin: 'subagent'` under the parent; each child's mode/label is
|
||||
* the registered `subagent` projection unit's value, resolved down a
|
||||
* three-rung ladder: the registry's watermark cache for a live child, a
|
||||
* durable projection-cache row when it serves an own-suffix identity (the
|
||||
* seq gate), and one persistence inspection folded through the registry
|
||||
* otherwise, validated against the enumerated lifecycle. The projection
|
||||
* fold is the single
|
||||
* classification authority — this module parses no descriptor itself. Absent
|
||||
* persistence, enumeration is live-only: a cold child is unreachable for
|
||||
* resume anyway, so its absence is capability absence, not an error. The
|
||||
* module owns no catalog state and does not consult Activation,
|
||||
* Agent-registry, continuation-manager, or provider state.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionQueryService, SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import type SubagentService from './index.ts'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import { SubagentError } from './error.ts'
|
||||
import { foldSubagentDescriptor } from './descriptor.ts'
|
||||
|
||||
type SessionQueryRuntime = Pick<
|
||||
typeof import('@deepseek-ai/dsh-session-query'),
|
||||
'assertSessionHeadersCompatible' | 'SessionQueryError'
|
||||
>
|
||||
import type { SubagentIdentityProjection } from './projection-types.ts'
|
||||
|
||||
/**
|
||||
* One entry of a {@link listChildren} result in trace candidate order. Only a
|
||||
* candidate whose durable header has `origin: 'subagent'` is inspected. A
|
||||
* valid descriptor produces a `child`, a per-child inspection failure produces
|
||||
* a `diagnostic`, and a candidate without its own descriptor is omitted.
|
||||
* Healthy rows include a one-level, origin-classified descendant hint.
|
||||
* Diagnostics are transient query results, never session events or catalog
|
||||
* state, and never expose model-hidden descriptor content.
|
||||
* Concurrent cold inspections per listing; a constant because it bounds one
|
||||
* read-only scan of local media, not deployment behavior. Should a networked
|
||||
* persistence backend appear, promote it to a validated `Config` field.
|
||||
*/
|
||||
const COLD_READ_CONCURRENCY = 4
|
||||
|
||||
/**
|
||||
* One entry of a {@link listChildren} result, ordered by header `createdAt`
|
||||
* with ties broken on id. Only a candidate whose durable header has
|
||||
* `origin: 'subagent'` is interpreted. A served `subagent` projection value
|
||||
* produces a `child`; a settled candidate whose fold served no identity
|
||||
* produces a `diagnostic`; a running candidate without one is omitted — its
|
||||
* descriptor may not be appended yet (the creation window). Diagnostics
|
||||
* relay the projection fold's outcome or a failed read, never a per-child
|
||||
* event scan, and never expose model-hidden descriptor content.
|
||||
*/
|
||||
export type SubagentListEntry =
|
||||
| {
|
||||
@@ -35,7 +49,7 @@ export type SubagentListEntry =
|
||||
/** The durable child session id, stable across Activations. */
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* Corpus snapshot activity: `running` means the logical record is live in
|
||||
* Store snapshot activity: `running` means the logical record is live in
|
||||
* `ctx.sessions`; `inactive` means it exists only in persistence. Neither
|
||||
* encodes a durable outcome, and a continuable child may still reject
|
||||
* delivery as an ownership conflict.
|
||||
@@ -59,179 +73,263 @@ export type SubagentListEntry =
|
||||
)
|
||||
| {
|
||||
readonly kind: 'diagnostic'
|
||||
/** The traced candidate's session id. */
|
||||
/** The candidate's session id. */
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* Why the candidate was omitted: `corrupt` for invalid surfaces, header
|
||||
* conflicts, or malformed/duplicated descriptors; `unsupported` for an
|
||||
* unknown descriptor version; `unavailable` when the child disappeared or
|
||||
* its per-child read hit a persistence failure.
|
||||
* Why the candidate has no `child` row: `corrupt` for a settled candidate
|
||||
* whose projection fold served no identity (a missing, malformed, or
|
||||
* unrecognized-version descriptor — deliberately undistinguished), and
|
||||
* for any candidate whose log makes a registered unit's fold or schema
|
||||
* throw (deterministic data damage, contained per child); `unavailable`
|
||||
* when the candidate's persistence inspection failed (retried on the
|
||||
* next listing). `unsupported` is kept for consumers already routing on
|
||||
* it but is no longer produced.
|
||||
*/
|
||||
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret one parent's origin-classified direct descendants as session-backed
|
||||
* subagents without loading or resuming an Agent. Ordinary forks are skipped
|
||||
* before per-child event inspection.
|
||||
* @see {@link SubagentService.listChildren} for the public cancellation and
|
||||
* failure contract.
|
||||
* @param ctx - context carrying the optional session-query service.
|
||||
* Enumerate one parent's origin-classified direct children from the
|
||||
* live-preferred merge of `ctx.sessions` and optional session persistence,
|
||||
* serving each identity from the `subagent` projection unit: the registry's
|
||||
* watermark snapshot for a live child; for a cold one, a durable
|
||||
* projection-cache row when it serves an own-suffix identity (the seq gate),
|
||||
* else one bounded-concurrency persistence inspection folded through the
|
||||
* registry.
|
||||
* @see SubagentService.listChildren for the public cancellation and failure contract.
|
||||
* @param ctx - context carrying the session store, the projection registry,
|
||||
* optional persistence, and the optional projection cache.
|
||||
* @param parentSessionId - parent session whose direct children are listed.
|
||||
* @param signal - caller-owned cancellation.
|
||||
* @returns children and per-child diagnostics in stable trace order.
|
||||
* @throws {@link SubagentError} when session query is unavailable or
|
||||
* the caller cancels the scan.
|
||||
* @param signal - caller-owned cancellation observed around every persistence read.
|
||||
* @returns children and per-child diagnostics ordered by `createdAt`, then id.
|
||||
* @throws {@link SubagentError} when the projection registry or the session
|
||||
* store is not mounted, or the caller cancels the listing.
|
||||
*/
|
||||
export async function listChildren(
|
||||
ctx: Context,
|
||||
parentSessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): ReturnType<SubagentService['listChildren']> {
|
||||
const query = ctx.get('sessionQuery')
|
||||
if (query === undefined) {
|
||||
): Promise<SubagentListEntry[]> {
|
||||
const projections = ctx.get('sessionProjections')
|
||||
// Checked before any read, even with zero candidates: mode/label are the
|
||||
// row's strong contract, so a missing fold capability is a deterministic
|
||||
// deployment configuration error, never an empty success.
|
||||
if (projections === undefined) {
|
||||
throw new SubagentError(
|
||||
'listing subagents requires session query (load a dsh-session-query backend)',
|
||||
'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE',
|
||||
'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
// Strict global read, never the `ctx.sessions` property proxy: the proxy is
|
||||
// caller-scope bound, so a consumer plugin without its own `sessions`
|
||||
// injection (the model-facing tool, the API proxy) would throw on access.
|
||||
const sessions = ctx.get('sessions')
|
||||
if (sessions === undefined) {
|
||||
throw new SubagentError(
|
||||
'listing subagents requires the session store (load @deepseek-ai/dsh-session)',
|
||||
'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
// Keep runtime values behind the listing-only boundary so ordinary
|
||||
// subagent imports and control operations do not evaluate the optional peer.
|
||||
const queryRuntime: SessionQueryRuntime = await import('@deepseek-ai/dsh-session-query')
|
||||
assertListingNotCancelled(signal)
|
||||
const trace = await runListingQuery(
|
||||
() => query.traceSession(parentSessionId, signal),
|
||||
signal,
|
||||
)
|
||||
const entries: SubagentListEntry[] = []
|
||||
for (const node of trace.descendants) {
|
||||
if (node.session.header.origin !== 'subagent') continue
|
||||
const hasChildren = node.descendants.some(
|
||||
descendant => descendant.session.header.origin === 'subagent',
|
||||
)
|
||||
const entry = await inspectChild(
|
||||
query, queryRuntime, parentSessionId, node.session, hasChildren, signal,
|
||||
)
|
||||
// Cancellation can race the inspection's last checkpoint or diagnostic
|
||||
// mapping; do not return success or begin another candidate afterward.
|
||||
assertListingNotCancelled(signal)
|
||||
if (entry !== undefined) entries.push(entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Interpret one traced direct-child record as a child, diagnostic, or exclusion. */
|
||||
async function inspectChild(
|
||||
query: SessionQueryService,
|
||||
queryRuntime: SessionQueryRuntime,
|
||||
parentSessionId: SessionId,
|
||||
candidate: SessionRecord,
|
||||
hasChildren: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SubagentListEntry | undefined> {
|
||||
const childId = candidate.header.id
|
||||
try {
|
||||
const records = await runListingQuery(() => query.listEvents(childId), signal)
|
||||
// Only the child's own suffix: a fork seed may replay an ancestor's
|
||||
// descriptor without making the fork itself a subagent.
|
||||
const seedLength = candidate.header.seedLength ?? 0
|
||||
const descriptorSeqs = records
|
||||
.filter(record => record.seq >= seedLength && record.type === 'subagent/descriptor')
|
||||
.map(record => record.seq)
|
||||
if (descriptorSeqs.length === 0) return undefined
|
||||
if (descriptorSeqs.length > 1) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
// The length-one branch proves this exact-read sequence exists.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const seq = descriptorSeqs[0]!
|
||||
const window = await runListingQuery(
|
||||
() => query.readEvent({ sessionId: childId, seq }, signal),
|
||||
signal,
|
||||
)
|
||||
queryRuntime.assertSessionHeadersCompatible(window.session, candidate.header)
|
||||
if (window.session.parentSession !== parentSessionId || window.target.type !== 'subagent/descriptor') {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
let descriptor: ReturnType<typeof foldSubagentDescriptor>
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
// Optional acceleration only: an absent cache service just means every
|
||||
// cold candidate takes the authoritative preparation rung, so it carries
|
||||
// no error code and no configuration check.
|
||||
const cache = ctx.get('sessionProjectionCache')
|
||||
let persistedHeaders: readonly SessionHeader[] = []
|
||||
if (persistence !== undefined) {
|
||||
try {
|
||||
descriptor = foldSubagentDescriptor([window.target])
|
||||
} catch {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
persistedHeaders = await persistence.list(signal)
|
||||
} catch (error: unknown) {
|
||||
// The backend may reject with its own abort failure after observing the
|
||||
// forwarded signal; cancellation stays a stable subagent failure.
|
||||
assertListingNotCancelled(signal)
|
||||
throw error
|
||||
}
|
||||
if (descriptor === undefined) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'unsupported' }
|
||||
}
|
||||
const activity = candidate.live ? 'running' : 'inactive'
|
||||
if (descriptor.mode === 'one-shot') {
|
||||
return {
|
||||
kind: 'child',
|
||||
id: childId,
|
||||
mode: descriptor.mode,
|
||||
...descriptor.label !== undefined ? { label: descriptor.label } : {},
|
||||
activity,
|
||||
hasChildren,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'child', id: childId, mode: descriptor.mode, label: descriptor.label,
|
||||
activity, hasChildren,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError)
|
||||
if (reason === undefined) throw error
|
||||
return { kind: 'diagnostic', id: childId, reason }
|
||||
assertListingNotCancelled(signal)
|
||||
}
|
||||
// Live-preferred merge without header reconciliation: a live record wins
|
||||
// its id wholesale, exactly as a live-preferred corpus would serve it.
|
||||
const corpus = new Map<SessionId, { header: SessionHeader; live: Session | undefined }>()
|
||||
for (const header of persistedHeaders) corpus.set(header.id, { header, live: undefined })
|
||||
for (const session of sessions.list()) {
|
||||
corpus.set(session.header.id, { header: session.header, live: session })
|
||||
}
|
||||
const subagentParents = new Set<SessionId>()
|
||||
for (const record of corpus.values()) {
|
||||
if (record.header.origin === 'subagent' && record.header.parentSession !== undefined) {
|
||||
subagentParents.add(record.header.parentSession)
|
||||
}
|
||||
}
|
||||
const candidates = [...corpus.values()]
|
||||
.filter(record => record.header.parentSession === parentSessionId
|
||||
&& record.header.origin === 'subagent')
|
||||
.sort((a, b) => a.header.createdAt - b.header.createdAt
|
||||
|| a.header.id.localeCompare(b.header.id))
|
||||
|
||||
const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length })
|
||||
const coldReads: { index: number; header: SessionHeader }[] = []
|
||||
candidates.forEach((candidate, index) => {
|
||||
const childId = candidate.header.id
|
||||
if (candidate.live === undefined) {
|
||||
coldReads.push({ index, header: candidate.header })
|
||||
return
|
||||
}
|
||||
// The registry's watermark cache serves the live value with zero log
|
||||
// reads; a live child without an identity yet is the creation window
|
||||
// before the establishing provider appends its descriptor.
|
||||
let identity: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
identity = projections.snapshot(candidate.live).values.subagent
|
||||
} catch {
|
||||
// The snapshot folds EVERY registered unit over this child's log, so
|
||||
// any unit's fold or schema can reject damaged payloads. That is
|
||||
// deterministic data damage in this one child; it degrades to one
|
||||
// corrupt diagnostic instead of failing the whole listing.
|
||||
rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
return
|
||||
}
|
||||
// The unit's serializable no-value sentinel is `null`; `undefined` can
|
||||
// only mean the key was dropped at a JSON boundary. Both are no value.
|
||||
if (identity === undefined || identity === null) return
|
||||
rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId))
|
||||
})
|
||||
|
||||
// Cold candidates exist only when persistence listed them, so the narrow
|
||||
// re-check is about types, not reachability.
|
||||
if (persistence !== undefined && coldReads.length > 0) {
|
||||
const queue = [...coldReads]
|
||||
await Promise.all(Array.from(
|
||||
{ length: Math.min(COLD_READ_CONCURRENCY, queue.length) },
|
||||
async () => {
|
||||
for (let job = queue.shift(); job !== undefined; job = queue.shift()) {
|
||||
rows[job.index] = await resolveColdIdentity(
|
||||
persistence, projections, cache, job.header,
|
||||
subagentParents.has(job.header.id), signal,
|
||||
)
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
return rows.filter((row): row is SubagentListEntry => row !== undefined)
|
||||
}
|
||||
|
||||
/** Stop a listing scan at its next cancellation checkpoint. */
|
||||
/**
|
||||
* Resolve one cold candidate down the remaining ladder: a durable
|
||||
* projection-cache row when it serves an own-suffix identity (the seq gate),
|
||||
* otherwise one persistence inspection folded through the projection
|
||||
* registry (the same detached recipe the API proxy uses for detached session
|
||||
* projections). A failed inspection is one transient `unavailable` row
|
||||
* retried on the next listing; an inspection naming another lifecycle, and a
|
||||
* settled log the fold cannot identify — or that makes any registered unit
|
||||
* throw — are final, so they report `corrupt`.
|
||||
*/
|
||||
async function resolveColdIdentity(
|
||||
persistence: SessionPersistence,
|
||||
projections: SessionProjectionRegistry,
|
||||
cache: SessionProjectionCache | undefined,
|
||||
header: SessionHeader,
|
||||
hasChildren: boolean,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<SubagentListEntry> {
|
||||
const childId = header.id
|
||||
if (cache !== undefined) {
|
||||
let cached: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
cached = cache.cachedSnapshot(header)?.values.subagent
|
||||
} catch {
|
||||
// Unlike the preparation fold below, a throwing cache read renders no
|
||||
// verdict: the cache is derived data, so its damage (a poisoned stored
|
||||
// row of ANY unit) silently falls through to the authoritative re-fold.
|
||||
cached = undefined
|
||||
}
|
||||
// A child's OWN descriptor is immutable once appended, so a cached
|
||||
// identity is final only when the seq gate proves it was folded from the
|
||||
// own suffix: a creation-window checkpoint may instead carry a fork
|
||||
// seed's replayed ANCESTOR descriptor (seq below `seedLength`), which
|
||||
// must not outrank the re-fold. Everything else also falls through to
|
||||
// preparation: an absent key (a cut before any descriptor) and the
|
||||
// `null` sentinel, whose verdict belongs to the authoritative re-fold,
|
||||
// not to a derived row.
|
||||
if (cached !== undefined && cached !== null && cached.seq >= (header.seedLength ?? 0)) {
|
||||
return childRow(childId, cached, 'inactive', hasChildren)
|
||||
}
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
let inspected: { meta: SessionHeader; events: readonly SessionEvent[] }
|
||||
try {
|
||||
inspected = await persistence.inspect(childId, signal)
|
||||
} catch {
|
||||
// Per-child isolation: the child vanished or its backend read failed —
|
||||
// one diagnostic row, and the listing itself still succeeds.
|
||||
assertListingNotCancelled(signal)
|
||||
return { kind: 'diagnostic', id: childId, reason: 'unavailable' }
|
||||
}
|
||||
assertListingNotCancelled(signal)
|
||||
// A session id names a slot, not a lifecycle: a child deleted and
|
||||
// re-published under another owner between the enumeration and this read
|
||||
// must not leak into the old parent's listing.
|
||||
if (!sameLifecycle(inspected.meta, header)) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
let identity: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent
|
||||
} catch {
|
||||
// The restore folds EVERY registered unit over this child's log, so any
|
||||
// unit's fold or schema can reject damaged payloads — deterministic data
|
||||
// damage in this one child, contained as its own corrupt diagnostic.
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
if (identity === undefined || identity === null) {
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
return childRow(childId, identity, 'inactive', hasChildren)
|
||||
}
|
||||
|
||||
/** Materialize one served identity as its child row. */
|
||||
function childRow(
|
||||
id: SessionId,
|
||||
identity: SubagentIdentityProjection,
|
||||
activity: 'running' | 'inactive',
|
||||
hasChildren: boolean,
|
||||
): SubagentListEntry {
|
||||
return identity.mode === 'one-shot'
|
||||
? {
|
||||
kind: 'child',
|
||||
id,
|
||||
mode: 'one-shot',
|
||||
...identity.label !== undefined ? { label: identity.label } : {},
|
||||
activity,
|
||||
hasChildren,
|
||||
}
|
||||
: {
|
||||
kind: 'child',
|
||||
id,
|
||||
mode: 'continuable',
|
||||
label: identity.label,
|
||||
activity,
|
||||
hasChildren,
|
||||
}
|
||||
}
|
||||
|
||||
/** Immutable header fields that distinguish one session lifecycle from another under the same id. */
|
||||
const LIFECYCLE_WITNESS_KEYS = [
|
||||
'version', 'id', 'createdAt', 'cwd', 'parentSession', 'seedLength', 'delegationDepth',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Whether an inspected log still belongs to the enumerated lifecycle,
|
||||
* mirroring the retired query-source compatibility check's field set.
|
||||
*/
|
||||
function sameLifecycle(meta: SessionHeader, expected: SessionHeader): boolean {
|
||||
return LIFECYCLE_WITNESS_KEYS.every(key => meta[key] === expected[key])
|
||||
}
|
||||
|
||||
/** Stop a listing at its next cancellation checkpoint. */
|
||||
function assertListingNotCancelled(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new SubagentError('subagent listing was cancelled', 'CANCELLED')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one session-query operation between cancellation checkpoints. Query
|
||||
* implementations may reject with their own abort error after observing the
|
||||
* forwarded signal; cancellation remains a stable subagent failure.
|
||||
*/
|
||||
async function runListingQuery<T>(
|
||||
operation: () => Promise<T>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<T> {
|
||||
assertListingNotCancelled(signal)
|
||||
try {
|
||||
const result = await operation()
|
||||
assertListingNotCancelled(signal)
|
||||
return result
|
||||
} catch (error: unknown) {
|
||||
assertListingNotCancelled(signal)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a per-child query failure to a fixed diagnostic. Configuration errors
|
||||
* and unrecognized failures remain operation failures.
|
||||
*/
|
||||
function perChildDiagnosticReason(
|
||||
error: unknown,
|
||||
SessionQueryError: SessionQueryRuntime['SessionQueryError'],
|
||||
): 'corrupt' | 'unavailable' | undefined {
|
||||
if (!(error instanceof SessionQueryError)) return undefined
|
||||
switch (error.code) {
|
||||
case 'SESSION_QUERY_CORRUPT_SESSION':
|
||||
return 'corrupt'
|
||||
case 'SESSION_QUERY_SESSION_NOT_FOUND':
|
||||
case 'SESSION_QUERY_EVENT_NOT_FOUND':
|
||||
case 'SESSION_QUERY_PERSISTENCE_FAILED':
|
||||
return 'unavailable'
|
||||
case 'SESSION_QUERY_INVALID_SURFACE':
|
||||
case 'SESSION_QUERY_SOURCE_CONFLICT':
|
||||
return 'corrupt'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,48 @@ export interface SubagentTimingProjection {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable identity of one descriptor-backed subagent session: lifecycle mode
|
||||
* plus creation label, folded last-wins from `subagent/descriptor` events.
|
||||
* Label strength follows the descriptor schema: a continuable child always
|
||||
* carries one, a one-shot child may omit it.
|
||||
*/
|
||||
export type SubagentIdentityProjection =
|
||||
| {
|
||||
/** A terminal one-shot child. */
|
||||
mode: 'one-shot'
|
||||
/** Optional durable creation label from the child's descriptor. */
|
||||
label?: string
|
||||
/**
|
||||
* Seq of the `subagent/descriptor` event this identity was folded from.
|
||||
* `seq >= header.seedLength` proves the identity comes from the child's
|
||||
* OWN log suffix — where a descriptor is immutable once appended — and
|
||||
* not from a fork seed's replayed ancestor descriptor.
|
||||
*/
|
||||
seq: number
|
||||
}
|
||||
| {
|
||||
/** A resumable conversation. */
|
||||
mode: 'continuable'
|
||||
/** Durable creation label from the child's descriptor. */
|
||||
label: string
|
||||
/** Seq of the folded descriptor event; see the one-shot arm for the own-suffix proof. */
|
||||
seq: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Active-turn duration for a descriptor-backed subagent session. */
|
||||
subagentTiming: SubagentTimingProjection
|
||||
/**
|
||||
* Identity of a descriptor-backed subagent session. `null` ⟺ no valid
|
||||
* descriptor (missing, malformed, or unrecognized-version — deliberately
|
||||
* undistinguished). The sentinel is deliberately serializable: a
|
||||
* value pushed over JSON transports must survive `JSON.stringify`
|
||||
* losslessly, where an `undefined` field would be dropped and a stale
|
||||
* identity would survive on the receiving side. The entry itself stays
|
||||
* non-optional.
|
||||
*/
|
||||
subagent: SubagentIdentityProjection | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
/**
|
||||
* Pure session projection for subagent active-turn duration.
|
||||
* Pure session projections for subagent identity (mode/label) and active-turn
|
||||
* duration.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/projection
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SubagentTimingProjection } from './projection-types.ts'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { foldSubagentDescriptor } from './descriptor.ts'
|
||||
import type { SubagentDescriptorData } from './descriptor.ts'
|
||||
import type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
interface TimingState {
|
||||
/** Milliseconds accumulated across completed post-descriptor turns. */
|
||||
@@ -80,3 +84,73 @@ ProjectionDefinition<'subagentTiming', TimingState> = {
|
||||
}),
|
||||
stateVersion: 2,
|
||||
}
|
||||
|
||||
interface IdentityState {
|
||||
/** Identity from the last valid descriptor; absent before one, and after an invalid one. */
|
||||
identity?: SubagentIdentityProjection
|
||||
}
|
||||
|
||||
// The cast bridges only the optional-label arm: Zod's optional output
|
||||
// includes explicit `undefined`, which exactOptionalPropertyTypes excludes
|
||||
// from the public interface. The no-value state itself is the serializable
|
||||
// `null` arm — never `undefined` — so every registry read and push frame
|
||||
// survives JSON.stringify losslessly.
|
||||
const identitySchema = z.discriminatedUnion('mode', [
|
||||
z.object({
|
||||
mode: z.literal('one-shot'),
|
||||
label: z.string().optional(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
}).strict(),
|
||||
z.object({
|
||||
mode: z.literal('continuable'),
|
||||
label: z.string(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
}).strict(),
|
||||
]).nullable() as unknown as z.ZodType<SubagentIdentityProjection | null>
|
||||
|
||||
/** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */
|
||||
function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined {
|
||||
let descriptor: SubagentDescriptorData | undefined
|
||||
try {
|
||||
descriptor = foldSubagentDescriptor([event])
|
||||
} catch {
|
||||
// Only a malformed current-version payload throws in descriptor parsing;
|
||||
// a projection fold must never throw, so damage folds to no value.
|
||||
descriptor = undefined
|
||||
}
|
||||
if (descriptor === undefined) return undefined
|
||||
return descriptor.mode === 'one-shot'
|
||||
? {
|
||||
mode: 'one-shot',
|
||||
...descriptor.label !== undefined ? { label: descriptor.label } : {},
|
||||
seq: event.seq,
|
||||
}
|
||||
: { mode: 'continuable', label: descriptor.label, seq: event.seq }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the durable mode/label identity from `subagent/descriptor` events,
|
||||
* last-wins: a fork seed may replay an ancestor's descriptor, and the child's
|
||||
* own descriptor must override it — the same reset discipline as
|
||||
* {@link subagentTimingProjectionDefinition}. A malformed or unknown-version
|
||||
* payload resets to the `null` sentinel instead of throwing, so a fork of a
|
||||
* healthy ancestor never inherits an identity its own descriptor failed to
|
||||
* establish — and the reset survives every JSON push frame, so a consumer
|
||||
* holding the earlier identity replaces it instead of keeping it stale;
|
||||
* `null` ⟺ no valid descriptor, with the causes deliberately undistinguished.
|
||||
*/
|
||||
export const subagentIdentityProjectionDefinition:
|
||||
ProjectionDefinition<'subagent', IdentityState> = {
|
||||
key: 'subagent',
|
||||
schema: identitySchema,
|
||||
init: () => ({}),
|
||||
apply: (state, event) => {
|
||||
if (event.type !== 'subagent/descriptor') return state
|
||||
const identity = descriptorIdentity(event)
|
||||
return identity === undefined ? {} : { identity }
|
||||
},
|
||||
view: state => state.identity ?? null,
|
||||
// Bumped when the identity gained its `seq` field: an older checkpoint row
|
||||
// would replay into a value the schema rejects, so it must refold instead.
|
||||
stateVersion: 2,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { Context } from 'cordis'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -9,7 +10,12 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
import SubagentService, {
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
SubagentError,
|
||||
@@ -17,7 +23,6 @@ import SubagentService, {
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
@@ -26,18 +31,29 @@ afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Boot the continuable stack plus a concrete session-query service. */
|
||||
async function setup(script: Script, options: { sessionQuery?: boolean } = {}) {
|
||||
/** Boot the continuable stack with real JSONL session persistence. */
|
||||
async function setup(
|
||||
script: Script,
|
||||
options: { sessionProjections?: boolean; projectionCache?: boolean } = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-list-'))
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry)
|
||||
if (options.projectionCache === true) {
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(new MemoryMediaPool()))
|
||||
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', facility)
|
||||
ctx.provide('storageDomain', facility)
|
||||
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
}
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(SubagentFork, { providerName: 'fork' })
|
||||
if (options.sessionQuery !== false) await ctx.plugin(TestSessionQueryService)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
@@ -101,38 +117,76 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION)
|
||||
return { version, mode: 'continuable' as const, provider: 'spawn', label }
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Test-only hostile probe proving per-child isolation of foreign unit failures. */
|
||||
subagentListHostileProbe: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A foreign registered unit that rejects one specific child's log at view
|
||||
* time: `apply` never throws (the eager drive passes every committed event
|
||||
* through it), while the poisoned state detonates only when a listing read
|
||||
* folds or serves this child through the registry.
|
||||
*/
|
||||
const hostileProjectionDefinition: ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean }> = {
|
||||
key: 'subagentListHostileProbe',
|
||||
schema: z.null(),
|
||||
init: () => ({}),
|
||||
apply: (state, event) =>
|
||||
event.type === 'subagent/descriptor' && (event.data as { label?: string }).label === 'poison me'
|
||||
? { poisoned: true }
|
||||
: state,
|
||||
view: (state) => {
|
||||
if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log')
|
||||
return null
|
||||
},
|
||||
stateVersion: 1,
|
||||
}
|
||||
|
||||
describe('SubagentService.listChildren', () => {
|
||||
it('lists through session query without the Activation continuation runtime', async () => {
|
||||
it('lists live children without persistence, query services, or the continuation runtime', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
expect(ctx.get('tasks')).toBeUndefined()
|
||||
expect(ctx.get('agents')).toBeUndefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeUndefined()
|
||||
|
||||
const parentId = SessionId('query-only-parent')
|
||||
const parentId = SessionId('live-only-parent')
|
||||
ctx.sessions.create(parentId)
|
||||
const childId = SessionId('query-only-child')
|
||||
const childId = SessionId('live-only-child')
|
||||
const child = ctx.sessions.create(childId, {
|
||||
meta: { parentSession: parentId, origin: 'subagent' },
|
||||
})
|
||||
child.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
child.append('subagent/descriptor', descriptorPayload('query-only child'))
|
||||
child.append('subagent/descriptor', descriptorPayload('live-only child'))
|
||||
|
||||
await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([
|
||||
{
|
||||
kind: 'child', id: childId, label: 'query-only child', mode: 'continuable',
|
||||
kind: 'child', id: childId, label: 'live-only child', mode: 'continuable',
|
||||
activity: 'running', hasChildren: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('fails loud before any work when session query is not loaded', async () => {
|
||||
const { ctx, parent } = await setup([], { sessionQuery: false })
|
||||
it('fails loud when the projection registry is not mounted, even with no children', async () => {
|
||||
const { ctx, parent } = await setup([], { sessionProjections: false })
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE' }) as Error,
|
||||
expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('fails loud when the session store is not mounted', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await expect(ctx.subagents.listChildren(SessionId('no-store-parent'))).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -148,7 +202,7 @@ describe('SubagentService.listChildren', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('lists one-shot and continuable children from the same trace', async () => {
|
||||
it('lists one-shot and continuable children under the same parent', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('once'), textResponse('again')])
|
||||
const oneShot = await ctx.subagents.start('spawn', {
|
||||
prompt: [{ type: 'text', text: 'finish once' }],
|
||||
@@ -205,33 +259,59 @@ describe('SubagentService.listChildren', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('orders children by createdAt then id without inspecting ordinary forks', async () => {
|
||||
it('orders children by createdAt then id without listing ordinary forks', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// Authored headers pin the ordering key deterministically: same createdAt
|
||||
// ties break on id, different createdAt orders ascending.
|
||||
const late = await authorChild(ctx, '00000000-0000-4000-8000-000000000003', {
|
||||
parentSession: parent.id,
|
||||
createdAt: 9,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('late child')))
|
||||
const tieB = await authorChild(ctx, '00000000-0000-4000-8000-000000000002', {
|
||||
parentSession: parent.id,
|
||||
createdAt: 5,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('tie b')))
|
||||
const tieA = await authorChild(ctx, '00000000-0000-4000-8000-000000000001', {
|
||||
parentSession: parent.id,
|
||||
createdAt: 5,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('tie a')))
|
||||
/** Publish one live child with a pinned header ordering key. */
|
||||
const liveChild = (parentId: SessionId, id: string, createdAt: number, label: string): SessionId => {
|
||||
const session = ctx.sessions.create(SessionId(id), {
|
||||
meta: { parentSession: parentId, origin: 'subagent', createdAt },
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('subagent/descriptor', descriptorPayload(label))
|
||||
return session.header.id
|
||||
}
|
||||
// Live creation order is deliberately shuffled against the expected
|
||||
// result: same-createdAt ties break on id, different createdAt orders
|
||||
// ascending.
|
||||
const late = liveChild(parent.id, '00000000-0000-4000-8000-000000000009', 9, 'late child')
|
||||
const tieB = liveChild(parent.id, '00000000-0000-4000-8000-000000000002', 5, 'tie b')
|
||||
const tieA = liveChild(parent.id, '00000000-0000-4000-8000-000000000001', 5, 'tie a')
|
||||
// An ordinary session fork shares parentSession but has no subagent origin.
|
||||
const fork = ctx.sessions.fork(parent.session, undefined, SessionId('plain-fork'))
|
||||
await ctx.sessions.flush(fork)
|
||||
const listEvents = vi.spyOn(ctx.sessionQuery, 'listEvents')
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries.map(entry => entry.id)).toEqual([tieA, tieB, late])
|
||||
expect(entries.every(entry => entry.kind === 'child')).toBe(true)
|
||||
expect(listEvents).not.toHaveBeenCalledWith(fork.id)
|
||||
})
|
||||
|
||||
it('omits a live child that has not appended its descriptor yet', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const pending = ctx.sessions.create(SessionId('creation-window-child'), {
|
||||
meta: { parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
pending.append('turn/start', { turn: 1 })
|
||||
// The creation window: the establishing provider has not appended the
|
||||
// descriptor yet, so the row is omitted rather than diagnosed.
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('lists a one-shot child with its durable creation label', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const labeled = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab02', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents({
|
||||
version: SUBAGENT_DESCRIPTOR_VERSION,
|
||||
mode: 'one-shot',
|
||||
provider: 'spawn',
|
||||
label: 'labeled one-shot',
|
||||
}))
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([
|
||||
{
|
||||
kind: 'child', id: labeled, mode: 'one-shot', label: 'labeled one-shot',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a live child as running while keeping settled siblings complete', async () => {
|
||||
@@ -256,7 +336,7 @@ describe('SubagentService.listChildren', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('diagnoses duplicate descriptors as corrupt without hiding healthy siblings', async () => {
|
||||
it('lists the last descriptor when a log carries more than one', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const healthy = await startChild(ctx, parent, 'healthy sibling')
|
||||
const events = childEvents(descriptorPayload('twice'))
|
||||
@@ -267,22 +347,169 @@ describe('SubagentService.listChildren', () => {
|
||||
data: descriptorPayload('twice again'),
|
||||
} as SessionEvent)
|
||||
events[4] = { ...events[4]!, seq: 4 }
|
||||
const corrupt = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
|
||||
const doubled = await authorChild(ctx, '00000000-0000-4000-8000-00000000dupe', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, events)
|
||||
// The last-wins projection fold serves the final descriptor's identity; a
|
||||
// repeated descriptor is not a per-child corruption diagnostic.
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: doubled, label: 'twice again', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('diagnoses a child rejected by persisted Session preparation as corrupt', async () => {
|
||||
it('serves the serializable null sentinel when a later descriptor invalidates the identity', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// The surface-eligible user/message lacks its required surfaceOp. The
|
||||
// first-party persistence inspection rejects before session-query can fold it.
|
||||
const liveId = SessionId('invalidated-live-child')
|
||||
const live = ctx.sessions.create(liveId, {
|
||||
meta: { parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
live.append('turn/start', { turn: 1 })
|
||||
live.append('subagent/descriptor', descriptorPayload('was valid'))
|
||||
expect(ctx.sessionProjections.snapshot(live).values.subagent)
|
||||
.toEqual({ mode: 'continuable', label: 'was valid', seq: 1 })
|
||||
// Last-wins: the malformed follow-up resets the identity to the sentinel.
|
||||
live.append(
|
||||
'subagent/descriptor',
|
||||
{ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 } as never,
|
||||
)
|
||||
const values = ctx.sessionProjections.snapshot(live).values
|
||||
expect(values.subagent).toBeNull()
|
||||
// The sentinel survives a JSON push frame; an undefined field would be
|
||||
// dropped there and a consumer would keep the stale identity forever.
|
||||
const wired = JSON.parse(JSON.stringify(values)) as Record<string, unknown>
|
||||
expect('subagent' in wired).toBe(true)
|
||||
expect(wired['subagent']).toBeNull()
|
||||
// The listing reads the same null as no value: running → omitted.
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('diagnoses a settled child whose later descriptor invalidated the identity as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const events = childEvents(descriptorPayload('was valid'))
|
||||
events.splice(3, 0, {
|
||||
type: 'subagent/descriptor',
|
||||
seq: 3,
|
||||
time: 3,
|
||||
data: { version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 },
|
||||
} as SessionEvent)
|
||||
events[4] = { ...events[4]!, seq: 4 }
|
||||
const invalidated = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad01', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, events)
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([
|
||||
{ kind: 'diagnostic', id: invalidated, reason: 'corrupt' },
|
||||
])
|
||||
})
|
||||
|
||||
it('serves a cached own-suffix identity directly without inspection', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const child = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae01', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('disk label')))
|
||||
// seq 2 >= seedLength 0: the cached identity provably comes from the
|
||||
// child's own suffix, so it is final and the log is never re-read — the
|
||||
// divergent label proves the row, not the log, produced the entry.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({
|
||||
asOfSeq: 2,
|
||||
values: { subagent: { mode: 'continuable', label: 'cached own', seq: 2 } },
|
||||
})
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: child, label: 'cached own', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a cached ancestor identity from the fork seed and lets preparation rule', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
// A fork child: the seed replays the ancestor's descriptor (seq 2), and
|
||||
// the child's own descriptor arrives in its first own turn (seq 5).
|
||||
const seed = childEvents(descriptorPayload('ancestor label'))
|
||||
const events = [
|
||||
...seed,
|
||||
{ type: 'turn/start', seq: 4, time: 5, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'subagent/descriptor', seq: 5, time: 6, data: descriptorPayload('own label') },
|
||||
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae02', {
|
||||
parentSession: parent.id,
|
||||
seedLength: seed.length,
|
||||
origin: 'subagent',
|
||||
}, events)
|
||||
// A creation-window checkpoint carried the ANCESTOR identity: its seq 2
|
||||
// fails the own-suffix gate (< seedLength 4), so preparation rules.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({
|
||||
asOfSeq: 2,
|
||||
values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } },
|
||||
})
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: forkChild, label: 'own label', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['version', (meta: SessionHeader): SessionHeader => ({ ...meta, version: meta.version + 1 })],
|
||||
['id', (meta: SessionHeader): SessionHeader => ({ ...meta, id: SessionId('another-lifecycle') })],
|
||||
['createdAt', (meta: SessionHeader): SessionHeader => ({ ...meta, createdAt: meta.createdAt + 1 })],
|
||||
['cwd', (meta: SessionHeader): SessionHeader => ({ ...meta, cwd: '/elsewhere' })],
|
||||
['parentSession', (meta: SessionHeader): SessionHeader => ({ ...meta, parentSession: SessionId('another-parent') })],
|
||||
['seedLength', (meta: SessionHeader): SessionHeader => ({ ...meta, seedLength: (meta.seedLength ?? 0) + 1 })],
|
||||
['delegationDepth', (meta: SessionHeader): SessionHeader => ({ ...meta, delegationDepth: (meta.delegationDepth ?? 0) + 1 })],
|
||||
] as const)('diagnoses an inspection returning another lifecycle (%s) as corrupt', async (_field, mutate) => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const healthy = await startChild(ctx, parent, 'healthy sibling')
|
||||
const reborn = await authorChild(ctx, '00000000-0000-4000-8000-00000000ae03', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('reborn child')))
|
||||
const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.inspect = async (sessionId, signal) => {
|
||||
const result = await original(sessionId, signal)
|
||||
if (sessionId !== reborn) return result
|
||||
// The id was re-published as a different lifecycle after enumeration.
|
||||
return { ...result, meta: mutate(result.meta) }
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toContainEqual({ kind: 'diagnostic', id: reborn, reason: 'corrupt' })
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('lets preparation rule when the cache serves the null sentinel', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const healthy = await authorChild(ctx, '00000000-0000-4000-8000-00000000ad02', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('actually valid')))
|
||||
// A stale cached sentinel must not out-rank the authoritative re-fold.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: { subagent: null } })
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: healthy, label: 'actually valid', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('maps a child rejected by persistence inspection to unavailable', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// The surface-eligible user/message lacks its required surfaceOp, so the
|
||||
// first-party inspection rejects before any projection fold can run.
|
||||
const invalid = await authorChild(ctx, '00000000-0000-4000-8000-0000000000ee', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
@@ -297,7 +524,7 @@ describe('SubagentService.listChildren', () => {
|
||||
{ type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') },
|
||||
] as SessionEvent[])
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }])
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'unavailable' }])
|
||||
})
|
||||
|
||||
it('diagnoses a malformed descriptor payload as corrupt', async () => {
|
||||
@@ -310,28 +537,36 @@ describe('SubagentService.listChildren', () => {
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: malformed, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('diagnoses an unknown descriptor version as unsupported', async () => {
|
||||
it('diagnoses an unknown descriptor version as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const future = await authorChild(ctx, '00000000-0000-4000-8000-0000000000aa', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('from the future', SUBAGENT_DESCRIPTOR_VERSION + 1)))
|
||||
// The projection fold does not distinguish an unrecognized version from
|
||||
// other invalid descriptors: both serve no identity, and a settled
|
||||
// no-value candidate is corrupt.
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'unsupported' }])
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('ignores an ancestor descriptor replayed inside a fork seed', async () => {
|
||||
it('lists a fork whose seed replays an ancestor descriptor under that identity', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
// A fork child whose seed replays a parent log containing a descriptor:
|
||||
// the seed's descriptor is the ANCESTOR's, not this child's.
|
||||
// The last-wins fold serves a seed-replayed ancestor descriptor until the
|
||||
// child's own descriptor overrides it (known deviation #1 in the design).
|
||||
const seed = childEvents(descriptorPayload('ancestor label'))
|
||||
await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
|
||||
const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
|
||||
parentSession: parent.id,
|
||||
seedLength: seed.length,
|
||||
origin: 'subagent',
|
||||
}, seed)
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([])
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: 'child', id: forkChild, label: 'ancestor label', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not filter by provider availability: children of unmounted providers stay listed', async () => {
|
||||
@@ -354,103 +589,85 @@ describe('SubagentService.listChildren', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a per-child read failure to one unavailable diagnostic after a successful trace', async () => {
|
||||
it('contains a foreign unit failure during a cold fold to that child as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'flaky storage')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalListEvents = query.listEvents.bind(query)
|
||||
query.listEvents = (sessionId) => {
|
||||
if (sessionId === childId) {
|
||||
return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
}
|
||||
return originalListEvents(sessionId)
|
||||
}
|
||||
ctx.sessionProjections.register(hostileProjectionDefinition)
|
||||
const healthy = await startChild(ctx, parent, 'healthy sibling')
|
||||
const poisoned = await authorChild(ctx, '00000000-0000-4000-8000-00000000d00d', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('poison me')))
|
||||
// The subagent unit itself folds this child cleanly; the FOREIGN unit's
|
||||
// view throws, and that damage stays contained to the one child.
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
|
||||
expect(entries).toContainEqual({ kind: 'diagnostic', id: poisoned, reason: 'corrupt' })
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['session', 'SESSION_QUERY_SESSION_NOT_FOUND'],
|
||||
['descriptor event', 'SESSION_QUERY_EVENT_NOT_FOUND'],
|
||||
] as const)('maps a missing child %s to unavailable', async (_target, code) => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'vanishing child')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () =>
|
||||
Promise.reject(new SessionQueryError('gone', code))
|
||||
it('contains a foreign unit failure during a live snapshot to that child as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
ctx.sessionProjections.register(hostileProjectionDefinition)
|
||||
const poisonedId = SessionId('live-poisoned-child')
|
||||
const poisoned = ctx.sessions.create(poisonedId, {
|
||||
meta: { parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
poisoned.append('turn/start', { turn: 1 })
|
||||
poisoned.append('subagent/descriptor', descriptorPayload('poison me'))
|
||||
const healthyId = SessionId('live-healthy-child')
|
||||
const healthy = ctx.sessions.create(healthyId, {
|
||||
meta: { parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
healthy.append('turn/start', { turn: 1 })
|
||||
healthy.append('subagent/descriptor', descriptorPayload('live healthy'))
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'unavailable' }])
|
||||
expect(entries).toContainEqual({ kind: 'diagnostic', id: poisonedId, reason: 'corrupt' })
|
||||
expect(entries).toContainEqual({
|
||||
kind: 'child', id: healthyId, label: 'live healthy', mode: 'continuable',
|
||||
activity: 'running', hasChildren: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps an invalid child surface to corrupt', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'invalid surface')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () =>
|
||||
Promise.reject(new SessionQueryError('invalid surface', 'SESSION_QUERY_INVALID_SURFACE'))
|
||||
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('diagnoses a read whose header no longer names this parent as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'reparented child')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalReadEvent = query.readEvent.bind(query)
|
||||
query.readEvent = async (request) => {
|
||||
const window = await originalReadEvent(request)
|
||||
return {
|
||||
...window,
|
||||
session: { ...window.session, parentSession: SessionId('someone-else') },
|
||||
}
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
// The exact read's conflicting immutable header is per-child corruption.
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('diagnoses a read whose target is no longer the descriptor event as corrupt', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'shifted log')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalReadEvent = query.readEvent.bind(query)
|
||||
query.readEvent = async (request) => {
|
||||
const window = await originalReadEvent(request)
|
||||
return { ...window, target: { ...window.target, type: 'turn/start' } as typeof window.target }
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([{ kind: 'diagnostic', id: childId, reason: 'corrupt' }])
|
||||
})
|
||||
|
||||
it('fails the whole call when the initial trace fails', async () => {
|
||||
it('fails the whole enumeration when the persisted listing itself fails', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'never listed')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.traceSession = () =>
|
||||
Promise.reject(new SessionQueryError('listing failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'SESSION_QUERY_PERSISTENCE_FAILED' }) as Error,
|
||||
)
|
||||
ctx.sessionPersistence.list = () => Promise.reject(new Error('backend listing failed'))
|
||||
// Without any abort in flight, the original backend failure propagates
|
||||
// as the operation failure — no cancellation mapping, no diagnostic rows.
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('backend listing failed')
|
||||
})
|
||||
|
||||
it('propagates an unrecognized per-child failure as an operation failure', async () => {
|
||||
it('maps a failed cold inspection to one unavailable diagnostic and retries it next listing', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'strange failure')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () => Promise.reject(new Error('not a query failure'))
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow('not a query failure')
|
||||
})
|
||||
|
||||
it('propagates a configuration/window query failure instead of diagnosing the child', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'misconfigured query')
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () =>
|
||||
Promise.reject(new SessionQueryError('bad window', 'SESSION_QUERY_INVALID_WINDOW'))
|
||||
await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'SESSION_QUERY_INVALID_WINDOW' }) as Error,
|
||||
)
|
||||
const healthy = await startChild(ctx, parent, 'healthy sibling')
|
||||
const flaky = await authorChild(ctx, '00000000-0000-4000-8000-00000000f1a7', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('flaky storage')))
|
||||
const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.inspect = (sessionId, signal) => {
|
||||
if (sessionId === flaky) {
|
||||
return Promise.reject(new Error('backend read failed'))
|
||||
}
|
||||
return original(sessionId, signal)
|
||||
}
|
||||
// Per-child isolation: the failed child degrades to one diagnostic while
|
||||
// the healthy sibling stays complete.
|
||||
const degraded = await ctx.subagents.listChildren(parent.id)
|
||||
expect(degraded).toContainEqual({ kind: 'diagnostic', id: flaky, reason: 'unavailable' })
|
||||
expect(degraded).toContainEqual({
|
||||
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
// Nothing is memoized: with the backend healthy again, the next listing
|
||||
// folds the same child to its identity.
|
||||
ctx.sessionPersistence.inspect = original
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({
|
||||
kind: 'child', id: flaky, label: 'flaky storage', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('lists compacted and uncompacted children identically', async () => {
|
||||
@@ -492,19 +709,18 @@ describe('SubagentService.listChildren', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an origin-classified grandchild without reading its events', async () => {
|
||||
it('reports an origin-classified grandchild without inspecting it', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'direct child')
|
||||
const grandchildId = await authorChild(ctx, '00000000-0000-4000-8000-0000000000cc', {
|
||||
parentSession: childId,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('grandchild')))
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalListEvents = query.listEvents.bind(query)
|
||||
const inspected: SessionId[] = []
|
||||
query.listEvents = (sessionId) => {
|
||||
const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.inspect = (sessionId, signal) => {
|
||||
inspected.push(sessionId)
|
||||
return originalListEvents(sessionId)
|
||||
return original(sessionId, signal)
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toEqual([
|
||||
@@ -513,10 +729,112 @@ describe('SubagentService.listChildren', () => {
|
||||
activity: 'inactive', hasChildren: true,
|
||||
},
|
||||
])
|
||||
// The grandchild contributes only its header to the hasChildren hint.
|
||||
expect(inspected).toContain(childId)
|
||||
expect(inspected).not.toContain(grandchildId)
|
||||
})
|
||||
|
||||
it('inspects each cold child exactly once and a live child never', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const coldStarted = await startChild(ctx, parent, 'cold started child')
|
||||
const coldAuthored = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab01', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('cold authored child')))
|
||||
const liveId = SessionId('live-mixed-child')
|
||||
const live = ctx.sessions.create(liveId, {
|
||||
meta: { parentSession: parent.id, origin: 'subagent' },
|
||||
})
|
||||
live.append('turn/start', { turn: 1 })
|
||||
live.append('subagent/descriptor', descriptorPayload('live mixed child'))
|
||||
|
||||
const inspected: SessionId[] = []
|
||||
const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.inspect = (sessionId, signal) => {
|
||||
inspected.push(sessionId)
|
||||
return original(sessionId, signal)
|
||||
}
|
||||
const entries = await ctx.subagents.listChildren(parent.id)
|
||||
expect(entries).toHaveLength(3)
|
||||
// The cost model: one inspection per cold child, none for a live child,
|
||||
// whose identity is served from the registry's watermark cache.
|
||||
expect(inspected.filter(id => id === coldStarted)).toHaveLength(1)
|
||||
expect(inspected.filter(id => id === coldAuthored)).toHaveLength(1)
|
||||
expect(inspected).not.toContain(liveId)
|
||||
})
|
||||
|
||||
it('serves a cold child from the projection cache without any inspection', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')], { projectionCache: true })
|
||||
const childId = await startChild(ctx, parent, 'cached child')
|
||||
// The child's turn/end and disposal are the cache's mandatory checkpoint
|
||||
// points; both writes are fail-soft asynchronous, so wait for the row.
|
||||
const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId)
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined()
|
||||
}, { timeout: 5_000 })
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: childId, label: 'cached child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to inspection when the cache serves no identity for the child', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac01', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('uncached child')))
|
||||
const expected = [{
|
||||
kind: 'child', id: foreign, label: 'uncached child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}]
|
||||
// No stored row at all for a foreign child this process never ran.
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
// A stored row whose cut predates the descriptor: the subagent key is
|
||||
// absent from the served values, and preparation still rules.
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: {} })
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
|
||||
expect(inspect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('takes the preparation rung directly when no projection cache is mounted', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
expect(ctx.get('sessionProjectionCache')).toBeUndefined()
|
||||
const foreign = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac02', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('uncacheable child')))
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: foreign, label: 'uncacheable child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('silently falls through to preparation when the cache read throws', async () => {
|
||||
const { ctx, parent } = await setup([], { projectionCache: true })
|
||||
const recovered = await authorChild(ctx, '00000000-0000-4000-8000-00000000ac03', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('recovered child')))
|
||||
ctx.sessionProjectionCache.cachedSnapshot = () => {
|
||||
// A poisoned stored row (any unit's) detonates at view time; the cache
|
||||
// is derived data, so its failure must not become a verdict.
|
||||
throw new Error('poisoned cache row')
|
||||
}
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: recovered, label: 'recovered child', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not count an ordinary grandchild without subagent origin', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
const childId = await startChild(ctx, parent, 'direct child')
|
||||
@@ -550,115 +868,92 @@ describe('SubagentService.listChildren', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('stops the scan at the between-candidates checkpoint when the signal aborts', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('one'), textResponse('two')])
|
||||
await startChild(ctx, parent, 'first child')
|
||||
await startChild(ctx, parent, 'second child')
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalListEvents = query.listEvents.bind(query)
|
||||
let inspected = 0
|
||||
query.listEvents = (sessionId) => {
|
||||
inspected += 1
|
||||
// Cancel while the first candidate's read is in flight: the loop's next
|
||||
// between-candidates checkpoint must stop before the second read.
|
||||
controller.abort()
|
||||
return originalListEvents(sessionId)
|
||||
}
|
||||
await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
expect(inspected).toBe(1)
|
||||
})
|
||||
|
||||
it('forwards cancellation to the initial trace and reports the stable subagent error', async () => {
|
||||
it('a pre-aborted signal stops before any persistence read', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
query.traceSession = (_sessionId, signal) => {
|
||||
entered.resolve(undefined)
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(new Error('query trace aborted'))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
|
||||
await entered.promise
|
||||
controller.abort()
|
||||
await expect(listing).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards cancellation to the exact descriptor read and reports the stable subagent error', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'cancelled exact read')
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
query.readEvent = (_request, signal) => {
|
||||
entered.resolve(undefined)
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(new Error('query read aborted'))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
|
||||
await entered.promise
|
||||
controller.abort()
|
||||
await expect(listing).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('stops after a per-child read when the signal aborts mid-inspection', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'cancelled mid-read')
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
const originalReadEvent = query.readEvent.bind(query)
|
||||
let exactReads = 0
|
||||
query.readEvent = async (request) => {
|
||||
exactReads += 1
|
||||
const window = await originalReadEvent(request)
|
||||
controller.abort()
|
||||
return window
|
||||
}
|
||||
// The post-read checkpoint throws a subagent error, which is not a
|
||||
// session-query failure and therefore propagates instead of becoming a
|
||||
// per-child diagnostic.
|
||||
await expect(ctx.subagents.listChildren(parent.id, controller.signal))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'CANCELLED' }) as Error)
|
||||
expect(exactReads).toBe(1)
|
||||
})
|
||||
|
||||
it('a mapped per-child failure during an abort cannot become a successful result', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'aborted behind a diagnostic')
|
||||
const controller = new AbortController()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () => {
|
||||
// The read fails with a diagnostic-mapped code while the caller aborts:
|
||||
// cancellation normalization must fail the scan rather than return a
|
||||
// one-diagnostic success.
|
||||
controller.abort()
|
||||
return Promise.reject(new SessionQueryError('backend read failed', 'SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
}
|
||||
ctx.sessionPersistence.list = () => Promise.reject(new Error('must not be called'))
|
||||
await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('a pre-aborted signal stops before any candidate read', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('done')])
|
||||
await startChild(ctx, parent, 'never read')
|
||||
it('forwards cancellation to the persisted listing and reports the stable subagent error', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const controller = new AbortController()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.list = (signal) => {
|
||||
entered.resolve(undefined)
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(new Error('backend listing aborted'))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
|
||||
await entered.promise
|
||||
controller.abort()
|
||||
const query = ctx.get('sessionQuery')!
|
||||
query.listEvents = () => Promise.reject(new Error('must not be called'))
|
||||
await expect(listing).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards cancellation to a cold inspection and reports the stable subagent error', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await authorChild(ctx, '00000000-0000-4000-8000-00000000ce11', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('cancelled cold read')))
|
||||
const controller = new AbortController()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.inspect = (_sessionId, signal) => {
|
||||
entered.resolve(undefined)
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
reject(new Error('backend read aborted'))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
const listing = ctx.subagents.listChildren(parent.id, controller.signal)
|
||||
await entered.promise
|
||||
controller.abort()
|
||||
await expect(listing).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
})
|
||||
|
||||
it('an abort observed after a cold inspection resolves cannot become a successful result', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await authorChild(ctx, '00000000-0000-4000-8000-00000000ce12', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('cancelled mid-listing')))
|
||||
const controller = new AbortController()
|
||||
const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
|
||||
ctx.sessionPersistence.inspect = async (sessionId, signal) => {
|
||||
const result = await original(sessionId, signal)
|
||||
controller.abort()
|
||||
return result
|
||||
}
|
||||
// The post-read checkpoint throws the stable subagent error instead of
|
||||
// interpreting the fully-read log as a successful listing.
|
||||
await expect(ctx.subagents.listChildren(parent.id, controller.signal))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'CANCELLED' }) as Error)
|
||||
})
|
||||
|
||||
it('a cold inspection failure during an abort cannot become an unavailable diagnostic', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await authorChild(ctx, '00000000-0000-4000-8000-00000000ce13', {
|
||||
parentSession: parent.id,
|
||||
origin: 'subagent',
|
||||
}, childEvents(descriptorPayload('aborted behind a failure')))
|
||||
const controller = new AbortController()
|
||||
ctx.sessionPersistence.inspect = () => {
|
||||
// The read fails while the caller aborts: cancellation normalization
|
||||
// must fail the listing rather than return a one-diagnostic success.
|
||||
controller.abort()
|
||||
return Promise.reject(new Error('backend read failed'))
|
||||
}
|
||||
await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toThrow(
|
||||
expect.objectContaining({ code: 'CANCELLED' }) as Error,
|
||||
)
|
||||
@@ -671,9 +966,9 @@ describe('SubagentService.listChildren', () => {
|
||||
})
|
||||
|
||||
it('SubagentError from listChildren is typed with its stable code', async () => {
|
||||
const { ctx, parent } = await setup([], { sessionQuery: false })
|
||||
const { ctx, parent } = await setup([], { sessionProjections: false })
|
||||
const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error)
|
||||
expect(caught).toBeInstanceOf(SubagentError)
|
||||
expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE')
|
||||
expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('@deepseek-ai/dsh-subagent optional session-query peer', () => {
|
||||
it('loads ordinary subagent operations without evaluating the optional query package', async () => {
|
||||
vi.doMock('@deepseek-ai/dsh-session-query', () => {
|
||||
throw new Error('optional session-query runtime was loaded eagerly')
|
||||
})
|
||||
|
||||
const subagent = await import('../src/index.ts')
|
||||
|
||||
expect(subagent.SubagentService).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
@@ -23,11 +23,15 @@ describe('subagent timing projection', () => {
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const serviceFiber = await ctx.plugin(SubagentService)
|
||||
|
||||
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
|
||||
.toEqual({ settledMs: 0 })
|
||||
const before = ctx.sessionProjections.snapshot(ctx.sessions.create()).values
|
||||
expect(before.subagentTiming).toEqual({ settledMs: 0 })
|
||||
// The identity unit registers alongside timing; an empty log serves its
|
||||
// serializable null sentinel.
|
||||
expect(before.subagent).toBeNull()
|
||||
await serviceFiber.dispose()
|
||||
expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming)
|
||||
.toBeUndefined()
|
||||
const after = ctx.sessionProjections.snapshot(ctx.sessions.create()).values
|
||||
expect(after.subagentTiming).toBeUndefined()
|
||||
expect(after.subagent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets inherited seed timing at the child descriptor and sums later completed turns', () => {
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
"path": "../../session-projection/session-projection-cache"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
|
||||
@@ -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/subagent/tool-subagent-control/README.md
|
||||
README.md: 5d775a524c38750953c6389b9ebdea67a33df7ca
|
||||
README.zh.md: 3b989fca8b79cea3e3b10bb2e65805e0cee79c69
|
||||
README.md: ea95a45b85e01d1f5f1c478a35c80c65151724ac
|
||||
README.zh.md: 2cc876c8b39caa19fdf30eae7c8def0ba81fe7b1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and requires only `subagents`; the separately loadable `./list-agents` plugin registers `list_agents`, declares `sessionQuery` as a load-time dependency, and remains inactive until that service is available. A deployment without session query keeps `send_message` and omits the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
|
||||
The optional, globally named `send_message` and `list_agents` tools are thin adapters over `ctx.subagents`. Provider-bound `@deepseek-ai/dsh-tool-subagent` instances register distinct delegation tools per transport; this separately loaded package registers shared control tools once, so multiple delegation tools never register duplicate global controls. The root plugin registers `send_message` and the separately loadable `./list-agents` plugin registers `list_agents`; both require only `subagents`, so a deployment can keep `send_message` while omitting the list tool. Neither tool's presence determines whether a delegation tool starts continuable work. These tools own only the parent-to-child direction; the independently installed [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) owns the child-to-parent direction.
|
||||
|
||||
The tool performs no lifecycle routing — residency and cold resume belong to the subagent service. It passes `exec.agent` as the exact live parent that authorizes delivery and attributes every message as durable provenance `{ kind: 'coordinator', senderSessionId: parent.id }`, which the service retains but never treats as authority. Every message becomes the subagent's next FIFO turn through `Agent.followup()`: if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. The tool forwards its execution signal, which owns admission only until inbox acceptance; once the child accepts the message the accepted turn cannot be cancelled through this tool. This call returns no child reply — its transcript by that id is the source of what it did — and a child with `report` sends content on its own initiative as a separate parent message. A delivery failure becomes an errored tool result stating the message was not delivered.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,且只要求 `subagents`;可单独加载的 `./list-agents` 插件注册 `list_agents`,将 `sessionQuery` 声明为加载时依赖,并在该服务可用前保持未激活状态。没有会话查询服务的部署可保留 `send_message` 并省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
|
||||
可选的全局具名 `send_message` 与 `list_agents` 工具是 `ctx.subagents` 之上的轻量适配器。绑定提供方的 `@deepseek-ai/dsh-tool-subagent` 实例会为每种传输注册不同的委派工具;这个单独加载的包只注册一次共享控制工具,因此多个委派工具绝不会重复注册全局控制工具。根插件注册 `send_message`,可单独加载的 `./list-agents` 插件注册 `list_agents`;两者都只要求 `subagents`,部署可保留 `send_message` 而省略列表工具。是否加载这些工具不会决定委派工具是否启动可继续工作。这些工具只负责父到子的方向;单独安装的 [`@deepseek-ai/dsh-tool-subagent-report`](../tool-subagent-report/README.md) 负责子到父的方向。
|
||||
|
||||
本工具不执行生命周期路由:驻留与冷恢复归 subagent 服务所有。它将 `exec.agent` 作为授权投递的确切在线父级传入,并把每条消息的来源标记为持久化来源 `{ kind: 'coordinator', senderSessionId: parent.id }`;服务会保留该来源,但绝不将其视为权限。每条消息都会通过 `Agent.followup()` 成为子 agent(智能体)的下一个 FIFO 轮次:如果子 agent 仍在工作,该消息会等待其当前轮次结束,因此无法重定向已经在进行的工作。本工具会转发其执行信号,该信号只在 inbox 接受之前掌管准入;一旦子 agent 接受消息,已接受的轮次便无法再通过本工具取消。本次调用不会返回子 agent 的回复;通过该 id 查看其 transcript(文本记录),才是了解它完成了哪些工作的真源。拥有 `report` 的子 agent 会自行把内容作为一条单独的父级消息发回。投递失败会变为出错的工具结果,并明确说明消息未送达。
|
||||
|
||||
|
||||
@@ -33,16 +33,10 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-session-query": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
@@ -52,7 +46,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
/**
|
||||
* The globally named `list_agents` tool: a thin model-facing adapter over
|
||||
* the continuable projection of `ctx.subagents.listChildren()`. It is
|
||||
* separately loadable from the
|
||||
* root `send_message` plugin because it additionally requires the session
|
||||
* query service — a deployment may use `send_message` without loading session
|
||||
* query, and this plugin remains inactive until that service is available.
|
||||
* the continuable projection of `ctx.subagents.listChildren()`. It stays
|
||||
* separately loadable from the root `send_message` plugin so a deployment
|
||||
* can register `send_message` without exposing the list tool.
|
||||
* @module @deepseek-ai/dsh-tool-subagent-control/list-agents
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-session-query'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
export const name = 'tool-subagent-list-agents'
|
||||
export const inject = ['tools', 'subagents', 'sessionQuery']
|
||||
export const inject = ['tools', 'subagents']
|
||||
|
||||
type ListAgentsEntry =
|
||||
| {
|
||||
@@ -31,7 +28,7 @@ type ListAgentsEntry =
|
||||
|
||||
/**
|
||||
* Register the `list_agents` tool.
|
||||
* @param ctx - context carrying the tool registry, subagent service, and session query.
|
||||
* @param ctx - context carrying the tool registry and subagent service.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
|
||||
@@ -8,11 +8,11 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import { TestSessionQueryService } from '../../../session-query/session-query/tests/test-service.ts'
|
||||
import * as tool from '../src/list-agents.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
@@ -29,9 +29,9 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(tool)
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
@@ -177,17 +177,16 @@ describe('dsh-tool-subagent-control/list-agents', () => {
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
const fiber = await ctx.plugin(tool)
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(false)
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape and requires sessionQuery at load', () => {
|
||||
it('has the namespace-plugin export shape', () => {
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-subagent-list-agents')
|
||||
expect(tool.inject).toEqual(['tools', 'subagents', 'sessionQuery'])
|
||||
expect(tool.inject).toEqual(['tools', 'subagents'])
|
||||
expect(typeof tool.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
@@ -27,6 +28,7 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0]) {
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(tool)
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
{
|
||||
"path": "../subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user