diff --git a/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs b/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs index 5ca8db1ac6..4acea7354d 100644 --- a/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs +++ b/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs @@ -88,7 +88,7 @@ async function exercise(ctx, config) { // A transitive repository package the registry served, absent from npm at this version. const localDependency = JSON.parse(await readFile(join(config.profile, 'node_modules', config.localDependency, 'package.json'), 'utf8')).version const provider = name.endsWith('-codex') ? 'codex' : 'claude-code' - const providerEnabled = ctx.subagents.list().includes(provider) + const providerEnabled = ctx.subagents.getProvider(provider) !== undefined const nativeDir = join(config.profile, 'node_modules', config.nativePackages[name]) const binaryName = `${provider === 'codex' ? 'codex' : 'claude'}${process.platform === 'win32' ? '.exe' : ''}` const nativeBinary = (await readdir(nativeDir, { recursive: true })).find(path => basename(path) === binaryName) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index d120aec139..6d1168b8df 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -712,7 +712,7 @@ describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surfac const productCtx = await bootSelected(installed) const spawn = vi.spyOn(productCtx.subprocess, 'spawn') try { - expect(productCtx.subagents.list().filter(name => (products as readonly string[]).includes(name)).sort()) + expect(products.filter(name => productCtx.subagents.getProvider(name) !== undefined).sort()) .toEqual([...installed].sort()) const expected = installed.map(toolName).sort() for (const preset of ['standard', 'cordis', 'ptc', 'minimal']) { diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 3ca55f2f85..4173379bfd 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -76,10 +76,13 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t expect(resolved.agent.session.snapshotEvents() .filter(event => event.type === 'agent-preset/selected') .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : []) - expect(await scaffold.ctx.subagents.listChildren(id)).toEqual([ - { id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' }, - { id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' }, - ]) + { + using observed = await scaffold.ctx.sessionQuery.observeSession(id) + expect(observed.projections?.values.subagentCatalog).toEqual([ + { id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' }, + { id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' }, + ]) + } const childSuccessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, SESSION_FORMAT_VERSION, 'zstd') await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index dfea4006eb..f24f6cf4dc 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -307,25 +307,23 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() - expect(await scaffold.ctx.subagents.listChildren(parent.id)).toMatchObject([ - { - id: childId, mode: 'continuable', label: LABEL, - }, - { - id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL, - }, - ]) + { + using observed = await scaffold.ctx.sessionQuery.observeSession(parent.id) + expect(observed.projections?.values.subagentCatalog).toMatchObject([ + { + id: childId, mode: 'continuable', label: LABEL, + }, + { + id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL, + }, + ]) + } { using observed = await scaffold.ctx.sessionQuery.observeSession(childId) expect(observed.source).toBe('prepared') expect(observed.projections?.values.subagentCatalog).toEqual([ { id: grandchildId, createdAt: authoredAt, mode: 'continuable', label: NESTED_LABEL }, ]) - expect(await scaffold.ctx.subagents.listChildren(childId)).toMatchObject([ - { - id: grandchildId, mode: 'continuable', label: NESTED_LABEL, - }, - ]) } await waitForCacheRow(scaffold, childHeader) const cache = scaffold.ctx.storageDomain.get(projectionCacheDomainSpec.name) diff --git a/benchmarks/agent-continuation/child-catalog.worker.ts b/benchmarks/agent-continuation/child-catalog.worker.ts index aedb279937..34808c2d85 100644 --- a/benchmarks/agent-continuation/child-catalog.worker.ts +++ b/benchmarks/agent-continuation/child-catalog.worker.ts @@ -81,7 +81,12 @@ async function run(root: string, mode: string): Promise ctx.subagents.listChildren(PARENT_ID) + const readCatalog = async () => { + using observation = await ctx.sessionQuery.observeSession(PARENT_ID) + const catalog = observation.projections?.values.subagentCatalog + if (catalog === undefined) throw new Error('child-catalog projection is unavailable') + return catalog + } const cpuStart = process.cpuUsage() const start = performance.now() const first = await readCatalog() diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 230e8fa2ae..bbb93c125e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3413,7 +3413,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:187`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 33317fd71a..a5eda4c7c2 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3415,7 +3415,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:187`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ef853bb936..fd62167d4a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -70,10 +70,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:167`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index cd5a0b46f8..3422bc3ad3 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -72,10 +72,10 @@ | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:167`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 508178bfc2..33a716829f 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -12,17 +12,17 @@ en: 6f87231d18d82916 zh: 9f064b23058f8766 /subagents/messages-and-interruption: - en: 6ff748d66293c367 - zh: b9f8a7183a27e7a1 + en: 9befacb931520842 + zh: c214b4fffa779b06 /subagents/durable-enumeration: - en: 30c3607a969696ce - zh: e6359cdf22b1e235 + en: e02e1703b183594c + zh: 130d475a4304f453 /subagents/results-and-backend-handles: en: 69ea5bed7d9fb64a zh: b187a822b813e80a /subagents/the-provider-contract-subagentprovider: en: 226ba8094a8cf96c zh: 719678662905d1eb -/subagents/durable-enumeration-listchildren-listdescendants-and-their-entries: - en: d8ce7969b65ad86e - zh: d39dac6592681bf2 +/subagents/durable-enumeration-listdescendants-and-catalog-entries: + en: 8000138f84fd15fa + zh: 32fa91d3aebd3d01 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 153c74d60b..19af546ee6 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -170,7 +170,7 @@ interface ContinuableCreateSpec { ## Messages and interruption -`sendMessage` requires the exact live sender and a target Session id in the same root tree. Root and continuable members can message across branches; admission revalidates the sender and root. Running targets receive Steer; only an absent target cold-resumes, while its creation ancestors may remain offline. External executions reject follow-ups. Local and external executions report final text, structured output, diagnostics, and status to their direct creator only while it is online; caller delivery suppresses those notices. `drainDescendants` and `drainChildren` await owned teardown. +`sendMessage` requires the exact live sender and a target Session id in the same root tree. Root and continuable members can message across branches; admission revalidates the sender and root. Running targets receive Steer; only an absent target cold-resumes, while its creation ancestors may remain offline. External executions reject follow-ups. Local and external executions report final text, structured output, diagnostics, and status to their direct creator only while it is online; caller delivery suppresses those notices. `drainDescendants` awaits whole-tree teardown; an activation receipt’s `dispose()` releases only that execution. ```ts type-equiv /** Durable attribution for one model-authored message between Agents. */ @@ -223,7 +223,7 @@ type SubagentInterruptAuthority = ## Durable enumeration -`listChildren` reads the parent-owned catalog of all local children and parent-delivery external children. Caller-delivery external executions leave membership and result collection to their caller; local workflow children remain discoverable after completion. External entries carry `mode: 'external'` and cannot open a child Session or accept follow-ups. Catalog entries record membership at creation without execution status. `list_agents` exposes direct continuable children with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents). +The parent-owned `subagentCatalog` projection lists all local children and parent-delivery external children. Caller-delivery external executions leave membership and result collection to their caller; local workflow children remain discoverable after completion. External entries carry `mode: 'external'` and cannot open a child Session or accept follow-ups. Catalog entries record membership at creation without execution status. `list_agents` exposes the root and reachable continuable descendants with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents). ## Results and backend handles @@ -369,11 +369,11 @@ interface SubagentProvider { } ``` -## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries +## Durable enumeration: `listDescendants()` and catalog entries The model-facing `list_agents` adapter reports current activity as `running` or `inactive`. These values do not describe task completion or guarantee that `send_message` will succeed. -`SubagentRuntime.listChildren(parentSessionId, signal?)` reads the parent's `subagentCatalog` view through a live-preferred Session observation and releases that observation on success or failure. It returns direct-child entries in parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection fails explicitly. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` recursively reads those catalogs and derives `hasChildren` from each child catalog. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs. +Direct-child entries are available as `observation.projections?.values.subagentCatalog` through `ctx.sessionQuery.observeSession(parentSessionId, { signal })`; callers release the observation with `using`. Entries follow parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection means discovery is unavailable. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` recursively reads those catalogs and derives `hasChildren` from each child catalog. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs. `SubagentRuntime.listDescendants(rootSessionId)` recursively calls the same catalog reader in stable pre-order, preserving each parent's event order. External entries are leaves without local Sessions. One-shot and unknown-mode entries remain traversal nodes; unknown modes produce `unsupported` diagnostics. An unreadable child catalog produces `corrupt` or `unavailable` and stops only that branch. Root read failures, missing services or projections, and cancellation reject the listing. Each reachable catalog is observed once and released before the next read; repeated ids and cycles are skipped. Sessions absent from reachable catalogs are not discovered, including ordinary Session forks and any subagents below those forks. Each row carries its catalog parent and root-relative depth: @@ -491,29 +491,6 @@ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): vo */ async drainDescendants(parents: readonly Agent[]): Promise -/** - * Release selected resident direct children of one exact live - * parent. Every unselected member, including their descendants, stays resident. - * Absent targets and a manager-less composition are accepted no-ops. - * @param parent - exact live direct parent authorizing the selected release. - * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected activation released its execution handle. - * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a - * different parent or the supplied parent identity is stale. - */ -async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise - -/** - * Read the parent's durable direct-child catalog without loading or resuming an Agent. - * The service owns and releases the live-preferred Session observation. - * @param parentSessionId - parent whose direct children are requested. - * @param signal - cancellation forwarded to the Session query. - * @returns catalog children in parent event order. - * @throws {@link SubagentError} when query or catalog projection is unavailable. - * @throws SessionQueryError when the parent cannot be read or the query is cancelled. - */ -listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise - /** * Recursively list reachable parent catalogs in stable pre-order, preserving * each catalog's event order. Each row carries its catalog parent and depth; @@ -580,12 +557,6 @@ registerProvider(provider: SubagentProvider): () => void * @returns the provider, or undefined when absent. */ getProvider(name: string): SubagentProvider | undefined - -/** - * List registered provider names in insertion order. - * @returns the registered names. - */ -list(): string[] ``` Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 35fb760aad..b9bc5c23af 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -170,7 +170,7 @@ interface ContinuableCreateSpec { ## 消息与中断 -`sendMessage` 要求确切在线发送方和同一根树中的目标 Session id。根和可继续成员可跨分支发消息;接纳时重新校验发送方和根。运行中目标接收 Steer;只冷恢复不在线的目标,其创建祖先可以保持离线。外部执行拒绝后续输入。本地和外部执行仅向仍在线的直接创建者报告最终文本、结构化输出、诊断和状态;caller 投递抑制这些通知。`drainDescendants` 和 `drainChildren` 等待所属资源释放。 +`sendMessage` 要求确切在线发送方和同一根树中的目标 Session id。根和可继续成员可跨分支发消息;接纳时重新校验发送方和根。运行中目标接收 Steer;只冷恢复不在线的目标,其创建祖先可以保持离线。外部执行拒绝后续输入。本地和外部执行仅向仍在线的直接创建者报告最终文本、结构化输出、诊断和状态;caller 投递抑制这些通知。`drainDescendants` 等待整棵树释放;activation 回执的 `dispose()` 仅释放那一次执行。 ```ts type-equiv /** Durable attribution for one model-authored message between Agents. */ @@ -223,7 +223,7 @@ type SubagentInterruptAuthority = ## 持久枚举 -`listChildren` 读取父级拥有的目录,其中包含所有本地子级及 parent 投递的外部子级。caller 投递的外部执行由调用方负责成员关系与结果收集;本地工作流子级在完成后仍可被发现。外部条目携带 `mode: 'external'`,不能打开子 Session 或接受后续消息。目录在创建时记录成员关系,不携带执行状态。`list_agents` 展示可继续的直接子级,其活跃状态为 `running` 或 `inactive`;参见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。 +父级拥有的 `subagentCatalog` 投影列出所有本地子级及 parent 投递的外部子级。caller 投递的外部执行由调用方负责成员关系与结果收集;本地工作流子级在完成后仍可被发现。外部条目携带 `mode: 'external'`,不能打开子 Session 或接受后续消息。目录在创建时记录成员关系,不携带执行状态。`list_agents` 展示根和可达的可继续后代,其活跃状态为 `running` 或 `inactive`;参见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。 ## 结果与后端句柄 @@ -369,11 +369,11 @@ interface SubagentProvider { } ``` -## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目 +## 持久化枚举:`listDescendants()` 与目录条目 模型侧的 `list_agents` 适配器将当前活动表示为 `running` 或 `inactive`。这些值不描述任务完成情况,也不保证 `send_message` 会成功。 -`SubagentRuntime.listChildren(parentSessionId, signal?)` 通过优先使用在线 Session 的观察读取父会话的 `subagentCatalog` 视图,并在成功或失败时释放观察。它按父会话事件顺序返回直接子级条目,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影时显式失败。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 递归读取这些目录,并从每个子级目录推导 `hasChildren`。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。 +通过 `ctx.sessionQuery.observeSession(parentSessionId, { signal })`,可从 `observation.projections?.values.subagentCatalog` 获取直接子级条目;调用方使用 `using` 释放观察对象。条目遵循父会话事件顺序,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影意味着发现功能不可用。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 递归读取这些目录,并从每个子级目录推导 `hasChildren`。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。 `SubagentRuntime.listDescendants(rootSessionId)` 以稳定前序递归调用同一目录读取函数,保留每个父级的事件顺序。外部条目是没有本地 Session 的叶节点。一次性和未知模式条目仍是遍历节点;未知模式产生 `unsupported` 诊断。无法读取的子级目录产生 `corrupt` 或 `unavailable`,只停止该分支。根读取失败、服务或投影缺失、取消会使整个查询失败。每个可达目录只观察一次,并在下一次读取前释放;重复 id 和循环引用会被跳过。可达目录中不存在的 Session 不会被发现,包括普通 Session fork 及其下的子 agent。每行携带目录中的父级和相对根的深度: @@ -493,29 +493,6 @@ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): vo */ async drainDescendants(parents: readonly Agent[]): Promise -/** - * Release selected resident direct children of one exact live - * parent. Every unselected member, including their descendants, stays resident. - * Absent targets and a manager-less composition are accepted no-ops. - * @param parent - exact live direct parent authorizing the selected release. - * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected activation released its execution handle. - * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a - * different parent or the supplied parent identity is stale. - */ -async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise - -/** - * Read the parent's durable direct-child catalog without loading or resuming an Agent. - * The service owns and releases the live-preferred Session observation. - * @param parentSessionId - parent whose direct children are requested. - * @param signal - cancellation forwarded to the Session query. - * @returns catalog children in parent event order. - * @throws {@link SubagentError} when query or catalog projection is unavailable. - * @throws SessionQueryError when the parent cannot be read or the query is cancelled. - */ -listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise - /** * Recursively list reachable parent catalogs in stable pre-order, preserving * each catalog's event order. Each row carries its catalog parent and depth; @@ -582,12 +559,6 @@ registerProvider(provider: SubagentProvider): () => void * @returns the provider, or undefined when absent. */ getProvider(name: string): SubagentProvider | undefined - -/** - * List registered provider names in insertion order. - * @returns the registered names. - */ -list(): string[] ``` Types: [Agent](core.zh.md) · [ContentBlock](llm-streaming.zh.md) · [MessageId](llm-streaming.zh.md) · [SessionId](core.zh.md) diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.i18n.yaml new file mode 100644 index 0000000000..464cd3ccbe --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md +/: + en: 6955b11d6f698e4c + zh: 8a8d4d6edd5dea0c +/subagent-service-method-removal: + en: 102f43348f7c8eb0 + zh: be09ff117c31ada1 +/subagent-service-method-removal/change: + en: 8cd7f93df401c417 + zh: 27313127c2059e6b +/subagent-service-method-removal/migration: + en: f0f0f921f284efec + zh: 8b9e88e9412282c2 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md new file mode 100644 index 0000000000..a1eaad618b --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "The subagent service removes drainChildren, listChildren, and list." +--- + +# Subagent service method removal + +English | [中文](guide.zh.md) + +## Change + +`ctx.subagents` removes `drainChildren(parent, childIds)`, `listChildren(parentSessionId, signal?)`, and `list()`. This affects plugins that release resident children by id, read direct-child catalogs through the service, or enumerate registered provider names. + +## Migration + +1. Retain the activation returned by `startActivation()` and await its `dispose()` to release that exact execution. An old activation cannot release a later execution of the same Session. There is no replacement for releasing the current resident child by id; use `drainDescendants([root])` only when closing the entire tree. `interrupt()` stops current work but preserves a local Activation. +2. For direct-child entries, use `ctx.sessionQuery.observeSession(parentSessionId, { signal })`, read `observation.projections?.values.subagentCatalog`, and dispose the observation with `using`. Load Session Query and the subagent service, and treat a missing catalog projection as unavailable. Keep `listDescendants()` for recursive discovery; its rows differ from direct catalog entries. +3. Use `getProvider(name)` to check known provider names. The service no longer enumerates all registered names. +4. Rebuild plugins against the updated service declarations. Verify registration and unload checks, exact-activation disposal, and catalog observation cleanup. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.zh.md new file mode 100644 index 0000000000..ce24ef1b90 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "子代理服务移除 drainChildren、listChildren 和 list。" +--- + +# 子代理服务方法移除 + +[English](guide.md) | 中文 + +## 变更 + +`ctx.subagents` 移除 `drainChildren(parent, childIds)`、`listChildren(parentSessionId, signal?)` 和 `list()`。这会影响按 id 释放驻留子代理、通过服务读取直接子级目录或枚举已注册 provider 名称的插件。 + +## 迁移 + +1. 保留 `startActivation()` 返回的 activation,并等待其 `dispose()` 完成以释放那一次执行。旧 activation 不能释放同一 Session 后来恢复的执行。按 id 释放当前驻留子代理没有替代方法;仅在关闭整棵树时使用 `drainDescendants([root])`。`interrupt()` 停止当前工作,但保留本地 Activation。 +2. 读取直接子级条目时,使用 `ctx.sessionQuery.observeSession(parentSessionId, { signal })`,读取 `observation.projections?.values.subagentCatalog`,并通过 `using` 释放观察对象。加载 Session Query 和子代理服务,并将缺失的目录投影视为不可用。递归发现继续使用 `listDescendants()`;它返回的行与直接目录条目不同。 +3. 使用 `getProvider(name)` 检查已知 provider 名称。服务不再枚举全部已注册名称。 +4. 根据更新后的服务声明重新构建插件,验证注册和卸载检查、指定 activation 的释放以及目录观察对象的清理。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 6e842cf61a..9552b1eab9 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -551,12 +551,9 @@ - id: tool-result-pruner disabled: true -# The subagent registry and its backends STAY in the host plane. `subagents` is -# a process singleton with a cross-session query surface (`listChildren`, -# `followup`) that the host api-proxy serves to the browser, and a provider -# registers under a globally unique name, so a per-session copy would both -# starve that host row and collide on the second session. What a preset -# chooses is which delegation TOOLS its agent sees, below. +# The subagent registry and its backends belong in the host composition: browser +# prompt and interrupt requests share its runtime, and provider names are unique +# within the registry. Presets select the delegation tools visible to each Agent. - id: tool-subagent-control disabled: true diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 3b196f4eb4..3c63fc683a 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -9,7 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SubagentService from '@deepseek-ai/dsh-subagent' +import SubagentService, { type SubagentActivation } from '@deepseek-ai/dsh-subagent' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -73,7 +73,7 @@ function content(text: string) { } /** Start one teammate: a continuable child of the Lead. */ -async function spawn(ctx: Context, lead: Agent, description: string | undefined, provider = 'spawn'): Promise { +async function spawn(ctx: Context, lead: Agent, description: string | undefined, provider = 'spawn'): Promise { const started = await ctx.subagents.startActivation({ provider, label: `${description ?? 'shared'} responsibility`, @@ -81,7 +81,7 @@ async function spawn(ctx: Context, lead: Agent, description: string | undefined, signal: SIGNAL, delivery: 'parent', }) - return started.childId + return started } async function waitNoAgent(ctx: Context, id: SessionId): Promise { @@ -147,8 +147,9 @@ describe('Tree-owned task boards', () => { it('makes every root a Lead and every live subagent below it a teammate', async () => { const { ctx, lead } = await setup(['hang', 'hang', 'hang']) - const named = await waitRunning(ctx, await spawn(ctx, lead, 'alpha')) - const unnamed = await waitRunning(ctx, await spawn(ctx, lead, undefined)) + const namedActivation = await spawn(ctx, lead, 'alpha') + const named = await waitRunning(ctx, namedActivation.childId) + const unnamed = await waitRunning(ctx, (await spawn(ctx, lead, undefined)).childId) const grandchildId = (await ctx.subagents.startActivation({ provider: 'spawn', label: 'nested', delivery: 'caller', signal: SIGNAL, prompt: content('nested'), parent: named, @@ -160,7 +161,7 @@ describe('Tree-owned task boards', () => { expect(ctx.agentTeams.getTask(agent, shared.id).id).toBe(shared.id) } - await ctx.subagents.drainChildren(lead, [named.id]) + await namedActivation.dispose() await waitNoAgent(ctx, named.id) const task = await ctx.agentTeams.createTask(grandchild, { subject: 'still active', description: 'creator offline' }) expect(ctx.agentTeams.listTasks(lead).map(row => row.id)).toContain(task.id) @@ -174,7 +175,7 @@ describe('Tree-owned task boards', () => { it('treats an ordinary fork as a new Root Team and filters inherited Team state', async () => { const { ctx, lead } = await setup([textResponse('alpha done')]) - const alpha = await spawn(ctx, lead, 'alpha') + const alpha = (await spawn(ctx, lead, 'alpha')).childId await waitNoAgent(ctx, alpha) await ctx.agentTeams.createTask(lead, { subject: 'parent task', description: 'belongs to parent' }) const handle = await ctx.agents.create({ @@ -195,8 +196,8 @@ describe('Tree-owned task boards', () => { it('assigns deep descendants by id and rejects historical roster authority', async () => { const { ctx, lead } = await setup(['hang', 'hang']) - const child = await waitRunning(ctx, await spawn(ctx, lead, 'parent')) - const grandchild = await waitRunning(ctx, await spawn(ctx, child, 'nested')) + const child = await waitRunning(ctx, (await spawn(ctx, lead, 'parent')).childId) + const grandchild = await waitRunning(ctx, (await spawn(ctx, child, 'nested')).childId) const task = await ctx.agentTeams.createTask(lead, { subject: 'nested work', description: 'deep assignment' }) const assigned = await ctx.agentTeams.updateTask(lead, { taskId: task.id, expectedRevision: task.revision, action: 'reassign', owner: grandchild.id, @@ -280,7 +281,7 @@ describe('Tree-owned task boards', () => { sessionId: SessionId('temporary-parent'), agentOptions: { provider: 'mock', model: 'mock' }, }) - const child = await spawn(ctx, parent.agent, 'orphan') + const child = (await spawn(ctx, parent.agent, 'orphan')).childId await waitNoAgent(ctx, child) await parent.dispose() const orphan = await ctx.agents.resume({ @@ -348,9 +349,9 @@ describe('Team shared task DAG', () => { it('enforces CAS, ownership, dependencies, transitions, and write-scope warnings', async () => { const { ctx, lead } = await setup(['hang', 'hang', textResponse('beta integrated update')]) const firstMember = await spawn(ctx, lead, 'alpha') - const alpha = await waitRunning(ctx, firstMember) + const alpha = await waitRunning(ctx, firstMember.childId) const secondMember = await spawn(ctx, lead, 'beta') - const beta = await waitRunning(ctx, secondMember) + const beta = await waitRunning(ctx, secondMember.childId) const first = await ctx.agentTeams.createTask(alpha, { subject: 'first', @@ -416,7 +417,7 @@ describe('Team shared task DAG', () => { expect(released).toMatchObject({ status: 'pending', ready: true }) expect('ownerId' in released).toBe(false) - await ctx.subagents.drainChildren(lead, [alpha.id, beta.id]) + await Promise.all([firstMember.dispose(), secondMember.dispose()]) await Promise.all([waitNoAgent(ctx, alpha.id), waitNoAgent(ctx, beta.id)]) }) @@ -495,7 +496,7 @@ describe('Team shared task DAG', () => { it('supports Lead reassignment, completion, reopen, and deletion permissions', async () => { const { ctx, lead } = await setup(['hang']) const started = await spawn(ctx, lead, 'owner') - const owner = await waitRunning(ctx, started) + const owner = await waitRunning(ctx, started.childId) const task = await ctx.agentTeams.createTask(owner, { subject: 'lifecycle', description: 'lifecycle' }) const assigned = await ctx.agentTeams.updateTask(lead, { taskId: task.id, @@ -543,14 +544,14 @@ describe('Team shared task DAG', () => { action: 'edit', subject: 'late', })).rejects.toMatchObject({ code: 'TEAM_TASK_DELETED' }) - await ctx.subagents.drainChildren(lead, [owner.id]) + await started.dispose() await waitNoAgent(ctx, owner.id) }) it('covers partial edits, Lead ownership, unassignment, and blocked reassignment', async () => { const { ctx, lead } = await setup(['hang']) const started = await spawn(ctx, lead, 'editor') - const editor = await waitRunning(ctx, started) + const editor = await waitRunning(ctx, started.childId) const blocker = await ctx.agentTeams.createTask(lead, { subject: 'blocker', description: 'blocker' }) const task = await ctx.agentTeams.createTask(lead, { subject: 'draft', @@ -627,7 +628,7 @@ describe('Team shared task DAG', () => { }) expect(rootAssigned).toMatchObject({ status: 'in_progress', ownerId: lead.id }) - await ctx.subagents.drainChildren(lead, [editor.id]) + await started.dispose() await waitNoAgent(ctx, editor.id) }) }) diff --git a/packages/experimental/subagent-oar/tests/subagent-oar.spec.ts b/packages/experimental/subagent-oar/tests/subagent-oar.spec.ts index a1e3254bff..d58bd44436 100644 --- a/packages/experimental/subagent-oar/tests/subagent-oar.spec.ts +++ b/packages/experimental/subagent-oar/tests/subagent-oar.spec.ts @@ -72,7 +72,7 @@ describe('OAR provider composition', () => { expect(ctx.subagents.getProvider('codex-test')).toBeUndefined() expect(ctx.subagents.getProvider('claude-test')).toBeDefined() await second.dispose() - expect(ctx.subagents.list()).toEqual([]) + expect(ctx.subagents.getProvider('claude-test')).toBeUndefined() } finally { await ctx.fiber.dispose() } diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index fa49d38384..43463dc338 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -12,7 +12,7 @@ import { scopeOf } from '@deepseek-ai/dsh-scope' import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' -import SubagentService, { SubagentError } from '@deepseek-ai/dsh-subagent' +import SubagentService, { SubagentError, type SubagentActivation } from '@deepseek-ai/dsh-subagent' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { renderPrompt, renderContextSnapshot } from '@deepseek-ai/dsh-system-prompt' @@ -125,7 +125,7 @@ async function startWait(ctx: Context, agent: Agent) { } /** Start a continuable child of the Lead with a task description. */ -async function spawn(ctx: Context, lead: Agent, description: string | undefined, prompt: string, provider = 'spawn'): Promise { +async function spawn(ctx: Context, lead: Agent, description: string | undefined, prompt: string, provider = 'spawn'): Promise { const started = await ctx.subagents.startActivation({ provider, label: `${description ?? 'shared'} task`, @@ -133,7 +133,7 @@ async function spawn(ctx: Context, lead: Agent, description: string | undefined, signal: SIGNAL, delivery: 'parent', }) - return started.childId + return started } async function assembly(ctx: Context, agent: Agent) { @@ -167,14 +167,11 @@ async function waitNoAgent(ctx: Context, id: SessionId): Promise { await vi.waitFor(() => { expect(ctx.agents.get(id)).toBeUndefined() }, { timeout: 5_000 }) } -async function interrupt(ctx: Context, lead: Agent, target: SessionId): Promise { - await ctx.subagents.drainChildren(lead, [target]) -} - describe('dsh-tool-team', () => { it.each([true, false])('cancels reassignment without appending when discovery observes cancellation: %s', async (observesCancellation) => { const { ctx, lead } = await setup(['hang']) - const childId = await spawn(ctx, lead, 'worker', 'stay running') + const childActivation = await spawn(ctx, lead, 'worker', 'stay running') + const childId = childActivation.childId await waitRunning(ctx, childId) const task = await ctx.agentTeams.createTask(lead, { subject: 'work', description: 'assign work' }) const entered = Promise.withResolvers() @@ -218,8 +215,10 @@ describe('dsh-tool-team', () => { expect(ctx.tools.get(name, scopeOf(lead.ctx))).toBe(ctx.tools.get(name)) } - const namedId = await spawn(ctx, lead, 'reviewer', 'stay available') - const unnamedId = await spawn(ctx, lead, undefined, 'stay available too') + const namedActivation = await spawn(ctx, lead, 'reviewer', 'stay available') + const namedId = namedActivation.childId + const unnamedActivation = await spawn(ctx, lead, undefined, 'stay available too') + const unnamedId = unnamedActivation.childId for (const id of [namedId, unnamedId]) { const child = await waitRunning(ctx, id) const childAssembly = await assembly(ctx, child) @@ -228,8 +227,8 @@ describe('dsh-tool-team', () => { expect(renderContextSnapshot(childAssembly)).toContain(teammateIdentity(lead)) expect(ctx.tools.get('send_message', scopeOf(child.ctx))).toBe(ctx.tools.get('send_message')) } - await interrupt(ctx, lead, namedId) - await interrupt(ctx, lead, unnamedId) + await namedActivation.dispose() + await unnamedActivation.dispose() await Promise.all([waitNoAgent(ctx, namedId), waitNoAgent(ctx, unnamedId)]) }) @@ -241,7 +240,8 @@ describe('dsh-tool-team', () => { await runTurn(lead, 'Parent task') const parentRequest = serializeRequest(adapter.requests[0]!) const parentHistory = structuredClone(lead.session.deriveMessages()) - const childId = await spawn(ctx, lead, 'reviewer', 'Review the work', provider) + const childActivation = await spawn(ctx, lead, 'reviewer', 'Review the work', provider) + const childId = childActivation.childId await waitNoAgent(ctx, childId) const childRequest = serializeRequest(adapter.requests[1]!) expect(childRequest.tools).toEqual(parentRequest.tools) @@ -297,7 +297,8 @@ describe('dsh-tool-team', () => { it.each([undefined, 'in-history'] as const)('appends current Lead identity to a teammate fork with update mode %s', async (systemPromptUpdate) => { const { ctx, lead, adapter } = await setup([textResponse('review done'), textResponse('lead notified'), textResponse('fork answer')]) if (systemPromptUpdate !== undefined) adapter.systemPromptUpdate = systemPromptUpdate - const teammateId = await spawn(ctx, lead, 'reviewer', 'Review the work') + const teammateActivation = await spawn(ctx, lead, 'reviewer', 'Review the work') + const teammateId = teammateActivation.childId await waitNoAgent(ctx, teammateId) await using persisted = await ctx.sessionPersistence.open(teammateId, 'read') const { events: seed } = await persisted.read() @@ -346,7 +347,8 @@ describe('dsh-tool-team', () => { } return next() }) - const childId = await spawn(ctx, lead, 'reviewer', 'Review the work') + const childActivation = await spawn(ctx, lead, 'reviewer', 'Review the work') + const childId = childActivation.childId await waitNoAgent(ctx, childId) for (const request of adapter.requests.filter(request => request.sessionId === childId)) { const snapshots = request.messages.filter(message => message.source?.kind === 'runtime-context') @@ -362,7 +364,8 @@ describe('dsh-tool-team', () => { it('keeps required Team identities when optional runtime context is suppressed', async () => { const { ctx, lead, adapter } = await setup([textResponse('worker done')]) ctx.systemPrompt.suppressRuntimeContext() - const childId = await spawn(ctx, lead, 'reviewer', 'Review the work') + const childActivation = await spawn(ctx, lead, 'reviewer', 'Review the work') + const childId = childActivation.childId await waitNoAgent(ctx, childId) expect(renderContextSnapshot(await assembly(ctx, lead))).toContain(leadIdentity(lead)) expect(runtimeContextText(adapter.requests[0]!)).toContain(teammateIdentity(lead)) @@ -380,7 +383,8 @@ describe('dsh-tool-team', () => { it('adapts task CAS operations and waits to canonical JSON', async () => { const { ctx, lead } = await setup(['hang']) - const childId = await spawn(ctx, lead, 'json-worker', 'wait') + const childActivation = await spawn(ctx, lead, 'json-worker', 'wait') + const childId = childActivation.childId const child = await waitRunning(ctx, childId) const created = await execute(ctx, lead, 'team_task_create', { @@ -419,13 +423,14 @@ describe('dsh-tool-team', () => { expect(completed.isError).toBe(false) expect(JSON.parse(text(await wait))).toEqual({ timedOut: false }) - await interrupt(ctx, lead, childId) + await childActivation.dispose() await waitNoAgent(ctx, childId) }) it('adapts optional task filters, mutations, pagination, and default waiting', async () => { const { ctx, lead } = await setup(['hang']) - const childId = await spawn(ctx, lead, 'fork-worker', 'stay active', 'fork') + const childActivation = await spawn(ctx, lead, 'fork-worker', 'stay active', 'fork') + const childId = childActivation.childId await waitRunning(ctx, childId) const firstResult = await execute(ctx, lead, 'team_task_create', { @@ -487,13 +492,14 @@ describe('dsh-tool-team', () => { expect(wake.isError).toBe(false) expect(JSON.parse(text(await wait))).toEqual({ timedOut: false }) - await interrupt(ctx, lead, childId) + await childActivation.dispose() await waitNoAgent(ctx, childId) }) it('removes and reinstalls every scoped registration across plugin HMR without stopping the child', async () => { const { ctx, lead, fiber } = await setup(['hang']) - const childId = await spawn(ctx, lead, 'hmr-worker', 'wait') + const childActivation = await spawn(ctx, lead, 'hmr-worker', 'wait') + const childId = childActivation.childId const child = await waitRunning(ctx, childId) await fiber.dispose() @@ -512,7 +518,7 @@ describe('dsh-tool-team', () => { expect(renderContextSnapshot(assembled)).toContain(agent === lead ? leadIdentity(lead) : teammateIdentity(lead)) } expect(child.status).toBe('running') - await interrupt(ctx, lead, childId) + await childActivation.dispose() await waitNoAgent(ctx, childId) await replacement.dispose() }) @@ -636,7 +642,8 @@ describe('dsh-tool-team', () => { it('installs Team scope before a cold-resumed teammate request', async () => { const { ctx, lead, adapter } = await setup([textResponse('first'), textResponse('lead received settlement'), 'hang']) - const childId = await spawn(ctx, lead, 'cold-worker', 'finish once') + const childActivation = await spawn(ctx, lead, 'cold-worker', 'finish once') + const childId = childActivation.childId await waitNoAgent(ctx, childId) await vi.waitFor(() => { expect(adapter.requests.filter(request => request.sessionId === lead.id)).toHaveLength(1) @@ -651,7 +658,7 @@ describe('dsh-tool-team', () => { await vi.waitFor(() => { expect(childRequests()).toHaveLength(2) }) const firstMessages = childRequests()[0]!.messages expect(childRequests()[1]!.messages.slice(0, firstMessages.length)).toEqual(firstMessages) - await interrupt(ctx, lead, childId) + await ctx.subagents.drainDescendants([lead]) await waitNoAgent(ctx, childId) }) @@ -663,7 +670,8 @@ describe('dsh-tool-team', () => { sessionId: SessionId('temporary-parent'), agentOptions: { provider: 'mock', model: 'mock' }, }) - const childId = await spawn(ctx, parent.agent, 'orphan', 'finish') + const childActivation = await spawn(ctx, parent.agent, 'orphan', 'finish') + const childId = childActivation.childId await waitNoAgent(ctx, childId) await vi.waitFor(() => { expect(adapter.requests.filter(request => request.sessionId === parent.agent.id)).toHaveLength(1) }) await parent.agent.whenIdle() diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4155c0662a..b00cf88eb3 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3168,20 +3168,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'once all admitted startup and member cleanup have settled.', throws: ['an aggregate error after all branches settle when any failed.'], }, - { - signature: 'async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise', - description: 'Release selected resident direct children of one exact live parent. Every unselected member, including their descendants, stays resident. Absent targets and a manager-less composition are accepted no-ops.', - parameters: [{ name: 'parent', description: 'exact live direct parent authorizing the selected release.' }, { name: 'childIds', description: 'durable direct-child ids to release when resident.' }], - returns: 'once every selected activation released its execution handle.', - throws: ['{SubagentError} `UNAUTHORIZED` when a resident target belongs to a different parent or the supplied parent identity is stale.'], - }, - { - signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise', - description: 'Read the parent\'s durable direct-child catalog without loading or resuming an Agent. The service owns and releases the live-preferred Session observation.', - parameters: [{ name: 'parentSessionId', description: 'parent whose direct children are requested.' }, { name: 'signal', description: 'cancellation forwarded to the Session query.' }], - returns: 'catalog children in parent event order.', - throws: ['{@link SubagentError} when query or catalog projection is unavailable.', 'SessionQueryError when the parent cannot be read or the query is cancelled.'], - }, { signature: 'listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise', description: 'Recursively list reachable parent catalogs in stable pre-order, preserving each catalog\'s event order. Each row carries its catalog parent and depth; external children are leaves; one-shot and unknown-mode children remain traversal nodes. Unknown modes produce unsupported diagnostics. Unreadable child catalogs produce corrupt or unavailable diagnostics and stop only that branch. Root read failures, missing services or projections, and cancellation reject the whole listing. Each catalog is observed once and released before the next read. No Agent is loaded or resumed; Sessions absent from reachable catalogs are omitted.', @@ -3215,12 +3201,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'name', description: 'the provider name.' }], returns: 'the provider, or undefined when absent.', }, - { - signature: 'list(): string[]', - description: 'List registered provider names in insertion order.', - parameters: [], - returns: 'the registered names.', - }, ], }, { @@ -8098,10 +8078,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentCapabilities', declaration: 'export interface SubagentCapabilities {\n readonly agentOptions: boolean;\n readonly agentPreset: boolean;\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n}', }, - { - name: 'SubagentCatalogEntry', - declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & (KnownSubagentCatalogFields | {\n readonly mode: \'unknown\';\n readonly label?: string;\n});', - }, { name: 'SubagentCatalogRow', declaration: 'export type SubagentCatalogRow = {\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n} & KnownSubagentCatalogFields;', @@ -8164,7 +8140,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRuntime', - declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config;\n static inject;\n constructor(ctx: Context, private config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n startActivation(spec: SubagentActivationSpec & {\n readonly childId: SessionId;\n }): Promise;\n startActivation(spec: SubagentActivationSpec): Promise;\n async startActivation(spec: SubagentActivationSpec): Promise;\n rootOf(caller: Agent): Agent;\n async waitForTreeWork(root: Agent): Promise;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainDescendants(parents: readonly Agent[]): Promise;\n async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote({ name: \'prompt\', resources: { session: request => request.childSessionId } })\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote({ name: \'interruptByUser\', re /* …truncated — full shape in source */', + declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config;\n static inject;\n constructor(ctx: Context, private config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n startActivation(spec: SubagentActivationSpec & {\n readonly childId: SessionId;\n }): Promise;\n startActivation(spec: SubagentActivationSpec): Promise;\n async startActivation(spec: SubagentActivationSpec): Promise;\n rootOf(caller: Agent): Agent;\n async waitForTreeWork(root: Agent): Promise;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainDescendants(parents: readonly Agent[]): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote({ name: \'prompt\', resources: { session: request => request.childSessionId } })\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote({ name: \'interruptByUser\', resources: { session: childSessionId => childSessionId } })\n interruptByUser(childSessionId: SessionId): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => /* …truncated — full shape in source */', }, { name: 'SubagentSendMessageOptions', diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index c150ff4396..b3381bf565 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1570,9 +1570,9 @@ describe('dsh-subagent-acp', () => { await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} }) - expect(ctx.subagents.list()).toEqual(['acp']) + expect(ctx.subagents.getProvider('acp')).toBeDefined() await fiber.dispose() - expect(ctx.subagents.list()).toEqual([]) + expect(ctx.subagents.getProvider('acp')).toBeUndefined() }) it('has the namespace-plugin export shape (no stray default)', () => { diff --git a/packages/subagent/subagent-claude-code/tests/fixtures/loader/driver.ts b/packages/subagent/subagent-claude-code/tests/fixtures/loader/driver.ts index a00eb34bd6..1536bc2db3 100644 --- a/packages/subagent/subagent-claude-code/tests/fixtures/loader/driver.ts +++ b/packages/subagent/subagent-claude-code/tests/fixtures/loader/driver.ts @@ -72,8 +72,7 @@ try { .sort() process.stdout.write(`${JSON.stringify({ - registeredProviders: ctx.subagents.list().filter(providerName => - providerNames.includes(providerName as typeof providerNames[number])).sort(), + registeredProviders: providerNames.filter(providerName => ctx.subagents.getProvider(providerName) !== undefined).sort(), providers, tools, jobTools, diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 6ecb6226d6..00a31ccd10 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -428,7 +428,7 @@ describe('real Claude Agent SDK 0.3.263 and its distributed Claude Code 2.1.263 ]) await safeInstance.fixture.requestStarted await safeFiber.dispose() - expect(ctx.subagents.list()).toEqual(['claude-bypass']) + expect(ctx.subagents.getProvider('claude-bypass')).toBeDefined() await expect(startExternalActivation(ctx, 'claude-safe', { prompt: [{ type: 'text', text: 'This start must fail.' }], parent: safeParent, @@ -458,7 +458,7 @@ describe('real Claude Agent SDK 0.3.263 and its distributed Claude Code 2.1.263 ].sort()) await expectQuiescent(handles) await bypassFiber.dispose() - expect(ctx.subagents.list()).toEqual([]) + expect(ctx.subagents.getProvider('claude-bypass')).toBeUndefined() }) it('maps a real CLI process failure to its exit outcome', async () => { diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index cce9e35bda..4b46c397d5 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -453,9 +453,9 @@ describe('task admission and package contracts', () => { }, inheritsParentContext: false, }) - expect(ctx.subagents.list()).toEqual(['claude-code']) + expect(ctx.subagents.getProvider('claude-code')).toBeDefined() await fiber.dispose() - expect(ctx.subagents.list()).toEqual([]) + expect(ctx.subagents.getProvider('claude-code')).toBeUndefined() for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { await expect(ctx.plugin(claudeCode, { disposeGraceMs })) @@ -519,7 +519,8 @@ describe('task admission and package contracts', () => { permissionMode: 'bypassPermissions', disposeGraceMs: 29, }) - expect(ctx.subagents.list()).toEqual(['claude-safe', 'claude-bypass']) + expect(ctx.subagents.getProvider('claude-safe')).toBeDefined() + expect(ctx.subagents.getProvider('claude-bypass')).toBeDefined() expect(added).toEqual(['claude-safe', 'claude-bypass']) const safeController = new AbortController() @@ -528,7 +529,8 @@ describe('task admission and package contracts', () => { startExternalActivation(ctx, 'claude-bypass', request()), ]) await safeFiber.dispose() - expect(ctx.subagents.list()).toEqual(['claude-bypass']) + expect(ctx.subagents.getProvider('claude-bypass')).toBeDefined() + expect(ctx.subagents.getProvider('claude-safe')).toBeUndefined() expect(removed).toEqual(['claude-safe']) await expect(startExternalActivation(ctx, 'claude-safe', request())) .rejects.toMatchObject({ code: 'NO_PROVIDER' }) @@ -583,7 +585,6 @@ describe('task admission and package contracts', () => { permissionMode: 'bypassPermissions', })).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' }) expect(ctx.subagents.getProvider('claude-duplicate')).toBe(first) - expect(ctx.subagents.list()).toEqual(['claude-duplicate']) await firstFiber.dispose() await ctx.fiber.dispose() }) diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 6ee8fdaba8..4c64b33fd9 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -336,7 +336,7 @@ describe('real @openai/codex 0.153.4 product', () => { ]) await safeInstance.fixture.requestStarted await safeFiber.dispose() - expect(ctx.subagents.list()).toEqual(['codex-bypass']) + expect(ctx.subagents.getProvider('codex-bypass')).toBeDefined() await expect(startExternalActivation(ctx, 'codex-safe', { prompt: [{ type: 'text', text: 'This start must fail.' }], parent: safeParent, @@ -365,7 +365,7 @@ describe('real @openai/codex 0.153.4 product', () => { ].sort()) await expectQuiescent(handles) await bypassFiber.dispose() - expect(ctx.subagents.list()).toEqual([]) + expect(ctx.subagents.getProvider('codex-bypass')).toBeUndefined() }, 60_000) it('overrides on-request with never and reports a denied command safely', async () => { diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 80607bca6c..4e267275f5 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -444,9 +444,9 @@ describe('task admission and package contracts', () => { }, inheritsParentContext: false, }) - expect(ctx.subagents.list()).toEqual(['codex']) + expect(ctx.subagents.getProvider('codex')).toBeDefined() await fiber.dispose() - expect(ctx.subagents.list()).toEqual([]) + expect(ctx.subagents.getProvider('codex')).toBeUndefined() for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { await expect(ctx.plugin(codex, { disposeGraceMs })) @@ -494,7 +494,8 @@ describe('task admission and package contracts', () => { permissionMode: 'dangerously-bypass-approvals-and-sandbox', disposeGraceMs: 29, }) - expect(ctx.subagents.list()).toEqual(['codex-safe', 'codex-bypass']) + expect(ctx.subagents.getProvider('codex-safe')).toBeDefined() + expect(ctx.subagents.getProvider('codex-bypass')).toBeDefined() expect(added).toEqual(['codex-safe', 'codex-bypass']) const safeController = new AbortController() @@ -521,7 +522,8 @@ describe('task admission and package contracts', () => { bypassStarting, ]) await safeFiber.dispose() - expect(ctx.subagents.list()).toEqual(['codex-bypass']) + expect(ctx.subagents.getProvider('codex-bypass')).toBeDefined() + expect(ctx.subagents.getProvider('codex-safe')).toBeUndefined() expect(removed).toEqual(['codex-safe']) await expect(startExternalActivation(ctx, 'codex-safe', request())) .rejects.toMatchObject({ code: 'NO_PROVIDER' }) @@ -576,7 +578,6 @@ describe('task admission and package contracts', () => { permissionMode: 'dangerously-bypass-approvals-and-sandbox', })).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' }) expect(ctx.subagents.getProvider('codex-duplicate')).toBe(first) - expect(ctx.subagents.list()).toEqual(['codex-duplicate']) await firstFiber.dispose() await ctx.fiber.dispose() }) diff --git a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts index b0a5a03529..6556fde357 100644 --- a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts @@ -40,7 +40,8 @@ function text(blocks: readonly { type: string; text?: string }[]): string { describe('multi-subagent coexistence (spawn + fork on one context)', () => { it('both providers register and coexist', async () => { const { ctx } = await setup([]) - expect(ctx.subagents.list().sort()).toEqual(['fork', 'spawn']) + expect(ctx.subagents.getProvider('fork')).toBeDefined() + expect(ctx.subagents.getProvider('spawn')).toBeDefined() }) it('the same parent drives a spawn child AND a fork child, then keeps working', async () => { diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index c9030f3881..601444e5d0 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -199,9 +199,9 @@ describe('dsh-subagent-fork-in-process', () => { await ctx.plugin(SubagentRuntime) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(fork, { providerName: 'fork' }) - expect(ctx.subagents.list()).toEqual(['fork']) + expect(ctx.subagents.getProvider('fork')).toBeDefined() await fiber.dispose() - expect(ctx.subagents.list()).toEqual([]) + expect(ctx.subagents.getProvider('fork')).toBeUndefined() }) it('contributes the completed-turn prefix as a continuable child\'s seed', async () => { diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index 04ff5f6e2b..c254b67c8c 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -281,9 +281,9 @@ describe('dsh-subagent-spawn-in-process', () => { await ctx.plugin(SubagentRuntime) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) - expect(ctx.subagents.list()).toEqual(['spawn']) + expect(ctx.subagents.getProvider('spawn')).toBeDefined() await fiber.dispose() - expect(ctx.subagents.list()).toEqual([]) + expect(ctx.subagents.getProvider('spawn')).toBeUndefined() }) it('captures structured output through the shipped plugin (driver runtime, plugin wiring)', async () => { diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 37c1a5416b..633c2446cc 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -33,8 +33,8 @@ en: a740e5e9bdd661d9 zh: 712a5458fc8b3615 /deepseek-ai-dsh-subagent/use-this-package/messaging-interrupting-and-discovering: - en: 3785a9c76ae22cad - zh: a742559205db0dc6 + en: bf3175c2218a16dd + zh: 4be9271efe71ddb7 /deepseek-ai-dsh-subagent/use-this-package/failure-and-recovery: en: 896bf2515524c01f zh: 1f1413710e58e4f6 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index dd97e4be54..67ced97cfc 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -66,7 +66,7 @@ In-process spawn accepts an independent `agentPreset`; fork and external backend ### Messaging, interrupting, and discovering -`sendMessage()` lets the live root and resident continuable subagents contact any other root or continuable member in the same tree by Session id. The target receives Steer input at its next step or starts an idle turn. Only the cold target resumes, under the subagent tree root; its creation ancestors stay offline and the message retains its actual sender. `rootOf()` resolves the caller’s live root. Agent interruption requires ancestor authority. Browser prompts and user interrupts address only the child Session; the Gateway checks the corresponding action on that Session before any side effect. Parent or global grants do not imply child access. Prompts require a live subagent tree root; the creator may be offline. User interrupts perform no restoration and accept absent targets as no-ops. They select Queue or Steer and admit image batches through the attachment store; incompatible target models reject images. Direct-child discovery reads `subagentCatalog` through `listChildren(parentSessionId, signal?)`, owning and releasing a live-preferred Session observation. Recursive discovery preserves catalog order, reads each reachable Session once, skips external leaves, and reports unreadable branches without activating Agents. +`sendMessage()` lets the live root and resident continuable subagents contact any other root or continuable member in the same tree by Session id. The target receives Steer input at its next step or starts an idle turn. Only the cold target resumes, under the subagent tree root; its creation ancestors stay offline and the message retains its actual sender. `rootOf()` resolves the caller’s live root. Agent interruption requires ancestor authority. Browser prompts and user interrupts address only the child Session; the Gateway checks the corresponding action on that Session before any side effect. Parent or global grants do not imply child access. Prompts require a live subagent tree root; the creator may be offline. User interrupts perform no restoration and accept absent targets as no-ops. They select Queue or Steer and admit image batches through the attachment store; incompatible target models reject images. `listDescendants()` discovers members recursively through parent-owned `subagentCatalog` projections. It preserves catalog order, reads each reachable Session once, skips external leaves, and reports unreadable branches without activating Agents. ### Failure and recovery diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 2c8946cd98..f0f2814b1c 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -66,7 +66,7 @@ kind: "package-reference" ### 消息、中断与发现 -`sendMessage()` 允许在线根和驻留的可继续子代理,按 Session id 联系同一棵树中的任意其他根或可继续成员。目标在下一步接收 Steer 输入,或在空闲时开始轮次。仅在 subagent 树根下恢复冷目标,其创建祖先保持离线;消息保留实际发送者。`rootOf()` 解析调用方的在线根。Agent 中断要求祖先权限。浏览器提示词和用户中断仅按子 Session 寻址;Gateway 在任何副作用之前检查该 Session 上对应操作的权限。父级或 global 授权不意味着子级访问权。提示词要求 subagent 树根在线;创建者可以离线。用户中断不恢复目标,目标缺席时接受为无操作。提示词选择 Queue 或 Steer,经附件存储接受图片批次;不兼容的目标模型拒绝图片。直接子级发现通过 `listChildren(parentSessionId, signal?)` 读取 `subagentCatalog`,持有并释放优先在线的 Session observation。递归发现保留目录顺序,每个可达 Session 读取一次,跳过外部叶子,并报告不可读分支而不激活 Agent。 +`sendMessage()` 允许在线根和驻留的可继续子代理,按 Session id 联系同一棵树中的任意其他根或可继续成员。目标在下一步接收 Steer 输入,或在空闲时开始轮次。仅在 subagent 树根下恢复冷目标,其创建祖先保持离线;消息保留实际发送者。`rootOf()` 解析调用方的在线根。Agent 中断要求祖先权限。浏览器提示词和用户中断仅按子 Session 寻址;Gateway 在任何副作用之前检查该 Session 上对应操作的权限。父级或 global 授权不意味着子级访问权。提示词要求 subagent 树根在线;创建者可以离线。用户中断不恢复目标,目标缺席时接受为无操作。提示词选择 Queue 或 Steer,经附件存储接受图片批次;不兼容的目标模型拒绝图片。`listDescendants()` 通过父级拥有的 `subagentCatalog` 投影递归发现成员。它保留目录顺序,每个可达 Session 读取一次,跳过外部叶子,并报告不可读分支而不激活 Agent。 ### 失败与恢复 diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0159abc6ad..9b2f91c198 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -69,10 +69,9 @@ import { assertSubagentMaxDepth } from './depth.ts' import { createLifecycleEmitter } from './lifecycle.ts' import type { LifecycleEmitter } from './lifecycle.ts' import SubagentManager from './manager.ts' -import { listChildren as listSubagentChildren, listDescendants as listSubagentDescendants } from './list-children.ts' +import { listDescendants as listSubagentDescendants } from './list-children.ts' import type { SubagentDescendantListEntry } from './list-children.ts' import { installSubagentArchiveAdmission } from './archive-admission.ts' -import type { SubagentCatalogEntry } from './projection-types.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' import type { IdentityState } from './projection.ts' import { subagentCatalogProjectionDefinition } from './catalog.ts' @@ -364,35 +363,6 @@ export class SubagentRuntime extends TypertRemoteService { await manager.drainDescendants(parents) } - /** - * Release selected resident direct children of one exact live - * parent. Every unselected member, including their descendants, stays resident. - * Absent targets and a manager-less composition are accepted no-ops. - * @param parent - exact live direct parent authorizing the selected release. - * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected activation released its execution handle. - * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a - * different parent or the supplied parent identity is stale. - */ - async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise { - const manager = this.manager - if (manager === undefined) return - await manager.drainChildren(parent, childIds) - } - - /** - * Read the parent's durable direct-child catalog without loading or resuming an Agent. - * The service owns and releases the live-preferred Session observation. - * @param parentSessionId - parent whose direct children are requested. - * @param signal - cancellation forwarded to the Session query. - * @returns catalog children in parent event order. - * @throws {@link SubagentError} when query or catalog projection is unavailable. - * @throws SessionQueryError when the parent cannot be read or the query is cancelled. - */ - listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise { - return listSubagentChildren(this.ctx, parentSessionId, signal) - } - /** * Recursively list reachable parent catalogs in stable pre-order, preserving * each catalog's event order. Each row carries its catalog parent and depth; @@ -534,14 +504,6 @@ export class SubagentRuntime extends TypertRemoteService { return this.providers.get(name) } - /** - * List registered provider names in insertion order. - * @returns the registered names. - */ - list(): string[] { - return [...this.providers.keys()] - } - /** * Resolve one provider's detached continuable-creation contribution. Method * presence on the provider IS the capability, so a provider without it is diff --git a/packages/subagent/subagent/src/manager.ts b/packages/subagent/subagent/src/manager.ts index 0d339a8bad..4915f46bc5 100644 --- a/packages/subagent/subagent/src/manager.ts +++ b/packages/subagent/subagent/src/manager.ts @@ -282,35 +282,6 @@ export class SubagentManager { if (disposed.status === 'rejected') throw disposed.reason } - /** - * Release selected resident direct children of one exact live parent. - * @param parent - exact live direct parent authorizing the selected release. - * @param childIds - durable direct-child ids to release when resident. - */ - async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise { - if (this.ctx.agents.get(parent.id) !== parent) { - throw new SubagentError('selected child teardown requires the exact live parent agent', 'UNAUTHORIZED') - } - const targets: Activation[] = [] - for (const childId of new Set(childIds)) { - const activation = this.resident.get(childId) - if (activation === undefined) continue - if (activation.ancestors.at(-1) !== parent.id || activation.root !== this.rootOf(parent)) { - throw new SubagentError( - `subagent "${childId}" is not a direct child of agent "${parent.id}"`, - 'UNAUTHORIZED', - ) - } - targets.push(activation) - } - - for (const activation of targets) { - const disposal = this.dispose(activation) - void disposal.catch(() => undefined) - } - await this.disposeRoots(targets, 'selected activation(s)') - } - /** * Start one continuable background child and resolve at initial inbox acceptance. * Every earlier failure disposes any created handle and rolls back Activation diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 021a769ddb..ae7f642b4c 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -1,3 +1,4 @@ +import { listChildren } from '../src/list-children.ts' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -488,7 +489,7 @@ describe('continuable activation capacity', () => { await dispose() if (failDisposal) throw new Error('cleanup report failed') } - const drained = ctx.subagents.drainChildren(parent, [started.childId]) + const drained = started.dispose() const outcome = drained.catch((error: unknown) => error) releaseRun.resolve(undefined) await entered.promise @@ -1710,7 +1711,7 @@ describe('continuable durability and teardown', () => { expect(ctx.agents.get(leaf.childId)).toBeUndefined() }) - it('releases only selected direct children', async () => { + it('disposes one activation without releasing siblings or descendants', async () => { const releaseTarget = Promise.withResolvers() const releaseSibling = Promise.withResolvers() const releaseGrandchild = Promise.withResolvers() @@ -1729,13 +1730,9 @@ describe('continuable durability and teardown', () => { await vi.waitFor(() => { expect(adapter.requests).toHaveLength(3) }) const cancel = vi.spyOn(targetAgent, 'cancel') - // A repeated id folds to one teardown and an absent target is an accepted no-op. - const drained = ctx.subagents.drainChildren( - parent, - [target.childId, target.childId, SessionId('never-materialized')], - ) + const drained = Promise.all([target.dispose(), target.dispose()]) - expect(cancel).toHaveBeenCalledWith({ kind: 'parent' }) + expect(cancel).toHaveBeenCalledExactlyOnceWith({ kind: 'parent' }) expect(ctx.agents.get(sibling.childId)).toBe(siblingAgent) releaseTarget.resolve(undefined) await drained @@ -1748,14 +1745,7 @@ describe('continuable durability and teardown', () => { await waitNoActivation(ctx, sibling.childId) }) - it('ignores a selected direct child that is not resident', async () => { - const { ctx, parent } = await setup([]) - - await expect(ctx.subagents.drainChildren(parent, [SessionId('settled-child')])) - .resolves.toBeUndefined() - }) - - it('reports selected-child disposal failures after releasing the child', async () => { + it('reports activation disposal failures after releasing the child', async () => { const hold = Promise.withResolvers() const adapter = new GatedAdapter([{ chunks: textResponse('target'), gate: hold.promise }]) const { ctx, parent } = await setupWith(adapter) @@ -1769,37 +1759,13 @@ describe('continuable durability and teardown', () => { throw new Error('selected cleanup failed') } - const drained = ctx.subagents.drainChildren(parent, [target.childId]) + const drained = target.dispose() hold.resolve(undefined) await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' }) expect(ctx.agents.get(target.childId)).toBeUndefined() }) - it('rejects selected-child teardown through another live parent', async () => { - const release = Promise.withResolvers() - const adapter = new GatedAdapter([{ chunks: textResponse('target'), gate: release.promise }]) - const { ctx, parent } = await setupWith(adapter) - const other = await ctx.agentLoop.create(SessionId('other-parent'), { provider: 'mock', model: 'mock' }) - const target = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) - await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) - - await expect(ctx.subagents.drainChildren(other, [target.childId])) - .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) - expect(ctx.agents.get(target.childId)).toBeDefined() - - release.resolve(undefined) - await waitNoActivation(ctx, target.childId) - }) - - it('rejects selected-child teardown through a stale parent identity', async () => { - const { ctx, parent } = await setup([]) - const stale = { ...parent, id: parent.id } as Agent - - await expect(ctx.subagents.drainChildren(stale, [])) - .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) - }) - it('drains the subagent tree after an intermediate creator leaves the registry', async () => { const releaseIntermediate = Promise.withResolvers() const releaseDescendant = Promise.withResolvers() @@ -3534,7 +3500,7 @@ describe('continuable public API', () => { await run.dispose() await waitNoActivation(ctx, run.childId) expect(settlementNotices(parent)).toEqual([]) - expect(await ctx.subagents.listChildren(parent.id)).toMatchObject([ + expect(await listChildren(ctx, parent.id)).toMatchObject([ { id: run.childId, mode: 'continuable' }, ]) expect(userTexts((await loadStoredSession(ctx.sessionPersistence, run.childId)).events)) @@ -3621,7 +3587,8 @@ describe('continuable errors', () => { const detach = ctx.on('subagent/start', (info) => { starts.push(info) residentAtStart.push(ctx.agents.get(info.id)?.id === info.id) - drains.push(ctx.subagents.drainChildren(parent, [info.id])) + // The start event precedes the public activation receipt. + drains.push(subagentManager(ctx)['dispose'](managerState(ctx).resident.get(info.id)!)) }) ctx.on('subagent/end', (info) => { ends.push(info) }) try { diff --git a/packages/subagent/subagent/tests/external-activation.spec.ts b/packages/subagent/subagent/tests/external-activation.spec.ts index 33ea3b08e6..ee1b024efe 100644 --- a/packages/subagent/subagent/tests/external-activation.spec.ts +++ b/packages/subagent/subagent/tests/external-activation.spec.ts @@ -1,3 +1,4 @@ +import { listChildren } from '../src/list-children.ts' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' /** External execution ownership, result delivery, and process cleanup. */ @@ -347,14 +348,14 @@ it('records external membership at creation without publishing settlement update try { const activation = await fixture.start('parent') const expected = { id: activation.childId, mode: 'external', label: 'External work' } - expect(await fixture.ctx.subagents.listChildren(fixture.parent.id)).toMatchObject([expected]) + expect(await listChildren(fixture.ctx, fixture.parent.id)).toMatchObject([expected]) expect(fixture.ctx.sessions.get(activation.childId)).toBeUndefined() const result: SubagentResult = { output: [{ type: 'text', text: 'x'.repeat(100_000) }], stopReason: 'completed' } backend.result.resolve(result) backend.cleanup.resolve(undefined) await expect(activation.result).resolves.toEqual(result) await activation.dispose() - expect(await fixture.ctx.subagents.listChildren(fixture.parent.id)).toMatchObject([expected]) + expect(await listChildren(fixture.ctx, fixture.parent.id)).toMatchObject([expected]) expect(snapshots).toHaveLength(1) expect(JSON.stringify(snapshots).length).toBeLessThan(1_000) } finally { diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index b5e37e0ea6..7fd31fe301 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -1,3 +1,4 @@ +import { listChildren } from '../src/list-children.ts' import { subagentCatalogProjectionDefinition } from '../src/catalog.ts' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -85,7 +86,7 @@ async function startChild( return started.childId } -describe('SubagentRuntime.listChildren', () => { +describe('direct-child catalog reader', () => { it('lists historical children through a migrated parent while preserving V3 files', async () => { const { ctx, root } = await setup([], { compression: 'none' }) const parent = SessionId('historical-parent') @@ -106,7 +107,7 @@ describe('SubagentRuntime.listChildren', () => { return { path, content } }) - expect(await ctx.subagents.listChildren(parent)).toEqual([{ + expect(await listChildren(ctx, parent)).toEqual([{ id: child, createdAt: 2, mode: 'continuable', label: 'historical child', }]) for (const { path, content } of originals) { @@ -123,7 +124,7 @@ describe('SubagentRuntime.listChildren', () => { parent, signal: testSignal, }) - expect(await ctx.subagents.listChildren(parent.id)).toMatchObject([ + expect(await listChildren(ctx, parent.id)).toMatchObject([ { id: callerOwned.childId, label: 'spawn', mode: 'continuable' }, ]) await callerOwned.result @@ -132,7 +133,7 @@ describe('SubagentRuntime.listChildren', () => { const listSessions = vi.spyOn(ctx.sessionQuery, 'listSessions') const observeSession = vi.spyOn(ctx.sessionQuery, 'observeSession') - const children = await ctx.subagents.listChildren(parent.id) + const children = await listChildren(ctx, parent.id) expect(children.map(({ createdAt: _createdAt, ...child }) => child)).toEqual([ { id: callerOwned.childId, label: 'spawn', mode: 'continuable' }, { id: continuableId, label: 'continuable child', mode: 'continuable' }, @@ -158,7 +159,7 @@ describe('SubagentRuntime.listChildren', () => { ...observed, [Symbol.dispose]: dispose, }) - expect(await ctx.subagents.listChildren(parent.id)).toEqual([{ + expect(await listChildren(ctx, parent.id)).toEqual([{ id: childId, createdAt: 3, label: 'state child', mode: 'one-shot', }]) expect(dispose).toHaveBeenCalledOnce() @@ -169,7 +170,7 @@ describe('SubagentRuntime.listChildren', () => { using observed = await ctx.sessionQuery.observeSession(parent.id, { projectionMode: 'none' }) const dispose = vi.fn() vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({ ...observed, [Symbol.dispose]: dispose }) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toMatchObject({ + await expect(listChildren(ctx, parent.id)).rejects.toMatchObject({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', }) expect(dispose).toHaveBeenCalledOnce() @@ -179,12 +180,12 @@ describe('SubagentRuntime.listChildren', () => { const { ctx, parent } = await setup([]) const controller = new AbortController() controller.abort(new Error('cancel catalog')) - await expect(ctx.subagents.listChildren(parent.id, controller.signal)).rejects.toMatchObject({ + await expect(listChildren(ctx, parent.id, controller.signal)).rejects.toMatchObject({ code: 'SESSION_QUERY_ABORTED', }) const failure = new Error('parent read failed') vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(failure) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toBe(failure) + await expect(listChildren(ctx, parent.id)).rejects.toBe(failure) }) it('ignores inherited fork facts and accepts facts in the fork own suffix', async () => { @@ -213,7 +214,7 @@ describe('SubagentRuntime.listChildren', () => { label: 'own', }) - expect(await ctx.subagents.listChildren(fork.id)).toEqual([{ + expect(await listChildren(ctx, fork.id)).toEqual([{ id: SessionId('own-child'), createdAt: 2, mode: 'continuable', label: 'own', }]) }) @@ -246,7 +247,7 @@ describe('SubagentRuntime.listChildren', () => { chunkLengths.push(chunk.values.length) } expect(chunkLengths).toEqual([1, ...Array.from({ length: 16 }, () => 64)]) - const entries = await ctx.subagents.listChildren(parent.id) + const entries = await listChildren(ctx, parent.id) expect(listSessions).not.toHaveBeenCalled() expect(entries).toHaveLength(1_025) expect(entries.find(entry => entry.id === 'child-064')).toEqual({ @@ -261,7 +262,7 @@ describe('SubagentRuntime.listChildren', () => { await withoutProjection.plugin(SessionProjectionRegistry) await withoutProjection.plugin(SubagentRuntime) const parent = withoutProjection.sessions.create(SessionId('parent')) - await expect(withoutProjection.subagents.listChildren(parent.id)).rejects.toMatchObject({ + await expect(listChildren(withoutProjection, parent.id)).rejects.toMatchObject({ code: 'SUBAGENT_CONTROL_QUERY_UNAVAILABLE', }) }) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 09c6cd581c..6b28b8b31a 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -128,7 +128,7 @@ describe('SubagentRuntime', () => { expect(ctx.sessionProjections.snapshot(parent).values.subagentCatalog).toBeUndefined() }) - it('registers, lists, looks up, starts, and removes providers', async () => { + it('registers, looks up, starts, and removes providers', async () => { const { ctx, subagents } = await service() const added: string[] = [] const removed: string[] = [] @@ -137,7 +137,6 @@ describe('SubagentRuntime', () => { const provider = new StubProvider('alpha') const dispose = subagents.registerProvider(provider) - expect(subagents.list()).toEqual(['alpha']) expect(subagents.getProvider('alpha')).toBe(provider) const run = await start(subagents, 'alpha', baseRequest()) await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) @@ -190,7 +189,6 @@ describe('SubagentRuntime', () => { // Without `ctx.agents` no manager exists, so nothing was ever materialized. expect('drainContinuable' in subagents).toBe(false) await expect(subagents.drainDescendants([])).resolves.toBeUndefined() - await expect(subagents.drainChildren(fakeParent(), [SessionId('child')])).resolves.toBeUndefined() }) it('treats interrupt as an accepted no-op when no manager was bound', async () => { diff --git a/packages/subagent/subagent/tests/sibling-messages.spec.ts b/packages/subagent/subagent/tests/sibling-messages.spec.ts index cb93f2df90..b5ce725030 100644 --- a/packages/subagent/subagent/tests/sibling-messages.spec.ts +++ b/packages/subagent/subagent/tests/sibling-messages.spec.ts @@ -1,3 +1,4 @@ +import { listChildren } from '../src/list-children.ts' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' @@ -16,7 +17,7 @@ import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-a import SubagentRuntime from '../src/index.ts' import { loadStoredSession } from './persistence-helpers.ts' import { TestSessionQuery } from './test-session-query.ts' -import { managerState } from './manager-internals.ts' +import { managerState, subagentManager } from './manager-internals.ts' import { requireLocalActivation } from '../src/activation.ts' /** A model reply, or a turn that stays open until its Agent is cancelled. */ @@ -122,7 +123,7 @@ describe('subagent sibling messages', () => { const b = await start(ctx, parent, 'b') await settled(ctx, b) expect(a).not.toBe(b) - expect(await ctx.subagents.listChildren(parent.id)).toEqual([ + expect(await listChildren(ctx, parent.id)).toEqual([ expect.objectContaining({ id: a, mode: 'continuable', label: 'shared responsibility' }), expect.objectContaining({ id: b, mode: 'continuable', label: 'shared responsibility' }), ]) @@ -345,7 +346,7 @@ describe('tree-wide messages', () => { await dispose() if (failCleanup) throw new Error('cleanup report failed') } - const drained = ctx.subagents.drainChildren(parent, [branchId]).catch((error: unknown) => error) + const drained = subagentManager(ctx)['dispose'](activation).catch((error: unknown) => error) let delivery: Promise | undefined try { await entered.promise diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts index fd0ab2a0ce..e51d92901d 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts @@ -34,7 +34,7 @@ async function mount(config: Partial = {}): Promise { describe('scripted subagent provider fixture', () => { it('registers through the real service and returns the scripted reply', async () => { const ctx = await mount({ reply: 'hello from fixture' }) - expect(ctx.subagents.list()).toEqual(['mock']) + expect(ctx.subagents.getProvider('mock')).toBeDefined() const run = await ctx.subagents.getProvider('mock')!.start!(baseRequest()) await expect(run.result).resolves.toEqual({ @@ -47,7 +47,8 @@ describe('scripted subagent provider fixture', () => { it('registers under a configurable name', async () => { const ctx = await mount({ name: 'spawn' }) - expect(ctx.subagents.list()).toEqual(['spawn']) + expect(ctx.subagents.getProvider('spawn')).toBeDefined() + expect(ctx.subagents.getProvider('mock')).toBeUndefined() }) it('returns configured and default structured results', async () => { @@ -98,8 +99,9 @@ describe('scripted subagent provider fixture', () => { await mountWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) const fiber = await scripted.mountScriptedProvider(ctx, { name: 'mock' }) - expect(ctx.subagents.list()).toEqual(['mock']) + expect(ctx.subagents.getProvider('mock')).toBeDefined() + expect(ctx.subagents.getProvider('spawn')).toBeUndefined() await fiber.dispose() - expect(ctx.subagents.list()).toEqual([]) + expect(ctx.subagents.getProvider('mock')).toBeUndefined() }) })