diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 2324f0ae2b..e903e3c21e 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -32,7 +32,7 @@ const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-con const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar.expected.md', import.meta.url)) const SIDEBAR_CHAT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar-chat.expected.md', import.meta.url)) -const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url)) +const GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url)) const MIGRATING_CHILD_EXPECTED = fileURLToPath(new URL( './expected/subagent-conversation/migration-required-child.expected.md', import.meta.url, @@ -545,7 +545,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(tripwire.warnings).toEqual([]) }) - it('opens an unavailable persisted grandchild after recording the available child', async () => { + it('opens a persisted grandchild with input enabled while its creator is offline', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild')) await page.getByRole('button', { name: '1 subagent' }).hover() const tree = page.getByRole('tree', { name: 'Subagent sessions' }) @@ -574,19 +574,15 @@ describe('web e2e: persisted subagent conversation and human continuation', () = MODE, ) await nestedRow.click() - await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() - // The offline banner renders from the descriptor alone, so it says nothing - // about the transcript below it. The golden pins that transcript, and - // `captureStableAria` calls two identical polls stable — including two of - // "Loading history…". Wait for the message the golden asserts. await page.getByText(NESTED_PROMPT).waitFor() + expect(await page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' }).isEnabled()).toBe(true) const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) const crumbs = await hierarchy.getByRole('button').allTextContents() expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL]) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() await compareOrRefreshGolden( - UNAVAILABLE_GRANDCHILD_EXPECTED, + GRANDCHILD_EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE, ) diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 7afeaa9a10..9a7a706314 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -1,7 +1,7 @@ // Web e2e scenario: the composer's independent Stop interrupts a running // continuable child. The child holds its model turn open through a replay -// hang entry; the browser proves Send and Stop coexist, the parent-offline -// disabled-Send-with-Stop composer, the subagents/interruptByParent +// hang entry; the browser proves Send and Stop coexist regardless of the +// creator's list availability, the subagents/interruptByParent // (never session.cancel) transport, the parked follow-up, and the FIFO resume // on a waking send. // @@ -188,7 +188,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt UI teardown failed') }) - it('interrupts the live child through the parent-offline composer', async () => { + it('queues and interrupts the live child despite its creator being listed offline', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline')) // The parent is unavailable in the Session list while the child stays live. const pattern = '**/api/session/list' @@ -211,30 +211,21 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.getByRole('button', { name: /1 subagent/ }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() - const input = page.getByRole('textbox', { - name: 'Parent session offline; sending is unavailable but you can still stop the run', - }) + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 15_000 }) await page.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /) .waitFor({ timeout: 15_000 }) - expect(await input.isDisabled()).toBe(true) + expect(await input.isDisabled()).toBe(false) const stop = page.getByRole('button', { name: 'Stop generating' }) expect(await stop.count()).toBe(1) expect(await stop.isEnabled()).toBe(true) - const send = page.getByRole('button', { name: 'Send message' }) - expect(await send.count()).toBe(1) - expect(await send.isDisabled()).toBe(true) - // Keep the continuable Activation resident after this first abort. The - // direct setup queue also proves the ordinary row controls remain - // available while this parent-offline composer cannot submit new input. - await scaffold.ctx.subagents.prompt({ - requestId: 'interrupt-ui-rearm' as SubagentPromptRequestId, - parentSessionId: parent.id, - childSessionId: childId, - mode: 'continuable', - delivery: 'queue', - content: [{ type: 'text', text: REARM }], - }, new AbortController().signal) + const promptResponse = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/subagents/prompt') + await input.fill(REARM) + const send = page.getByRole('button', { name: 'Queue message' }) + expect(await send.isEnabled()).toBe(true) + await send.click() + expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result).toMatchObject({ ok: true }) await page.getByRole('button', { name: 'Edit queued message' }).waitFor({ timeout: 15_000 }) expect(await page.getByRole('button', { name: 'Remove queued message' }).count()).toBe(1) expect(await page.getByRole('button', { name: 'Steer queued message' }).count()).toBe(1) @@ -256,7 +247,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle') // Wake the parked setup message only after cancellation converges. A - // second hang keeps the parent-available case independent from this stop. + // second hang keeps the next interrupt case independent from this stop. await scaffold.ctx.subagents.prompt({ requestId: 'interrupt-ui-rearm-wake' as SubagentPromptRequestId, parentSessionId: parent.id, @@ -274,7 +265,6 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co it('interrupts through subagents/interruptByParent, parks the follow-up, and resumes it FIFO', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow')) - // A new connection reloads the truthful parent-availability hint. const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 5695baf3a7..5b65495afb 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -537,7 +537,7 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise, info: SubagentRunEndInfo): void @@ -671,7 +671,7 @@ A provider established a published child. For in-process providers, `ctx.agents. * Scope-filtered dispatch keys the carrier by the Team root, so a * root-scoped listener observes all Team activations. Paired with * `subagent/end`. - * @param info - the provider and published child identity. + * @param info - provider, child identity, and durable direct creator `parentSessionId`. * @mode emit */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index d2567b1f9b..2ebc9f57c4 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -539,7 +539,7 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise, info: SubagentRunEndInfo): void @@ -673,7 +673,7 @@ A provider established a published child. For in-process providers, `ctx.agents. * Scope-filtered dispatch keys the carrier by the Team root, so a * root-scoped listener observes all Team activations. Paired with * `subagent/end`. - * @param info - the provider and published child identity. + * @param info - provider, child identity, and durable direct creator `parentSessionId`. * @mode emit */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.i18n.yaml index d654a6248f..53f72f38cd 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 703c7766dd80e1d1 zh: 157c67164c1cbc7d /agent-teams-uses-subagents-and-session-ids/change: - en: 126ae873a56ebab5 - zh: b6c62c1a68fd5c0f + en: 328cd007f4ebcc38 + zh: 01d03db217acbe3d /agent-teams-uses-subagents-and-session-ids/migration: en: 959dc9114266fda4 zh: 6a4f99862c22bd14 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md index 7564d446eb..a797c12cff 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md @@ -9,7 +9,7 @@ English | [中文](guide.zh.md) ## Change -The experimental Agent Teams plugins share agent creation, discovery, messaging, and interruption with subagents. Start a teammate with `subagent` or `subagent_fork`, describe its responsibility with `description`, and use its returned agent id for messages and task assignment. Descriptions may repeat and never identify a recipient. +The experimental Agent Teams plugins use standard subagent tools and Session ids. Descriptions label responsibilities; repeated descriptions never identify recipients. - `spawn_teammate` and the Team-scoped `send_message`, `list_agents`, and `interrupt_agent` are removed. The standard control tools use `agent_id`, not `target`. Members of one root tree can message each other across branches by id; interruption remains ancestor-only. - `list_agents` takes no arguments and lists the entire Team, including the root and caller. Remove scripted `scope` arguments. The root row uses `kind: root`; descendant rows use `kind: child`, with parent ids and root-relative depth. Update consumers that expect `parent` or `sibling` rows. Discovery does not grant task-assignment authority. @@ -17,8 +17,8 @@ The experimental Agent Teams plugins share agent creation, discovery, messaging, - `agent-team` rejects `maxMembers`, `maxMessageBytes`, and `disposalTimeoutMs`; `tool-agent-team` rejects `interrupt`, `freshProvider`, and `forkProvider`. - `ctx.agentTeams` removes `spawnTeammate()`, `sendMessage()`, `interrupt()`, and `listMembers()`. Task views expose `ownerId` instead of `ownerName`; task ownership uses Session ids, including the Lead's id. - Creation ancestry remains unchanged, but runtime instances share Team-root ownership. A member finishing or being released no longer waits for or disposes its descendants. Messages restore only their target; offline creators stay offline. Hosts close the whole Team when its root closes, and members explicitly wait for work they need. -- Cold resume uses the target’s saved preset and permission policies. It does not join the creator’s current preset generation. Missing saved presets reject restoration; historical targets without a saved preset use host composition with their recorded descriptor overrides. `subagent/start` and `subagent/end` dispatch through the Team root’s scope. -- Historical Team roster and mailbox records remain readable. Names in those records are historical data and do not resolve message recipients or task owners. Subagent catalog payloads are unchanged. +- Cold resume uses the target’s saved preset and permission policies. It does not join the creator’s current preset generation. Missing saved presets reject restoration; historical targets without a saved preset use host composition with their recorded descriptor overrides. `subagent/start` and `subagent/end` dispatch through the Team root’s scope and carry the direct creator in `parentSessionId`; SDK completion notifications retain that creator. Browser continuation requires the root, not the creator, to remain online; root unavailability uses `subagent/delivery-unavailable` instead of `subagent/parent-unavailable`. +- Historical Team roster and mailbox records remain readable. Historical names do not resolve message recipients or task owners. Subagent catalog payloads are unchanged. - `agentTeam.members` carries the Lead and historical members. Browser consumers combine it with continuable `subagentCatalog` entries, preferring catalog entries for duplicate ids. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md index 7665dd9b51..a0dea40cd8 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md @@ -9,7 +9,7 @@ description: "Agent Teams 使用标准 subagent 工具和 Session id;移除 Te ## 变更 -实验性 Agent Teams 插件与 subagent 共用代理创建、发现、消息和中断能力。使用 `subagent` 或 `subagent_fork` 创建队友,以 `description` 描述职责,并用返回的 agent id 发送消息和分配任务。描述可以重复,不能作为收件人标识。 +实验性 Agent Teams 插件使用标准子代理工具和 Session id。描述标注职责;重复描述不能标识接收者。 - 移除 `spawn_teammate` 以及 Team 作用域的 `send_message`、`list_agents` 和 `interrupt_agent`。标准控制工具使用 `agent_id` 而非 `target`。同一根下的成员可以通过 id 跨分支互发消息;中断仍只允许祖先执行。 - `list_agents` 不接收参数,列出整个 Team,包括根与调用方。移除脚本中的 `scope` 参数。根行使用 `kind: root`;后代行使用 `kind: child`,包含父级 id 和相对根的深度。更新依赖 `parent` 或 `sibling` 行的消费者。发现成员不会授予任务指派权限。 @@ -17,7 +17,7 @@ description: "Agent Teams 使用标准 subagent 工具和 Session id;移除 Te - `agent-team` 拒绝 `maxMembers`、`maxMessageBytes` 和 `disposalTimeoutMs`;`tool-agent-team` 拒绝 `interrupt`、`freshProvider` 和 `forkProvider`。 - `ctx.agentTeams` 移除 `spawnTeammate()`、`sendMessage()`、`interrupt()` 和 `listMembers()`。任务视图以 `ownerId` 取代 `ownerName`;包括 Lead 在内,任务所有者均使用 Session id。 - 创建关系保持不变,运行实例统一归 Team 根管理。成员完成或释放时不再等待或释放其后代。消息只恢复收件人,离线的创建者保持离线。宿主在根关闭时关闭整个 Team;成员需要其他任务的结果时须显式等待。 -- 冷恢复使用目标保存的 preset 和权限策略,不加入创建者当前 preset 代际。已保存的 preset 缺失时拒绝恢复;没有保存 preset 的历史目标使用宿主组装,并应用记录的 descriptor 覆盖。`subagent/start` 与 `subagent/end` 通过 Team 根作用域派发。 +- 冷恢复使用目标保存的 preset 和权限策略,不加入创建者当前 preset 代际。已保存的 preset 缺失时拒绝恢复;没有保存 preset 的历史目标使用宿主组装,并应用记录的 descriptor 覆盖。`subagent/start` 与 `subagent/end` 通过 Team 根作用域派发,并在 `parentSessionId` 中携带直接创建者;SDK 完成通知保留该创建者。浏览器续接要求根在线,不要求创建者在线;根不可用使用 `subagent/delivery-unavailable`,不再使用 `subagent/parent-unavailable`。 - 历史 Team 名册和邮箱记录仍可读取。其中的名字仅作为历史数据,不用于解析消息收件人或任务所有者。Subagent catalog 载荷不变。 - `agentTeam.members` 携带 Lead 和历史成员。浏览器消费者将其与可继续的 `subagentCatalog` 条目组合,重复 id 优先使用目录条目。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 46f670802e..753f2f525b 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -18,8 +18,8 @@ en: f9e4042fae88f801 zh: f379f78966d57c2e /session-controller/client-references: - en: 41bdda1ac32df558 - zh: aa5d0e325924066b + en: 91c815ac941ba7fa + zh: 81ae0d741f79b09d /session-controller/session-media-references: en: 266ab2b1e2fec542 zh: b3450ae28a1e0baf diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 02dff176ba..f6e788a2f5 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -79,7 +79,7 @@ The controller composes `ArchivedSessionGate` through `ctx.plugin`: it loads onc `sessions.retain(target, { source, signal? })` immediately acquires one exact Client generation and starts its shared initial history opening. The target is a known Session id or a durable direct-parent subagent address; the Host validates an explicit address when history opens. The returned reference supports idempotent `release()` and `Symbol.dispose`; its `ready` Promise follows the shared `Session.open()` result and resolves to the exact binding when that attempt settles, including when a Remote failure is represented by `openState: 'error'`. It rejects when `Session.open()` rejects, its waiter is cancelled, or the reference is released early. Cancelling one waiter does not cancel another owner's opening. `sessions.using(target, options, operation)` waits for that settlement, holds its reference until the callback settles, and propagates rejected readiness and callback failures. -References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. An unknown-mode subagent address permits history reads with direct-parent validation. Successful descriptor restoration determines the displayed mode; failures remain local to the child, and control requests still require a confirmed continuable identity. +References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. An unknown-mode subagent address permits history reads with direct-parent validation. Successful descriptor restoration determines the displayed mode; failures remain local to the child, and control requests still require a confirmed continuable identity. Client snapshots retain only the subagent address; runtime admission owns continuation availability. ## Session media references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index b01a99cc1f..2e76007f5e 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -79,7 +79,7 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 `sessions.retain(target, { source, signal? })` 立即获取一个精确 Client generation 的引用,并启动其共享的首次历史打开。目标是已知 Session id 或持久的直接父子 subagent 地址;Host 在打开历史时校验显式地址。返回引用支持幂等的 `release()` 和 `Symbol.dispose`;其 `ready` Promise 跟随共享的 `Session.open()` 结果,并在该次尝试结算时解析为确切 binding,包括 Remote failure 以 `openState: 'error'` 表示的情况。仅当 `Session.open()` 拒绝、等待方取消或引用提前释放时,`ready` 才拒绝。取消一个等待方不会取消其他 owner 的打开。`sessions.using(target, options, operation)` 等待该次结算,持有引用直到回调结束,并传播被拒绝的就绪与回调失败。 -引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 模式未知的子代理地址允许读取历史,但仍校验直接父级。成功恢复 descriptor 后确定展示模式;失败只影响该子会话,控制请求仍要求已确认的 continuable 身份。 +引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 模式未知的子代理地址允许读取历史,但仍校验直接父级。成功恢复 descriptor 后确定展示模式;失败只影响该子会话,控制请求仍要求已确认的 continuable 身份。Client 快照仅保留子代理地址;是否能接收续接输入由运行时校验。 ## 会话媒体引用 diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index 161cd858b4..ae3f2abe9a 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -73,8 +73,6 @@ export interface SessionSnapshot { readonly running: boolean readonly subagent: { readonly address: SubagentAddress - /** Absent until the direct-parent catalog resolves. */ - readonly parentAvailable?: boolean } | null readonly removed: boolean readonly openState: OpenState diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index b8bec9be8a..debd8f0699 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -142,12 +142,7 @@ export class SessionManager { throw new Error(`sessions.retain: unknown session ${id}`) } if (address !== undefined) this.addresses.set(id, address) - this.sessions.get(id)?.configureSubagent( - address, - address === undefined - ? undefined - : this.agentAvailable(address.parentSessionId), - ) + this.sessions.get(id)?.configureSubagent(address) return id } @@ -271,14 +266,8 @@ export class SessionManager { private createSession(sessionId: SessionId): Session { const address = this.addresses.get(sessionId) - const parentAvailable = address === undefined - ? undefined - : this.agentAvailable(address.parentSessionId) return new Session(sessionId, this.remote, { - ...(address === undefined ? {} : { - address, - ...parentAvailable === undefined ? {} : { parentAvailable }, - }), + ...(address === undefined ? {} : { address }), // The sender's local first-send flip mirrors into the list row so the // session surfaces (lists filter on blank) before any host frame lands. onEngaged: (engaged) => { @@ -395,18 +384,6 @@ export class SessionManager { return operation } - private agentAvailable(sessionId: SessionId): boolean | undefined { - return this.summaries.find(summary => summary.sessionId === sessionId)?.agentAvailable - ?? (this.listPhase === 'ready' ? false : undefined) - } - - private updateParentAvailability(): void { - for (const [childId, address] of this.addresses) { - const available = this.agentAvailable(address.parentSessionId) - if (available !== undefined) this.sessions.get(childId)?.handleSubagentParentAvailable(available) - } - } - // ---- List API ---- /** Full refresh via session.list (single-flight within one Host generation). */ @@ -441,7 +418,7 @@ export class SessionManager { for (const sessionId of this.engagedSessions) this.pruneEngagement(sessionId, retained) this.listState = 'idle' this.listPhase = 'ready' - this.updateParentAvailability() + // Resident Sessions and list rows share one display blank, reconciled with the metadata projection. for (const s of this.summaries) { const session = this.sessions.get(s.sessionId) @@ -595,7 +572,6 @@ export class SessionManager { */ private mergeSummary(summary: SessionSummary): void { this.recordMutation({ kind: 'upsert', summary }) - this.updateParentAvailability() } /** Apply immediately and retain for replay when a list response is in flight. */ @@ -715,11 +691,6 @@ export class SessionManager { this.projectionInflight.get(sessionId)?.controller.abort() this.projectionInflight.delete(sessionId) this.projectionLoads.delete(sessionId) - for (const [childId, address] of this.addresses) { - if (address.parentSessionId === sessionId) { - this.sessions.get(childId)?.handleSubagentParentAvailable(false) - } - } } /** @@ -730,7 +701,6 @@ export class SessionManager { handleSessionStatus(sessionId: SessionId, running: boolean): void { if (!this.disposed && running) this.engagedSessions.add(sessionId) this.recordMutation({ kind: 'status', sessionId, running, agentAvailable: true }) - this.updateParentAvailability() this.sessions.get(sessionId)?.handleRunning(running) } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 09494e01ab..2e2636313d 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -71,8 +71,6 @@ interface PendingHistory { export interface SessionOptions { /** Catalog-discovered address selecting non-activating subagent transport. */ address?: SubagentAddress - /** Whether the exact direct parent Agent is available in Host summaries; absent until known. */ - parentAvailable?: boolean /** * Publish each accepted prompt to the Manager, including after this Session * object is replaced. Acceptance converts display state, but does not @@ -113,7 +111,6 @@ export class Session implements SessionFace { private readonly assistantStream = new ClientAssistantStream() private running = false private address: SubagentAddress | undefined - private parentAvailable: boolean | undefined /** * Sticky send marker, private input of the composerPhase derivation: set * synchronously before prompt()'s first await, never reset — the blank → @@ -188,7 +185,6 @@ export class Session implements SessionFace { ) { this.projections = options.projections ?? new ProjectionValueStore() this.address = options.address - this.parentAvailable = options.parentAvailable this.notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() }) @@ -539,28 +535,16 @@ export class Session implements SessionFace { * Install or clear the catalog-discovered transport address. A changed * address rebuilds an already-open window through its new history route. * @param address - direct parent/child address, or undefined for ordinary transport. - * @param parentAvailable - latest exact-parent availability hint, or undefined before a catalog read. */ - configureSubagent(address: SubagentAddress | undefined, parentAvailable?: boolean): void { + configureSubagent(address: SubagentAddress | undefined): void { const same = this.address?.parentSessionId === address?.parentSessionId && this.address?.childSessionId === address?.childSessionId && this.address?.mode === address?.mode this.address = address - this.parentAvailable = parentAvailable if (!same && this.openState !== 'cold') void this.resync() else this.notifier.markDirty() } - /** - * Update only the parent availability hint from a catalog refresh. - * @param available - whether the exact direct parent is live. - */ - handleSubagentParentAvailable(available: boolean): void { - if (this.parentAvailable === available) return - this.parentAvailable = available - this.notifier.markDirty() - } - /** * Apply the Manager's effective display blank, further reconciled with the * current `sessionListMetadata` projection. Local send attempts and current @@ -897,7 +881,6 @@ export class Session implements SessionFace { address: this.address.mode === 'unknown' && identity != null ? { ...this.address, mode: identity.mode } : this.address, - ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }), }, removed: this.removed, openState: this.openState, diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index bcf4a033be..e5f407895e 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -837,7 +837,6 @@ describe('subagent catalogs', () => { address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable', }, - parentAvailable: true, }) // Clicking the same child through an ordinary list-selection path must not // erase the catalog-derived address and fall back to session.* transport. @@ -849,7 +848,6 @@ describe('subagent catalogs', () => { address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable', }, - parentAvailable: true, }) await manager.get(S2).open() await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue') @@ -1031,36 +1029,6 @@ describe('subagent catalogs', () => { expect(remote.session.projections).toHaveBeenCalledTimes(2) }) - it.for([false, true])('keeps live parent availability when an earlier initial read settles (failure: %s)', async (failure, { mock, remote }) => { - const response = Promise.withResolvers>>() - remote.session.projections.mockImplementation(() => response.promise) - const manager = makeManager(mock, remote) - manager.resolveTarget({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) - const child = manager.get(S2) - const read = manager.refreshProjections(S1) - manager.handleSessionAdded(summary(S1)) - manager.handleSessionStatus(S1, false) - response.resolve(failure - ? err(new RemoteError('gateway/internal', 'offline', {})) - : ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) - await read - expect(child.getSnapshot().subagent?.parentAvailable).toBe(true) - expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) - }) - - it('keeps parent availability unknown after list failure and accepts a later Host summary', async ({ mock, remote }) => { - const manager = makeManager(mock, remote) - manager.resolveTarget({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) - const child = manager.get(S2) - remote.session.list.mockResolvedValue(err(new RemoteError('gateway/internal', 'offline', {}))) - await manager.refreshList() - expect(manager.getListSnapshot().state).toBe('error') - expect(child.getSnapshot().subagent?.parentAvailable).toBeUndefined() - manager.handleSessionAdded(summary(S1)) - expect(child.getSnapshot().subagent?.parentAvailable).toBe(true) - await manager.dispose() - }) - it('keeps a pushed catalog when an earlier initial read reports the parent missing', async ({ mock, remote }) => { const response = Promise.withResolvers>>() remote.session.projections.mockImplementation(() => response.promise) @@ -1221,23 +1189,6 @@ describe('remaining branches', () => { await manager.dispose() }) - it('preserves Host availability when a local create echo follows Agent disposal', async ({ mock, remote }) => { - const response = Promise.withResolvers>>() - remote.session.create.mockImplementation(() => response.promise) - const manager = makeManager(mock, remote) - manager.resolveTarget({ - parentSessionId: S1, childSessionId: S2, mode: 'continuable', - }) - const child = manager.get(S2) - const request = manager.create() - manager.handleSessionAdded(summary(S1, { agentAvailable: false })) - response.resolve(ok({ sessionId: S1 })) - await request - manager.resolveTarget(S2) - expect(child.getSnapshot().subagent?.parentAvailable).toBe(false) - await manager.dispose() - }) - it('publishes a real Ungrouped summary from workspace-attach-failed', async ({ mock, remote }) => { remote.session.create.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', { sessionId: S1, workspaceId: 'w1', @@ -1591,7 +1542,6 @@ describe('connected generation', () => { }) expect(manager.get(S2).getSnapshot().subagent).toEqual({ address, - parentAvailable: true, }) expect(manager.subagentAddress(S2)).toEqual(address) }) diff --git a/packages/api/session-controller/tests/remote/bench.client.ts b/packages/api/session-controller/tests/remote/bench.client.ts index 6e0f10d569..a696fd0c28 100644 --- a/packages/api/session-controller/tests/remote/bench.client.ts +++ b/packages/api/session-controller/tests/remote/bench.client.ts @@ -11,7 +11,7 @@ import { sessionWorld } from './session.client.ts' * @param mock - this case's Remote responses. * @param start - the fixture's lazy client startup. * @param sessionId - Session identity. - * @param options - addressed child and parent availability, when applicable. + * @param options - addressed child and other Session options, when applicable. * @returns the Session, disposed at the end of the case. */ export async function sessionBench( diff --git a/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts b/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts index a6e7a274aa..7e4d8d4b96 100644 --- a/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts +++ b/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts @@ -35,7 +35,6 @@ describe('Session over a rejecting Remote face', () => { it('propagates the rejection from a subagent continuation prompt and interrupt', async () => { const session = new Session(SID, rejecting('assembly fault'), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, }) await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('assembly fault') await expect(session.cancel()).rejects.toThrow('assembly fault') diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 8d1838e40f..c4f3c8f368 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -474,7 +474,7 @@ describe('prompt and cancel errors', () => { const CHILD = { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' } as const it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') const steered = await session.prompt([{ type: 'text', text: '现在处理' }], 'steer') @@ -510,11 +510,11 @@ describe('prompt and cancel errors', () => { expect(mock.log.requests('session/cancel')).toEqual([]) // A successful interrupt leaves no stop error behind. expect(session.getSnapshot().promptError).toBeNull() - expect(session.getSnapshot().subagent).toEqual({ address: CHILD, parentAvailable: true }) + expect(session.getSnapshot().subagent).toEqual({ address: CHILD }) }) it('sends an addressed child continuation under its echo request id and retires a refused echo', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const accepted = session.beginSubmission({ mode: 'queue', text: '继续', attachments: [] }) await session.prompt([{ type: 'text', text: '继续' }], 'queue', undefined, accepted.requestId) @@ -527,7 +527,7 @@ describe('prompt and cancel errors', () => { }) it('forwards continuation image parts to the subagent prompt Remote unstripped', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const content = [ { type: 'text' as const, text: '看这张图' }, @@ -549,7 +549,7 @@ describe('prompt and cancel errors', () => { }) it('lands an interrupt business failure in promptError with op=stop', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) mock.remote.subagents.interruptByParent.mockResolvedValue(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID }))) await session.open() const cancelled = await session.cancel() @@ -560,7 +560,7 @@ describe('prompt and cancel errors', () => { }) it('rejects staged files instead of dropping them from subagent continuations', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const prompted = await session.prompt([ diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 3b74b55d70..21206e1b04 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,8 +21,8 @@ en: 9912b286384c41d7 zh: 3e994887c1948677 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: c4d4aaef46daab72 - zh: 0dac50689d806ecf + en: 766af590ff80e1ae + zh: d6eac7d57fd4294e /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 1a0cb88bc9..6d80397c52 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -106,7 +106,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. -Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label. Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions and accept pasted or dropped images, but expose no File row; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../subagent/subagent/README.md)). +Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label. Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions and accept pasted or dropped images, but expose no File row; creator availability does not disable input, and Host admission failures use the ordinary prompt error feedback ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../subagent/subagent/README.md)). File chips and editable skill references share a whole-reference hover background and follow the composer's line height and text baseline. The first click delegates preview opening to the registered reference source immediately, including the first click of a double-click sequence. Subsequent clicks retain native text selection; an existing noncollapsed selection suppresses pointer preview activation. Previewing does not change the draft, its clipboard projection, or submission. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index ccef596924..970f148a92 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -106,7 +106,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 -Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,允许粘贴或拖入图片,但不提供「文件」菜单项;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../subagent/subagent/README.zh.md))。 +Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,允许粘贴或拖入图片,但不提供「文件」菜单项;创建者的在线状态不禁用输入,Host 接收失败使用普通提示词错误反馈([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../subagent/subagent/README.zh.md))。 文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。 diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f1f708073b..3b3d112ae0 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -19,7 +19,6 @@ export const zh = { 'placeholder.plan': PLAN_NEXT_ACTION_ZH, 'placeholder.default': '发消息或创建任务, / 调用指令, @ 文件或对话', 'placeholder.unavailable': '会话不可用', - 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', 'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话', 'placeholder.workspace': '选择一个工作区开始', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', @@ -399,7 +398,6 @@ export const en = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Message or run a task, / commands, @ files or sessions', 'placeholder.unavailable': 'Session unavailable', - 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', 'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions', 'placeholder.workspace': 'Choose a workspace to start', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx index 674b644a9f..dc82d99775 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx @@ -29,15 +29,7 @@ export function ConversationMainPanel(props: ConversationSlotProps) { // The exemption is deliberately open-state-wide, not loading-only: a // summary-blank session is the hero before its open starts (`cold`) and // after one fails (`error`) for the same reason — there is no history. - // A restored continuable subagent waits for a Host summary to establish - // parent availability. This keeps the composer - // hidden instead of briefly rendering the parent-offline takeover. - const parentAvailabilityPending = session?.subagent?.address.mode === 'continuable' - && session.subagent.parentAvailable === undefined - const settling = sessionId !== undefined && ( - (shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true) - || parentAvailabilityPending - ) + const settling = sessionId !== undefined && shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true const hero = sessionId === undefined || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true)) const phase = settling ? 'settling' : hero ? 'hero' : 'active' diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index ef95796577..002d9a68f9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -129,15 +129,8 @@ export const InputBar = memo(function InputBar({ const cardRef = useRef(null) const scrollRef = useRef(null) - // A continuable child without its live parent cannot accept human input, - // but its independent Stop below stays available while it runs. const continuable = subagent?.address.mode === 'continuable' - const parentOffline = continuable && subagent.parentAvailable !== true - // Running input stays free; locked = session removed, the - // inert no-workspace state, the machine faces absent (no session), or a - // parent-offline continuable child. An owner block also disables input; - // adjudicating and submitting render read-only so the draft stays visible. - const disabled = removed || inert || !live || blocked !== undefined || parentOffline + const disabled = removed || inert || !live || blocked !== undefined const locked = disabled // The model seat is the ONE control a block leaves live: every block this // contract has is cleared by choosing a model, so locking it too would leave @@ -360,16 +353,14 @@ export const InputBar = memo(function InputBar({ return translated !== hintKey ? translated : rawHint })() - const placeholderText = placeholder ?? (parentOffline - ? t('placeholder.parentOffline') - : disabled - ? t('placeholder.unavailable') - // The steer hint deliberately outranks the plan placeholder: - // while it shows, the whole-queue gesture is genuinely available - // (the gate never consults plan mode), so the actionable hint wins. - : canSteerQueue - ? t('placeholder.steerQueue') - : planActive ? t('placeholder.plan') : t('placeholder.default')) + const placeholderText = placeholder ?? (disabled + ? t('placeholder.unavailable') + // The steer hint deliberately outranks the plan placeholder: + // while it shows, the whole-queue gesture is genuinely available + // (the gate never consults plan mode), so the actionable hint wins. + : canSteerQueue + ? t('placeholder.steerQueue') + : planActive ? t('placeholder.plan') : t('placeholder.default')) return (
diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 4ee04fe8a7..a6c74fbf63 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -52,7 +52,6 @@ const SCTX = {} as Context const SID = 's1' as SessionId const CONTINUABLE_CHILD = { address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' as const }, - parentAvailable: true, } function snapshotOf(overrides: Partial = {}): SessionSnapshot { @@ -241,7 +240,6 @@ function bench(over?: BenchOptions) { // that claim a command or freeze the machine query the DOM directly. const steeringAvailable = over?.subagent === undefined || over.subagent.address.mode === 'continuable' const composerLocked = over?.disabled === true || over?.inert === true || over?.blocked !== undefined - || (over?.subagent?.address.mode === 'continuable' && over.subagent.parentAvailable !== true) const uploadsPending = (over?.attachments ?? []).some(attachment => attachment.kind === 'file' && over?.fileUploads?.[attachment.id]?.status !== 'ready') const plainMessageDraft = sendableDraft && !uploadsPending && !(over?.draft?.trimStart().startsWith('/') ?? false) @@ -461,7 +459,6 @@ describe('image draft rail', () => { }) it.each([ - ['an offline parent', { ...CONTINUABLE_CHILD, parentAvailable: false }], ['a one-shot child', { ...CONTINUABLE_CHILD, address: { ...CONTINUABLE_CHILD.address, mode: 'one-shot' as const } }], ])('keeps attachment intake closed for %s', (_name, subagent) => { const addFiles = vi.fn(() => null) @@ -727,7 +724,6 @@ describe('Enter semantics', () => { queue: [row('q-1')], subagent: { address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, }, }).placeholder).toBe('Cmd/Ctrl+Enter 插话发送全部排队消息') }) @@ -846,7 +842,6 @@ describe('Enter semantics', () => { childSessionId: SID, mode: 'continuable' as const, }, - parentAvailable: true, } const childSteerQueue = vi.fn() const child = bench({ running: true, subagent, queue: [row('q-1')], steerQueue: childSteerQueue }) @@ -986,7 +981,7 @@ describe('running and lock semantics', () => { try { const { view, stopShortcut } = bench({ running: true, - subagent: { address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }, parentAvailable: true }, + subagent: { address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' } }, t: makeTranslate(messages, {}), }) const stop = view.getByRole('button', { name: label }) @@ -1163,7 +1158,6 @@ describe('running and lock semantics', () => { childSessionId: SID, mode: 'continuable', }, - parentAvailable: true, }, }) expect(button.getAttribute('aria-label')).toBe('排队发送') @@ -1190,7 +1184,6 @@ describe('running and lock semantics', () => { childSessionId: SID, mode: 'continuable' as const, }, - parentAvailable: true, } const { button, sink } = bench({ running: true, busyEnter: 'steer', draft: '子代理插话', subagent }) expect(button.getAttribute('aria-label')).toBe('插话发送') @@ -1246,24 +1239,14 @@ describe('running and lock semantics', () => { expect(shell.pickFiles()).toBe(false) }) - it('parent-offline running continuable locks Send but keeps independent Stop usable', () => { - const { button, interruptButton, textarea, stop, view } = bench({ + it('keeps input and Stop available for a continuable child without parent availability', () => { + const { button, interruptButton, textarea, stop } = bench({ running: true, - draft: '', - subagent: { - address: { - parentSessionId: 'parent' as SessionId, - childSessionId: SID, - mode: 'continuable', - }, - parentAvailable: false, - }, + draft: 'continue', + subagent: CONTINUABLE_CHILD, }) - expect(textarea.getAttribute('aria-disabled')).toBe('true') - expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行') - expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true) - expect(button.getAttribute('aria-label')).toBe('发送消息') - expect(button.disabled).toBe(true) + expect(textarea.getAttribute('aria-disabled')).not.toBe('true') + expect(button.disabled).toBe(false) expect(interruptButton?.disabled).toBe(false) fireEvent.click(interruptButton!) expect(stop).toHaveBeenCalledTimes(1) @@ -1279,7 +1262,6 @@ describe('running and lock semantics', () => { childSessionId: SID, mode: 'one-shot', }, - parentAvailable: true, }, }) expect(button.getAttribute('aria-label')).toBe('发送消息') @@ -1294,7 +1276,6 @@ describe('running and lock semantics', () => { childSessionId: SID, mode: 'continuable' as const, }, - parentAvailable: true, } const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent }) fireEvent.keyDown(plain.textarea, { key: 'Enter' }) diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index ec93676ff5..d81d349035 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -687,7 +687,6 @@ describe('QueueDock', () => { childSessionId: SID, mode: 'continuable' as const, }, - parentAvailable: false, }, } const source = liveSession(snap) @@ -710,7 +709,6 @@ describe('QueueDock', () => { childSessionId: SID, mode: 'one-shot' as const, }, - parentAvailable: true, }, } const source = liveSession(snap) diff --git a/packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts b/packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts index 990c543da5..73dd206b6c 100644 --- a/packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts +++ b/packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts @@ -190,7 +190,7 @@ describe('fixed stop routing', () => { }) expect(b.press()).not.toHaveBeenCalled() await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { - draft.subagent = { parentAvailable: false, address: { parentSessionId: 'parent' as SessionId, + draft.subagent = { address: { parentSessionId: 'parent' as SessionId, childSessionId: 's1' as SessionId, mode: 'continuable' } } }) b.press() diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index b12b090972..d13ff20b6e 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -21,8 +21,8 @@ en: 3508631091820123 zh: ec7da9e5bedb22ba /deepseek-ai-dsh-client-ui-subagent/use-this-package/continuing-a-conversation: - en: 876e7152c8b9c4a6 - zh: f89f5e2a812e8451 + en: 8701c6c75bccdfdc + zh: e8c6a8817a584a72 /deepseek-ai-dsh-client-ui-subagent/use-this-package/the-reference-source: en: 6a5f91b7dfcefafe zh: 2d11441952cfea53 @@ -36,8 +36,8 @@ en: b817e9907392f59c zh: e42feb97d572a989 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/composer-election: - en: 13c33af89ba0942e - zh: 00374c7944728ae2 + en: b3407bce2119da41 + zh: ae5b77ac32173ca0 /deepseek-ai-dsh-client-ui-subagent/further-exploration: en: 1766e9012e307178 zh: f3ee637f5941aae5 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index ad56362a7a..24fe073209 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -37,7 +37,7 @@ Rows display mode plus activity and an optional log-backed title; running uses t ### Continuing a conversation -A continuable child with a live parent keeps the ordinary input chrome: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, and an independent Stop routes through `subagents/interruptByParent`. A continuable child whose exact parent is unavailable and which is not running elects a read-only composer explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer with input and Send disabled but its independent Stop usable. +A continuable child keeps the ordinary input and independent Stop action regardless of creator availability. Prompts route through `subagents/prompt`, whose runtime validates the creation address and live Team root; failures use ordinary prompt feedback. Stop routes through `subagents/interruptByParent`. ### The `@` reference source @@ -67,7 +67,7 @@ Each visible catalog level advances its own clock while it contains a running ch ### Composer election -One-shot children always elect a read-only composer. A continuable child elects one only when its exact parent is unavailable and the child is not running; otherwise the ordinary composer's Session routes prompts through `subagents/prompt`. This package never receives host context or calls a model-facing tool. +One-shot and unknown-mode children elect a read-only composer. Continuable children route prompts through `subagents/prompt`. This package never receives host context or calls a model-facing tool. Unknown-mode catalog rows remain visible and clickable, using the child id when no label is available. Their composer is read-only until child history establishes a supported mode; a read failure is reported in the child conversation. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 1c2048920b..060e6db51c 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" ### 续接对话 -确切 parent 存活时,可继续 child 保留普通输入 chrome:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagents/interruptByParent` 路由。确切 parent 不可用且 child 未在运行的可继续 child 会选用说明恢复路径的只读编辑器;此类 child 仍在运行期间,selector 会让位给普通编辑器——输入区与 Send 被禁用,但独立的 Stop 保持可用。 +可继续 child 保留普通输入和独立 Stop 操作,不受创建者在线状态影响。提示词经由 `subagents/prompt` 路由,由运行时校验创建地址和在线 Team 根;失败使用普通提示词错误反馈。Stop 经由 `subagents/interruptByParent` 路由。 ### `@` 引用 source @@ -67,7 +67,7 @@ kind: "package-reference" ### 编辑器选举 -one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器;否则普通编辑器的会话会经 `subagents/prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。 +one-shot 和模式未知的 child 选用只读编辑器。可继续 child 经由 `subagents/prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。 模式未知的目录项仍可见、可点击,没有标签时显示子会话 id。读取子历史并确认受支持模式前,输入区保持只读;读取失败在该子会话中报告。 diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx index 07feb84f55..aacbea6afe 100644 --- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx +++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -4,7 +4,7 @@ import css from './SubagentReadOnlyComposer.module.css' /** Why a catalog-addressed conversation cannot accept human input. */ export interface SubagentReadOnlyMatch { - reason: 'one-shot' | 'parent-unavailable' | 'unknown' + reason: 'one-shot' | 'unknown' } /** Full chain props after the read-only subagent selector accepts the owner currency. */ @@ -24,7 +24,7 @@ export function SubagentReadOnlyComposer({
{t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {t(matched.reason === 'unknown' ? 'readonly.unknown.body' : oneShot ? 'readonly.oneShot.body' : 'readonly.body')} + {t(oneShot ? 'readonly.oneShot.body' : 'readonly.unknown.body')}
) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 6d4220ef9c..5bd710963e 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -33,19 +33,13 @@ export type { /** Required services for subagent presentation and navigation. */ export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight'] -/** Claim the composer for one-shot history or an unavailable continuation owner. */ +/** Claim the composer when the child mode does not allow follow-ups. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { const subagent = owner.session?.subagent if (subagent === undefined || subagent === null) return null if (subagent.address.mode === 'unknown') return { reason: 'unknown' } if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' } - // Until a Host summary establishes parent availability, keep the normal - // disabled composer instead of claiming that the parent is offline. - if (subagent.parentAvailable !== false) return null - // A RUNNING parent-offline continuable child keeps the default composer: - // its input is disabled there, but the same primary Stop stays available so - // the child can be interrupted. Once it stops, this takeover returns. - return owner.session?.running === true ? null : { reason: 'parent-unavailable' } + return null } /** diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 8004ca18f8..d255390c15 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -45,7 +45,6 @@ export const zh = { 'readonly.oneShot.title': '一次性子智能体记录', 'readonly.title': '此子智能体暂时只读', 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', - 'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。', } as const /** English dictionary, key-identical to the Chinese source of truth. */ @@ -90,7 +89,6 @@ export const en: Record = { 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', - 'readonly.body': 'The parent session is offline; reopen it to continue sending messages.', } /** Key domain of the `subagent` namespace (zh is the source of truth). */ diff --git a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx index ddc5bed7f9..6d9c3fcbb5 100644 --- a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx +++ b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx @@ -136,10 +136,7 @@ export function ConversationSlotPanel({ || session.running const shellPhase = active ? 'active' : session.promptAttempted ? 'engaging' : 'blank' const summaryBlank = useSessions(state => state.byId[sessionId]?.blank) - const parentAvailabilityPending = session.subagent?.address.mode === 'continuable' - && session.subagent.parentAvailable === undefined const settling = (shellPhase === 'blank' && session.openState === 'loading' && summaryBlank !== true) - || parentAvailabilityPending const hero = shellPhase === 'blank' && (session.openState === 'open' || summaryBlank === true) const phase = settling ? 'settling' : hero ? 'hero' : 'active' return renderFactorySlot('conversation.content', { variant: 'embedded', phase, hero }, { diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index d75c9d8639..af5e16e286 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -156,19 +156,14 @@ describe('apply', () => { }) expect(select(owner(undefined))).toBeNull() expect(select(owner(null))).toBeNull() - expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }))) + expect(select(owner({ address: { ...address, mode: 'one-shot' } }))) .toEqual({ reason: 'one-shot' }) // One-shot stays read-only even while running: it has no stop action. - expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true))) + expect(select(owner({ address: { ...address, mode: 'one-shot' } }, true))) .toEqual({ reason: 'one-shot' }) - expect(select(owner({ address: { ...address, mode: 'unknown' }, parentAvailable: true }))) + expect(select(owner({ address: { ...address, mode: 'unknown' } }))) .toEqual({ reason: 'unknown' }) expect(select(owner({ address }))).toBeNull() - expect(select(owner({ address, parentAvailable: true }))).toBeNull() - expect(select(owner({ address, parentAvailable: false }))) - .toEqual({ reason: 'parent-unavailable' }) - // A RUNNING parent-offline continuable yields the default composer, whose - // disabled input still carries the primary Stop; stopped, it takes back over. - expect(select(owner({ address, parentAvailable: false }, true))).toBeNull() + expect(select(owner({ address }, true))).toBeNull() }) }) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index ed5b4043fa..21abd15c01 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -30,7 +30,7 @@ type CatalogFixtureEntry = Entry extends SubagentC ? Omit & { activity: 'running' | 'inactive' } : never -type CatalogFixture = { entries: readonly CatalogFixtureEntry[]; parentAvailable: boolean; state: SessionListState['projectionsBySession'][SessionId]['state']; error: SessionListState['projectionsBySession'][SessionId]['error'] } +type CatalogFixture = { entries: readonly CatalogFixtureEntry[]; state: SessionListState['projectionsBySession'][SessionId]['state']; error: SessionListState['projectionsBySession'][SessionId]['error'] } function catalog(over: Partial = {}): CatalogFixture { return { @@ -44,7 +44,6 @@ function catalog(over: Partial = {}): CatalogFixture { label: 'reviewer', activity: 'inactive', }, ], - parentAvailable: true, state: 'ready', error: null, ...over, @@ -1219,11 +1218,6 @@ describe('SubagentHeaderLineage', () => { }) describe('SubagentReadOnlyComposer', () => { - it('explains the exact missing-parent recovery path', () => { - render() - expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') - }) - it('keeps an unknown child read-only until its descriptor is available', () => { render() expect(screen.getByRole('status').textContent).toContain('读取子会话后才能确定是否可继续') diff --git a/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx index 7b67420498..f7417ea910 100644 --- a/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx +++ b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx @@ -191,15 +191,15 @@ describe('Sidebar chat components', () => { expected: { phase: 'settling', hero: false }, }, { - name: 'continuable parent discovery', + name: 'continuable child without parent availability', session: { blank: false, awaitingFirstTurn: false, running: false, - subagent: { address: ADDRESS, parentAvailable: undefined }, + subagent: { address: ADDRESS }, }, summaryBlank: false, - expected: { phase: 'settling', hero: false }, + expected: { phase: 'active', hero: false }, }, ])('derives the $name embedded phase and fixes the local view to Chat', ({ session: patch, summaryBlank, expected }) => { const snapshot = { ...sessionSnapshot(CHILD), ...patch } as SessionSnapshot diff --git a/packages/experimental/hooks-claude-code/tests/bridge.spec.ts b/packages/experimental/hooks-claude-code/tests/bridge.spec.ts index f6b012bc28..10a12985fb 100644 --- a/packages/experimental/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/experimental/hooks-claude-code/tests/bridge.spec.ts @@ -328,8 +328,8 @@ describe('hooks-claude-code bridge — SubagentStart / SubagentStop (observe)', const { ctx, hooks } = await harnessWithFiber(dir, adapter) // Drive the observe-only lifecycle events directly. SubagentStart must run // the hook even when no registered child resolves from the notification. - ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) - ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { parentSessionId: SessionId('parent'), runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) + ctx.emit(subagentCarrier(ctx), 'subagent/end', { parentSessionId: SessionId('parent'), runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) // Both hooks run async (detached .then); poll for their marker files rather // than a fixed sleep that flakes under load. @@ -364,7 +364,7 @@ describe('hooks-claude-code bridge — SubagentStart / SubagentStop (observe)', const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { parentSessionId: SessionId('parent'), runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await hooks.dispose() diff --git a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts index 82ea12294f..934c04c627 100644 --- a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts @@ -252,7 +252,7 @@ export function defineCoverageCases(group: CoverageGroup): void { session: { id: SessionId('child-x'), header: { id: 'child-x' } }, } as unknown as Parameters[0] await ctx.agents.register(child) - ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { parentSessionId: SessionId('parent'), runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true }) await waitFor(() => injected.includes('child guidance')) expect(injected).toContain('child guidance') }) @@ -268,7 +268,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const warn = vi.fn(); ctx.logger.warn = warn as never const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters[0] await ctx.agents.register(child) - ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true }) + ctx.emit(subagentCarrier(ctx), 'subagent/start', { parentSessionId: SessionId('parent'), runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) @@ -312,7 +312,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) - ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' }) + ctx.emit(subagentCarrier(ctx), 'subagent/end', { parentSessionId: SessionId('parent'), runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) }) @@ -722,7 +722,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const { SessionId } = await import('@deepseek-ai/dsh-session') const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) const runId = SubagentRunId('run-stop') - const identity = { runId, provider: 'inproc', id: childHandle.agent.id, local: true } + const identity = { runId, parentSessionId: SessionId('parent'), provider: 'inproc', id: childHandle.agent.id, local: true } // Start is the registry-backed capture edge; end deliberately follows // handle disposal, matching continuable Activation settlement. ctx.emit(subagentCarrier(ctx), 'subagent/start', identity) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 1dd6bf22b9..1cacf90723 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -949,7 +949,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionSnapshot', - declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly pendingSubmissions: readonly PendingSubmission[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n readonly parentAvailable?: boolean;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: RemoteFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}', + declaration: 'export interface SessionSnapshot {\n readonly sessionId: SessionId;\n readonly pendingSubmissions: readonly PendingSubmission[];\n readonly running: boolean;\n readonly subagent: {\n readonly address: SubagentAddress;\n } | null;\n readonly removed: boolean;\n readonly openState: OpenState;\n readonly openError: RemoteFailure | null;\n readonly hasMore: boolean;\n readonly loadingOlder: boolean;\n readonly promptError: PromptError | null;\n readonly blank: boolean;\n readonly lastAgentError: string | null;\n readonly promptAttempted: boolean;\n readonly awaitingFirstTurn: boolean;\n}', }, { name: 'SessionStandardProps', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0a83b60cad..eab66d5ecb 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3066,10 +3066,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'prompt\') async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise', - description: 'Deliver one browser-authored message to a continuable child through the exact live direct parent, retaining the caller-minted request identity and validated browser zone on the accepted message. Success identifies the message the child\'s inbox accepted; later execution is independent of this call. Queue delivery targets a later turn; steer delivery targets the nearest step and retains the Agent loop\'s best-effort fallback semantics. Image parts are admitted and persisted through the attachment store before delivery, and the child\'s model must accept image input. Cold resume at capacity rejects with `subagent/delivery-unavailable`.', + description: 'Deliver one browser-authored message to a continuable child through the live Team root under its durable creation address, retaining the caller-minted request identity and validated browser zone on the accepted message. Success identifies the message the child\'s inbox accepted; later execution is independent of this call. Queue delivery targets a later turn; steer delivery targets the nearest step and retains the Agent loop\'s best-effort fallback semantics. Image parts are admitted and persisted through the attachment store before delivery, and the child\'s model must accept image input. Cold resume at capacity rejects with `subagent/delivery-unavailable`.', parameters: [{ name: 'request', description: 'durable address, delivery, minted identity, content, and optional browser zone.' }, { name: 'signal', description: 'carrier cancellation, owning the call until inbox acceptance.' }], returns: 'the accepted message\'s inbox identity.', - throws: ['{RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`, `subagent/invalid-time-zone`, `subagent/parent-unavailable`, `subagent/not-resumable`, `subagent/unauthorized`, `subagent/delivery-unavailable`, `gateway/cancelled`, or `gateway/internal`.'], + throws: ['{RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`, `subagent/invalid-time-zone`, `subagent/not-resumable`, `subagent/unauthorized`, `subagent/delivery-unavailable`, `gateway/cancelled`, or `gateway/internal`.'], }, { signature: '@Remote(\'interruptByParent\') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\', ): SubagentInterruptReceipt', @@ -4465,7 +4465,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'subagent/end\'(this: Scoped, info: SubagentRunEndInfo): void', summary: 'A published child settled.', description: 'A published child settled. Scope-filtered dispatch uses the same Team-root carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience.', - parameters: [{ name: 'info', description: 'the run identity and terminal outcome.' }], + parameters: [{ name: 'info', description: 'run identity, durable direct creator `parentSessionId`, and terminal outcome.' }], }, { name: 'subagent/provider-added', @@ -4489,7 +4489,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'subagent/start\'(this: Scoped, info: SubagentRunInfo): void', summary: 'A provider established a published child.', description: 'A provider established a published child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the Team root, so a root-scoped listener observes all Team activations. Paired with `subagent/end`.', - parameters: [{ name: 'info', description: 'the provider and published child identity.' }], + parameters: [{ name: 'info', description: 'provider, child identity, and durable direct creator `parentSessionId`.' }], }, { name: 'system-prompt/assemble', @@ -7845,7 +7845,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRunEndInfo', - declaration: 'export interface SubagentRunEndInfo {\n readonly runId: SubagentRunId;\n readonly provider: string;\n readonly id: SessionId;\n readonly local: boolean;\n readonly stopReason: SubagentResult[\'stopReason\'];\n readonly lastAssistantMessage?: readonly ContentBlock[];\n}', + declaration: 'export interface SubagentRunEndInfo {\n readonly runId: SubagentRunId;\n readonly provider: string;\n readonly parentSessionId: SessionId;\n readonly id: SessionId;\n readonly local: boolean;\n readonly stopReason: SubagentResult[\'stopReason\'];\n readonly lastAssistantMessage?: readonly ContentBlock[];\n}', }, { name: 'SubagentRunId', @@ -7853,7 +7853,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRunInfo', - declaration: 'export interface SubagentRunInfo {\n readonly runId: SubagentRunId;\n readonly provider: string;\n readonly id: SessionId;\n readonly local: boolean;\n}', + declaration: 'export interface SubagentRunInfo {\n readonly runId: SubagentRunId;\n readonly provider: string;\n readonly parentSessionId: SessionId;\n readonly id: SessionId;\n readonly local: boolean;\n}', }, { name: 'SubagentRuntime', diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 7618df7218..bd8f0cac42 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -685,7 +685,12 @@ describe('HarnessClient', () => { inject('session.event', { sessionId: 'grandchild', event: { type: 'noop' } }) inject('session.event', { sessionId: 'stranger', event: { type: 'noop' } }) inject('subagent.started', { parentSessionId: 'other-root', childSessionId: 'other-child' }) + const subtree = client.subscribeSessionTree('child') + inject('subagent.finished', { parentSessionId: 'root', childSessionId: 'child' }) inject('subagent.finished', { parentSessionId: 'child', childSessionId: 'grandchild' }) + expect((await subtree.next()).params.childSessionId).toBe('child') + expect((await subtree.next()).params).toMatchObject({ parentSessionId: 'child', childSessionId: 'grandchild' }) + subtree.close() // Self-loop and empty edges must not corrupt the lineage map. inject('subagent.started', { parentSessionId: 'loop', childSessionId: 'loop' }) inject('subagent.started', { parentSessionId: '', childSessionId: 'x' }) @@ -695,6 +700,7 @@ describe('HarnessClient', () => { expect((await tree.next()).method).toBe('subagent.started') expect((await tree.next()).method).toBe('subagent.started') expect((await tree.next()).params.sessionId).toBe('grandchild') + expect((await tree.next()).params.childSessionId).toBe('child') expect((await tree.next()).method).toBe('subagent.finished') // The foreign-root edge and stranger event were filtered; next is the root-child edge. expect((await tree.next()).params.childSessionId).toBe('root') diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 2f62e76afe..acbd3dfbba 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/README.i18n.yaml @@ -36,8 +36,8 @@ en: eca39e7027d67d8f zh: dd3f494276322857 /deepseek-ai-dsh-sdk-jsonrpc-server/understand-the-implementation/design-concept: - en: e83e01184c1a45ee - zh: d54ed126d31ee6f1 + en: 6dd677b29fea716e + zh: ea812d660f51dd01 /deepseek-ai-dsh-sdk-jsonrpc-server/understand-the-implementation/source-map: en: 9ef78eca4d95d97c zh: 45ff05f4d6c1cd0b diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index bc78c02cdb..ca7d6773fc 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -69,7 +69,7 @@ This section explains the design behind the serving plugin; the observable behav ### Design concept -The plugin is a thin presentation adapter: [`HarnessSdkJsonRpcServer`](src/server.ts) owns the protocol methods and notifications, while the transport and the named wire types come from `dsh-sdk-protocol`, shared with the client SDKs. It subscribes to session, agent, and subagent lifecycle events and forwards them as wire notifications; subagent completions are forwarded only when the service-snapshotted lifecycle `local` flag is true — provider names, child ids, and durable lineage never establish locality. +The plugin is a thin presentation adapter: [`HarnessSdkJsonRpcServer`](src/server.ts) owns the protocol methods and notifications, while the transport and the named wire types come from `dsh-sdk-protocol`, shared with the client SDKs. It subscribes to session, agent, and subagent lifecycle events and forwards them as wire notifications; subagent completions are forwarded only when the service-snapshotted lifecycle `local` flag is true — provider names, child ids, and durable lineage never establish locality. Both `subagent.started` and `subagent.finished` identify the durable direct creator in `parentSessionId`, including after that creator exits; the Team root only scopes runtime dispatch. ### Source map diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 772ae98e6f..fa85f9bbfd 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -69,7 +69,7 @@ Stdout 只承载 JSON-RPC 帧,客户端可以逐字节解析;诊断信息应 ### 设计理念 -本插件是轻量的展示适配器:[`HarnessSdkJsonRpcServer`](src/server.ts) 负责协议方法与通知,传输与具名协议类型来自 `dsh-sdk-protocol`,与客户端 SDK 共享。它订阅会话、agent 与 subagent 生命周期事件,并把它们作为协议通知转发;只有当服务在生命周期建立快照时记录的 `local` 标志为 true 时才转发 subagent 完成事件——提供方名称、子级 id 与持久化谱系均不能证明本地性。 +本插件是轻量的展示适配器:[`HarnessSdkJsonRpcServer`](src/server.ts) 负责协议方法与通知,传输与具名协议类型来自 `dsh-sdk-protocol`,与客户端 SDK 共享。它订阅会话、agent 与 subagent 生命周期事件,并把它们作为协议通知转发;只有当服务在生命周期建立快照时记录的 `local` 标志为 true 时才转发 subagent 完成事件——提供方名称、子级 id 与持久化谱系均不能证明本地性。 `subagent.started` 与 `subagent.finished` 的 `parentSessionId` 都标识持久化的直接创建者,即使创建者已退出;Team 根仅决定运行时事件的分发作用域。 ### 源码地图 diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index 726f430f15..fe9c8243b7 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -11,10 +11,8 @@ import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { admitEncodedImages, type EncodedImageAttachment, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createUserMessage, ReasoningEffortId, type ContentBlock, type LlmRuntime } from '@deepseek-ai/dsh-llm' -import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import type { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-working-directory' -import type SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import type { @@ -59,11 +57,6 @@ async function durablePromptContent(ctx: Context, blocks: SessionPromptParams['c : block) } -/** Recover the delegating parent from the service-owned scoped carrier. */ -function subagentParentOf(carrier: Scoped): Agent { - return carrierKeyOf(carrier) as Agent -} - /** Deployment-specific status mapping for SDK turn and subagent outcomes. */ export interface HarnessSdkJsonRpcServerOptions { /** Report max-token termination as an accepted result instead of an infrastructure error. */ @@ -126,8 +119,7 @@ export class HarnessSdkJsonRpcServer { } this.transport.notify('subagent.started', payload) })) - this.disposers.push(ctx.on('subagent/end', function (this: Scoped, info: SubagentRunEndInfo) { - const parent = subagentParentOf(this) + this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { // This protocol reports only in-process child sessions. The service // snapshots the provider name and local flag through child disposal; // matching ids or parent lineage alone never establishes locality. @@ -135,7 +127,7 @@ export class HarnessSdkJsonRpcServer { const payload: SubagentFinishedNotification = { provider: info.provider, agentId: String(info.id), - parentSessionId: String(parent.session.id), + parentSessionId: String(info.parentSessionId), childSessionId: String(info.id), status: successStatus(info.stopReason, serverOptions), stopReason: info.stopReason, diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 2553bb43a2..e587720304 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -80,7 +80,7 @@ async function startLifecycleFixture( const provider = ctx.subagents.getProvider(providerName) if (provider?.start === undefined) throw new Error('missing lifecycle fixture provider') const run = await provider.start({ ...request, cwd: request.cwd ?? process.cwd() }) - const identity = { runId: SubagentRunId(randomUUID()), provider: providerName, id: run.id, local } + const identity = { runId: SubagentRunId(randomUUID()), provider: providerName, id: run.id, parentSessionId: request.parent.id, local } const carrier = scopeTarget(ctx.subagents, request.parent) ctx.emit(carrier, 'subagent/start', identity) void run.result.then( @@ -94,7 +94,7 @@ async function startLifecycleFixture( async function settleSubagent( ctx: Context, parent: Agent, - info: Omit & { localAgent?: Agent }, + info: Omit & { localAgent?: Agent }, beforeSettle?: () => Promise, ): Promise { const result = Promise.withResolvers() @@ -130,6 +130,41 @@ async function settleSubagent( } describe('HarnessSdkJsonRpcServer', () => { + it('reports a nested child creator after disposal when lifecycle dispatch belongs to the Team root', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-nested-creator-')) + const ctx = await makeHarness(storageDir) + const transport = new FakeTransport() + const server = new HarnessSdkJsonRpcServer(ctx, transport) + try { + const root = await ctx.agents.create({ sessionId: SessionId('root'), meta: { cwd: storageDir } }) + const creator = await ctx.agents.create({ + sessionId: SessionId('creator'), parentAgent: root.agent, + meta: { cwd: storageDir, parentSession: root.agent.id, origin: 'subagent' }, + }) + const child = await ctx.agents.create({ + sessionId: SessionId('leaf'), parentAgent: root.agent, + meta: { cwd: storageDir, parentSession: creator.agent.id, origin: 'subagent' }, + }) + await creator.dispose() + await child.dispose() + ctx.emit(scopeTarget(ctx.subagents, root.agent), 'subagent/end', { + runId: SubagentRunId(randomUUID()), provider: 'spawn', id: child.agent.id, + parentSessionId: creator.agent.id, local: true, stopReason: 'completed', + }) + expect(transport.notifications.filter(n => n.params?.childSessionId === 'leaf')).toEqual([ + { method: 'subagent.started', params: { parentSessionId: 'creator', childSessionId: 'leaf' } }, + { method: 'subagent.finished', params: { + provider: 'spawn', agentId: 'leaf', parentSessionId: 'creator', childSessionId: 'leaf', + status: 'ok', stopReason: 'completed', + } }, + ]) + } finally { + await server.shutdown() + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('waits only for an existing SDK-owned session and includes a child-triggered root turn', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-wait-')) const ctx = await makeHarness(storageDir) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index e62a5f3c9c..447aa7a249 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: 878f7eb8d43bf1fc - zh: 4509a8da6a3f1797 + en: 4c91b1d897ff73fa + zh: 0c65fb3bb7de1244 /deepseek-ai-dsh-subagent/use-this-package/failure-and-recovery: en: 399eb5360009a386 zh: 366eb6dd5a304c3f @@ -93,8 +93,8 @@ en: 59e848fca82a46fb zh: fcd6a8a2bb58609f /deepseek-ai-dsh-subagent/known-limitations-and-deferred-work: - en: aca9a599985f4a93 - zh: aa2174f9e21747e6 + en: 746c75e25114ab8b + zh: 168d577f1fcc4281 /deepseek-ai-dsh-subagent/known-limitations-and-deferred-work/dev-note: en: 25cc0909348666bb zh: 95517f12de100049 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 5c9d608b24..f6c33ea57d 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 Team root; its creation ancestors stay offline and the message retains its actual sender. `rootOf()` resolves the caller’s live root. Interruption still requires ancestor authority. Browser prompts independently 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 Team root; its creation ancestors stay offline and the message retains its actual sender. `rootOf()` resolves the caller’s live root. Interruption still requires ancestor authority. Browser prompts validate the durable direct-creator address and require a live Team root; the creator may be offline. 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. ### Failure and recovery @@ -203,13 +203,11 @@ These limits define when the seam is a poor fit or needs special operational car - **Descendant reads are sequential** — each reachable catalog, including one-shot children, takes one observation. A cold Session without a valid prepared observation requires a full-log read; large cold trees can accumulate storage latency. - **External children execute once** — ACP, DSH SDK, Codex, and Claude Code have no local child Session and accept no follow-up input. Their parent-owned records remain discoverable after disposal. -- **Messaging stays within one tree** — targets use Session ids and must be the root or continuable members. Cold ancestors consume activation capacity. Browser prompts retain their separate human Queue-or-Steer control path. -- **A direct parent must remain live for child-to-parent delivery** — the service has no durable parent mailbox; a missing parent rejects the message instead of accepting work it cannot wake. +- **Messaging stays within one tree** — targets use Session ids and must be the root or continuable members. Only the restored target consumes activation capacity. Browser prompts retain their separate human Queue-or-Steer control path. - **Wake gap during cancellation convergence** — a follow-up accepted after an interrupt signal but before the Agent becomes idle stays queued until another waking send. - **Pending injected context retains an Activation** — settlement conservatively treats every Inbox occurrence as unfinished. Context parked after the Agent becomes idle keeps the child and its live ancestors resident until a waking delivery claims it, a queue mutation removes it, or manager teardown discards it. Host completion joins active work, pending child creation, and resource cleanup, but does not wait for a subtree containing only idle parked inboxes; it neither completes those activation results nor discards their messages. - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store needs a durable mailbox and cross-process lease protocol. - **No replay of accepted-but-unlogged messages** — a crash can lose an accepted prompt that never reached the child's session log; the lost message is not replayed automatically. -- **No durable parent mailbox** — child-to-parent messages require a resident continuable child and live direct parent, and provide acceptance identity rather than exactly-once delivery. - **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision API waits for a concrete consumer. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 8169bc4e57..e0586b5bd2 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 输入,或在空闲时开始轮次。仅在 Team 根下恢复冷目标,其创建祖先保持离线;消息保留实际发送者。`rootOf()` 解析调用方的在线根。中断仍要求祖先权限。浏览器提示词独立选择 Queue 或 Steer,经附件存储接受图片批次;不兼容的目标模型拒绝图片。直接子级发现通过 `listChildren(parentSessionId, signal?)` 读取 `subagentCatalog`,持有并释放优先在线的 Session observation。递归发现保留目录顺序,每个可达 Session 读取一次,跳过外部叶子,并报告不可读分支而不激活 Agent。 +`sendMessage()` 允许在线根和驻留的可继续子代理,按 Session id 联系同一棵树中的任意其他根或可继续成员。目标在下一步接收 Steer 输入,或在空闲时开始轮次。仅在 Team 根下恢复冷目标,其创建祖先保持离线;消息保留实际发送者。`rootOf()` 解析调用方的在线根。中断仍要求祖先权限。浏览器提示词校验持久化的直接创建者地址,并要求 Team 根在线;创建者可以离线。提示词选择 Queue 或 Steer,经附件存储接受图片批次;不兼容的目标模型拒绝图片。直接子级发现通过 `listChildren(parentSessionId, signal?)` 读取 `subagentCatalog`,持有并释放优先在线的 Session observation。递归发现保留目录顺序,每个可达 Session 读取一次,跳过外部叶子,并报告不可读分支而不激活 Agent。 ### 失败与恢复 @@ -203,13 +203,11 @@ You are a delegated subagent: your permission scope was fixed when you were star - **后代读取串行执行**——每个可达目录(包括一次性子级)都需要一次观察。冷 Session 缺少有效的 prepared 观察时需要读取完整日志;大型冷会话树可能累积存储延迟。 - **外部子级执行一次**——ACP、DSH SDK、Codex 与 Claude Code 没有本地子 Session,也不接受后续输入。释放后仍可发现父级拥有的执行记录。 -- **消息限定在一棵树内**——目标使用 Session id,必须是根或可继续成员。冷祖先占用激活容量。浏览器提示词保留独立的人类 Queue 或 Steer 控制路径。 -- **child 到 parent 的投递要求直接 parent 保持在线**——服务没有持久 parent mailbox;parent 缺失时会拒绝消息,而非接受无法唤醒的工作。 +- **消息限定在一棵树内**——目标使用 Session id,必须是根或可继续成员。仅恢复的目标占用激活容量。浏览器提示词保留独立的人类 Queue 或 Steer 控制路径。 - **取消收敛期间存在唤醒缺口**——中断信号发出后、Agent 进入 idle 前被接受的后续消息会保持排队,直到另一条唤醒发送到达。 - **待处理的注入上下文会保留 Activation**——settlement 会保守地把每个 Inbox occurrence 都视为未完成。Agent 进入 idle 后停放的上下文会让 child 及其在线祖先继续驻留,直到唤醒投递将其 claim、queue 变更将其移除,或 manager teardown 将其丢弃。宿主完成检查等待活动工作、待创建子级与资源清理,但不会等待仅包含空闲停放 Inbox 的子树;它既不结算这些 activation 的结果,也不丢弃其消息。 - **驻留仅限进程内**——Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问需要持久化邮箱与跨进程租约协议。 - **不回放已接受但未记录的消息**——崩溃可能丢失从未写入子会话日志、已被接受的提示词;丢失的消息不会自动回放。 -- **没有持久化 parent mailbox**——child 到 parent 的消息要求驻留的可继续 child 与在线直接 parent,提供的是接受标识,不保证恰好一次投递。 - **生命周期事件只供观察**——影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。 diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index 528692a0a5..73137dcffb 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -135,8 +135,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { /** A browser-supplied zone is neither UTC nor a canonical IANA name. */ 'subagent/invalid-time-zone': { readonly value: string } - /** No live Agent carries the addressed parent session. */ - 'subagent/parent-unavailable': { readonly parentSessionId: SessionId } /** The addressed child cannot take a continuation. */ 'subagent/not-resumable': { readonly childSessionId: SessionId } /** The claimed parent does not own the addressed child. */ diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 4b68a64852..a7469c664d 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -14,7 +14,7 @@ * to one activation's result contract rather than durable child composition. * Per-activation knobs such as `maxTokens` are omitted for the same reason as * `outputSchema`: they budget one activation. Cold resume requires the exact - * live parent for authorization but reconstructs child options only from the + * live Team root for authorization but reconstructs child options only from the * durable descriptor, so it neither restores the prior budget nor inherits * the parent's current one; the resumed route's defaults apply instead. * diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index b9d8c47144..c2bc53cd7b 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -153,7 +153,7 @@ declare module '@deepseek-ai/cordis' { * Scope-filtered dispatch keys the carrier by the Team root, so a * root-scoped listener observes all Team activations. Paired with * `subagent/end`. - * @param info - the provider and published child identity. + * @param info - provider, child identity, and durable direct creator `parentSessionId`. * @mode emit */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void @@ -161,7 +161,7 @@ declare module '@deepseek-ai/cordis' { * A published child settled. Scope-filtered dispatch uses the same Team-root * carrier as `subagent/start`, so the lifecycle pair reaches the * same scoped audience. - * @param info - the run identity and terminal outcome. + * @param info - run identity, durable direct creator `parentSessionId`, and terminal outcome. * @mode emit */ 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void @@ -217,7 +217,7 @@ export class SubagentRuntime extends TypertRemoteService { return provider.start(request) }, prepareContinuable: (name, request) => this.prepareContinuable(name, request), - observeActivation: (provider, childId, parent) => this.observeActivation(provider, childId, parent), + observeActivation: (provider, childId, root, parentSessionId) => this.observeActivation(provider, childId, root, parentSessionId), }, () => this.config.maxActiveSubagents.get()) this.manager = manager childCtx.effect(() => () => { @@ -330,7 +330,7 @@ export class SubagentRuntime extends TypertRemoteService { * Deliver one host-protocol message to a direct continuable child. * Symbol-keyed so host adapters can preserve their own source descriptors without * widening the public Service Definition or impersonating an Agent sender. - * @param parent - exact live direct parent authorizing delivery. + * @param parentSessionId - durable direct creator authorizing delivery. * @param childId - durable direct-child session id. * @param content - host-authored content to deliver. * @param source - durable host-protocol source descriptor. @@ -339,7 +339,7 @@ export class SubagentRuntime extends TypertRemoteService { * @returns the accepted message's inbox id. */ private [deliverSubagentPrompt]( - parent: Agent, + parentSessionId: SessionId, childId: SessionId, content: ContentBlock[], source: MessageSource, @@ -347,8 +347,8 @@ export class SubagentRuntime extends TypertRemoteService { delivery: SubagentDelivery, ): Promise { return delivery === 'steer' - ? this.requireManager().steerPrompt(parent, childId, content, source, signal) - : this.requireManager().queuePrompt(parent, childId, content, source, signal) + ? this.requireManager().steerPrompt(parentSessionId, childId, content, source, signal) + : this.requireManager().queuePrompt(parentSessionId, childId, content, source, signal) } /** @@ -436,7 +436,7 @@ export class SubagentRuntime extends TypertRemoteService { /** * Deliver one browser-authored message to a continuable child through the - * exact live direct parent, retaining the caller-minted request identity and + * live Team root under its durable creation address, retaining the caller-minted request identity and * validated browser zone on the accepted message. Success identifies the * message the child's inbox accepted; later execution is independent of this * call. Queue delivery targets a later turn; steer delivery targets the @@ -448,7 +448,7 @@ export class SubagentRuntime extends TypertRemoteService { * @param signal - carrier cancellation, owning the call until inbox acceptance. * @returns the accepted message's inbox identity. * @throws {RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`, - * `subagent/invalid-time-zone`, `subagent/parent-unavailable`, + * `subagent/invalid-time-zone`, * `subagent/not-resumable`, `subagent/unauthorized`, * `subagent/delivery-unavailable`, `gateway/cancelled`, or `gateway/internal`. */ @@ -466,14 +466,6 @@ export class SubagentRuntime extends TypertRemoteService { { value: clientTimeZone }, ) } - const parent = this.ctx.get('agents')?.get(parentSessionId) - if (parent === undefined) { - throw new RemoteError( - 'subagent/parent-unavailable', - `parent session "${parentSessionId}" is not live`, - { parentSessionId }, - ) - } const principal = principalOf(this.ctx.invocation) const source: BrowserPromptSource = { kind: 'user', @@ -494,7 +486,7 @@ export class SubagentRuntime extends TypertRemoteService { } return { messageId: await this[deliverSubagentPrompt]( - parent, + parentSessionId, childSessionId, content, source, @@ -630,9 +622,10 @@ export class SubagentRuntime extends TypertRemoteService { private observeActivation( provider: string, childId: SessionId, - parent: Agent, + root: Agent, + parentSessionId: SessionId, ): ActivationObserver { - return createActivationObserver(this.emitLifecycle, provider, childId, parent) + return createActivationObserver(this.emitLifecycle, provider, childId, root, parentSessionId) } /** Reject the first requested capability that the provider lacks. */ diff --git a/packages/subagent/subagent/src/internal.ts b/packages/subagent/subagent/src/internal.ts index b5dd0e287e..dc337ef52b 100644 --- a/packages/subagent/subagent/src/internal.ts +++ b/packages/subagent/subagent/src/internal.ts @@ -4,7 +4,6 @@ * @module @deepseek-ai/dsh-subagent/internal */ -import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' @@ -44,7 +43,7 @@ export const deliverSubagentPrompt = Symbol.for('dsh.subagent.deliverPrompt') /** Runtime face required by the host-only prompt adapters. */ export interface HostPromptDeliverer { [deliverSubagentPrompt]( - parent: Agent, + parentSessionId: SessionId, childId: SessionId, content: ContentBlock[], source: MessageSource, @@ -56,7 +55,7 @@ export interface HostPromptDeliverer { /** * Queue one host-protocol message without exposing another Service operation. * @param runtime - subagent runtime owning continuation residency. - * @param parent - exact live direct parent authorizing delivery. + * @param parentSessionId - durable direct creator authorizing delivery. * @param childId - durable direct-child session id. * @param content - host-authored content to deliver. * @param source - durable host-protocol source descriptor. @@ -65,14 +64,14 @@ export interface HostPromptDeliverer { */ export function queueHostSubagentPrompt( runtime: SubagentRuntime, - parent: Agent, + parentSessionId: SessionId, childId: SessionId, content: ContentBlock[], source: MessageSource, signal: AbortSignal, ): Promise { return (runtime as unknown as HostPromptDeliverer)[deliverSubagentPrompt]( - parent, + parentSessionId, childId, content, source, diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 9e699909b6..292b14305a 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -90,27 +90,29 @@ export function createLifecycleEmitter( * @param emit - the contained lifecycle emitter. * @param provider - the provider name recorded in the durable descriptor. * @param childId - the durable child session id. - * @param parent - the exact live Team root keying scoped dispatch. + * @param root - the exact live Team root keying scoped dispatch. + * @param parentSessionId - durable direct creator retained through disposal. * @returns the observer whose edges this epoch publishes. */ export function createActivationObserver( emit: LifecycleEmitter, provider: string, childId: SessionId, - parent: Agent, + root: Agent, + parentSessionId: SessionId, ): ActivationObserver { - const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, local: false } + const identity = { runId: SubagentRunId(randomUUID()), provider, id: childId, parentSessionId, local: false } return { start: (child?: Agent): void => { identity.local = child !== undefined - emit('subagent/start', identity, parent) + emit('subagent/start', identity, root) }, settle: ({ stopReason, output }: SubagentResult): void => { emit('subagent/end', { ...identity, stopReason, ...output.length === 0 ? {} : { lastAssistantMessage: output }, - }, parent) + }, root) }, } } diff --git a/packages/subagent/subagent/src/manager.ts b/packages/subagent/subagent/src/manager.ts index e26a6f475b..fcae27289b 100644 --- a/packages/subagent/subagent/src/manager.ts +++ b/packages/subagent/subagent/src/manager.ts @@ -60,7 +60,7 @@ interface SubagentHost { /** Resolve one provider's detached continuable-creation contribution. */ prepareContinuable(name: string, request: ContinuableCreateRequest): Promise /** Build the lifecycle observer for one Activation residency epoch. */ - observeActivation(provider: string, childId: SessionId, parent: Agent): ActivationObserver + observeActivation(provider: string, childId: SessionId, root: Agent, parentSessionId: SessionId): ActivationObserver } /** Inputs shared by fresh and resumed Activation materialization. */ @@ -516,8 +516,8 @@ export class SubagentManager { }) } - /** Verify durable creation edges without activating any ancestor. */ - private async ancestorsOf(root: Agent, source: SessionObservation, signal: AbortSignal): Promise { + /** Resolve the live owner through verified creation edges without activating ancestors. */ + private async teamOf(source: SessionObservation, signal: AbortSignal, root?: Agent): Promise> { const ancestors: SessionId[] = [] const seen = new Set([source.header.id]) let { header, projections, inheritedEventCount } = source @@ -525,20 +525,28 @@ export class SubagentManager { signal.throwIfAborted() const parentId = header.parentSession const identity = projections?.values.subagent - if (parentId === undefined || seen.has(parentId) || identity == null || identity.seq < inheritedEventCount) break + if (parentId === undefined || identity == null || identity.seq < inheritedEventCount) break + if (seen.has(parentId)) throw new SubagentError('cyclic subagent creation ancestry', 'UNAUTHORIZED') seen.add(parentId) using parent = await this.observeContinuation(parentId, signal) - if (!parent.projections?.values.subagentCatalog?.some(entry => entry.id === header.id)) break + if (!parent.projections?.values.subagentCatalog?.some(entry => entry.id === header.id)) { + throw new SubagentError('subagent is absent from its creator catalog', 'UNAUTHORIZED') + } ancestors.unshift(parentId) - if (parentId === root.id) return ancestors + if (parentId === root?.id) return { root, ancestors } ;({ header, projections, inheritedEventCount } = parent) } + if (root === undefined && ancestors.length > 0 && header.origin !== 'subagent') { + const owner = this.ctx.agents.roots().find(agent => agent.id === header.id) + if (owner === undefined) throw new SubagentError('Team root is not live', 'CONTINUATION_UNAVAILABLE') + return { root: owner, ancestors } + } throw new SubagentError(`agent "${source.header.id}" is not a member of this Team`, 'UNAUTHORIZED') } /** * Queue one human-authored prompt as a distinct direct-child turn. - * @param parent - exact live direct parent authorizing delivery. + * @param parentSessionId - durable direct creator authorizing delivery. * @param childId - durable direct-child session id. * @param content - model-visible prompt blocks. * @param source - durable attribution for the human prompt. @@ -546,18 +554,18 @@ export class SubagentManager { * @returns the accepted durable message id. */ async queuePrompt( - parent: Agent, + parentSessionId: SessionId, childId: SessionId, content: ContentBlock[], source: MessageSource, signal: AbortSignal, ): Promise { - return this.deliverFromParent(parent, childId, content, { source, signal, delivery: 'queue' }) + return this.deliverFromParent(parentSessionId, childId, content, { source, signal, delivery: 'queue' }) } /** * Steer one host-authored prompt to a direct continuable child. - * @param parent - exact live direct parent authorizing delivery. + * @param parentSessionId - durable direct creator authorizing delivery. * @param childId - durable direct-child session id. * @param content - model-visible prompt blocks. * @param source - durable attribution for the host prompt. @@ -565,13 +573,13 @@ export class SubagentManager { * @returns the accepted durable message id. */ async steerPrompt( - parent: Agent, + parentSessionId: SessionId, childId: SessionId, content: ContentBlock[], source: MessageSource, signal: AbortSignal, ): Promise { - return this.deliverFromParent(parent, childId, content, { source, signal, delivery: 'steer' }) + return this.deliverFromParent(parentSessionId, childId, content, { source, signal, delivery: 'steer' }) } /** Admit local input synchronously before any teardown can begin. */ @@ -804,7 +812,7 @@ export class SubagentManager { resource = { kind: 'local', handle, outputStart: handle.agent.session.seq, structured, failureAt: undefined, poke: Promise.withResolvers() } } - const observer = this.host.observeActivation(provider, childId, root) + const observer = this.host.observeActivation(provider, childId, root, inputs.ancestors.at(-1) as SessionId) const activation: Activation = { releaseSlot, @@ -1059,13 +1067,28 @@ export class SubagentManager { } /** Preserve direct-parent authorization for human host prompts. */ - private deliverFromParent(parent: Agent, childId: SessionId, content: ContentBlock[], options: ChildDeliveryOptions): Promise { - const root = this.rootOf(parent) + private async deliverFromParent( + parentSessionId: SessionId, childId: SessionId, content: ContentBlock[], options: ChildDeliveryOptions, + ): Promise { + options.signal.throwIfAborted() + const resident = this.resident.get(childId) + const creator = this.ctx.agents.get(parentSessionId) + let root: Agent + if (resident !== undefined) { + root = resident.root + } else if (creator !== undefined) { + root = this.rootOf(creator) + } else { + using source = await this.observeContinuation(childId, options.signal) + if (source.header.parentSession !== parentSessionId) { + throw new SubagentError(`subagent "${childId}" belongs to another parent session`, 'UNAUTHORIZED') + } + ;({ root } = await this.teamOf(source, options.signal)) + } return this.deliverToMember(root, childId, content, { ...options, authorize: (activation) => { - this.assertAdmitting(parent) - if (activation?.ancestors.at(-1) !== parent.id) { + if (activation?.ancestors.at(-1) !== parentSessionId) { throw new SubagentError(`subagent "${childId}" belongs to another parent session`, 'UNAUTHORIZED') } }, @@ -1133,7 +1156,7 @@ export class SubagentManager { 'NOT_RESUMABLE', ) } - const ancestors = await this.ancestorsOf(root, source, signal) + const { ancestors } = await this.teamOf(source, signal, root) let activation: Activation try { const projectedPreset = source.projections?.values.agentPreset diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 72cdcd72be..129dab3ebb 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -85,6 +85,8 @@ export interface SubagentRunInfo { * after that provider is unregistered. */ readonly provider: string + /** Durable direct creator, independent of the live Team root. */ + readonly parentSessionId: SessionId /** The child agent's id. */ readonly id: SessionId /** Whether this activation owns a local Agent, fixed at publication. */ @@ -100,6 +102,8 @@ export interface SubagentRunEndInfo { readonly runId: SubagentRunId /** The same provider name carried by the paired start event. */ readonly provider: string + /** Durable direct creator, independent of the live Team root. */ + readonly parentSessionId: SessionId /** The child agent's id. */ readonly id: SessionId /** Whether this activation owns a local Agent, fixed at publication. */ diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index df8542f988..798d1c9054 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -115,7 +115,7 @@ describe('continuable policy inheritance', () => { parent.session.append('permission/preset', { preset: preset === 'auto' ? 'danger-full-access' : 'auto' }) current.mockImplementation(() => { throw new Error('cold resume must not read parent permission') }) await queueHostSubagentPrompt( - ctx.subagents, parent, started.childId, + ctx.subagents, parent.id, started.childId, [{ type: 'text', text: 'continue please' }], { kind: 'user' }, new AbortController().signal, ) await waitNoActivation(ctx, started.childId) @@ -273,7 +273,7 @@ describe('continuable policy inheritance', () => { setSandboxMode(parent.session, 'danger-full-access') await queueHostSubagentPrompt( ctx.subagents, - parent, + parent.id, started.childId, [{ type: 'text', text: 'continue please' }], { kind: 'user' }, diff --git a/packages/subagent/subagent/tests/continuation-presets.spec.ts b/packages/subagent/subagent/tests/continuation-presets.spec.ts index 6f677a5c76..30ef9ea230 100644 --- a/packages/subagent/subagent/tests/continuation-presets.spec.ts +++ b/packages/subagent/subagent/tests/continuation-presets.spec.ts @@ -98,7 +98,7 @@ async function settled(ctx: Context, childId: SessionId) { function continueChild(ctx: Context, parent: Agent, childId: SessionId) { return queueHostSubagentPrompt( - ctx.subagents, parent, childId, + ctx.subagents, parent.id, childId, [{ type: 'text', text: 'continue' }], { kind: 'user' }, new AbortController().signal, ) } diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index c994f341ec..67849946e2 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -157,7 +157,7 @@ function queuePrompt( content: ContentBlock[], signal: AbortSignal = testSignal, ) { - return subagentManager(ctx).queuePrompt(parent, childId, content, { kind: 'user' }, signal) + return subagentManager(ctx).queuePrompt(parent.id, childId, content, { kind: 'user' }, signal) } function humanPrompt( @@ -1279,6 +1279,56 @@ describe('continuable human steering delivery', () => { }) describe('continuable child ownership', () => { + it.each(['queue', 'steer'] as const)('delivers human %s to a resident and cold child after its creator exits', async (delivery) => { + const releaseCreator = Promise.withResolvers() + const releaseLeaf = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('creator done'), gate: releaseCreator.promise }, + { chunks: textResponse('leaf'), gate: releaseLeaf.promise }, + { chunks: textResponse('resident follow-up') }, + { chunks: textResponse('cold follow-up') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const starts: SubagentRunInfo[] = [] + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/start', info => void starts.push(info)) + ctx.on('subagent/end', info => void ends.push(info)) + parkParent(ctx, parent) + const creator = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) + const creatorAgent = ctx.agents.get(creator.childId)! + const leaf = await ctx.subagents.startActivation({ ...startSpec(creatorAgent), delivery: 'parent' }) + try { + releaseCreator.resolve(undefined) + await waitNoActivation(ctx, creator.childId) + await humanPrompt(ctx, creatorAgent, leaf.childId, 'resident follow-up', delivery) + releaseLeaf.resolve(undefined) + await waitNoActivation(ctx, leaf.childId) + await humanPrompt(ctx, creatorAgent, leaf.childId, 'cold follow-up', delivery) + await waitNoActivation(ctx, leaf.childId) + expect(ctx.agents.get(creator.childId)).toBeUndefined() + const loaded = await loadStoredSession(ctx.sessionPersistence, leaf.childId) + expect(loaded.meta.parentSession).toBe(creator.childId) + expect(starts.filter(info => info.id === leaf.childId).map(info => info.parentSessionId)).toEqual([creator.childId, creator.childId]) + expect(ends.filter(info => info.id === leaf.childId).map(info => info.parentSessionId)).toEqual([creator.childId, creator.childId]) + expect(hasUserText(loaded.events, 'resident follow-up')).toBe(true) + expect(hasUserText(loaded.events, 'cold follow-up')).toBe(true) + await expect(subagentManager(ctx).queuePrompt(SessionId('wrong-creator'), leaf.childId, message('rejected'), { kind: 'user' }, testSignal)) + .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) + const roots = vi.spyOn(ctx.agents, 'roots').mockReturnValueOnce([]) + try { + await expect(humanPrompt(ctx, creatorAgent, leaf.childId, 'root offline', delivery)) + .rejects.toMatchObject({ code: 'subagent/delivery-unavailable' }) + expect(ctx.agents.get(leaf.childId)).toBeUndefined() + } finally { + roots.mockRestore() + } + } finally { + releaseCreator.resolve(undefined) + releaseLeaf.resolve(undefined) + await ctx.fiber.dispose() + } + }) + it('releases a finished creator while its child remains owned by the Team root', async () => { const releaseCreator = Promise.withResolvers() const releaseLeaf = Promise.withResolvers() @@ -3413,22 +3463,6 @@ describe('continuable errors', () => { hold.resolve(undefined) }) - it('rejects a parent that is no longer the live registry entry', async () => { - const { ctx, parent } = await setup([textResponse('first')]) - const started = await ctx.subagents.startActivation({ ...startSpec(parent), delivery: 'parent' }) - const child = await vi.waitFor(() => { - const found = ctx.agents.get(started.childId) - expect(found).toBeDefined() - return found! - }) - // A stale parent reference: same id, not the exact live entry. - const stale = { ...parent, id: parent.id } as Agent - - await expect(queuePrompt(ctx, stale, started.childId, message('stale'))) - .rejects.toMatchObject({ code: 'UNAUTHORIZED' }) - void child - }) - it('rejects delegation from an unregistered instance and input to a closing startup', async () => { const { ctx, parent } = await setup(['hang']) const unmanaged = await ctx.agents.create({ diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index 9377bbb8fc..9ad8d2b1cf 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -194,7 +194,7 @@ describe('subagent prompt Remote', () => { await expect(subagents.prompt(promptRequest('Asia/Shanghai'), signal)) .resolves.toEqual({ messageId: 'm-1' }) expect(delivery).toHaveBeenCalledWith( - expect.objectContaining({ status: 'idle' }), + PARENT, CHILD, [{ type: 'text', text: 'continue' }], { kind: 'user', rpcId: REQUEST_ID, clientTimeZone: 'Asia/Shanghai' }, @@ -253,14 +253,11 @@ describe('subagent prompt Remote', () => { } }) - it('refuses delivery when the exact parent Agent is not live', async () => { + it('passes the durable creator address without requiring its Agent', async () => { const { subagents } = await bench() - const delivery = promptDelivery(subagents) - - await expect(subagents.prompt(promptRequest(), signal)).rejects.toMatchObject({ - code: 'subagent/parent-unavailable', details: { parentSessionId: PARENT }, - }) - expect(delivery).not.toHaveBeenCalled() + const delivery = promptDelivery(subagents).mockResolvedValue('m-offline-creator' as MessageId) + await expect(subagents.prompt(promptRequest(), signal)).resolves.toEqual({ messageId: 'm-offline-creator' }) + expect(delivery.mock.calls[0]?.[0]).toBe(PARENT) }) it('maps each admission failure onto its stable code and hides the rest', async () => { diff --git a/packages/subagent/subagent/tests/sibling-messages.spec.ts b/packages/subagent/subagent/tests/sibling-messages.spec.ts index 18913aa2fe..aa3b0a75ab 100644 --- a/packages/subagent/subagent/tests/sibling-messages.spec.ts +++ b/packages/subagent/subagent/tests/sibling-messages.spec.ts @@ -158,7 +158,11 @@ describe('tree-wide messages', () => { const { ctx, parent } = await setup({}) expect(ctx.subagents.rootOf(parent)).toBe(parent) expect(() => ctx.subagents.rootOf({ ...parent })).toThrow(expect.objectContaining({ code: 'UNAUTHORIZED' })) - const fork = ctx.sessions.create(SessionId('ordinary-fork'), { meta: { parentSession: parent.id } }) + const source = ctx.sessions.create(SessionId('fork-source'), { meta: { origin: 'subagent', parentSession: parent.id } }) + source.append('turn/start', { turn: 1 }) + source.append('subagent/descriptor', { version: 3, mode: 'one-shot', provider: 'spawn', label: 'source' }) + source.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const fork = ctx.sessions.fork(source, undefined, SessionId('ordinary-fork')) const caller = { ...parent, id: fork.id, session: fork } onTestFinished(ctx.agents.enter(caller, undefined)) expect(ctx.subagents.rootOf(caller)).toBe(caller) @@ -273,7 +277,7 @@ describe('tree-wide messages', () => { expect(ctx.agents.get(leafId)).toBeUndefined() }) - it('requires the parent catalog to attest every target edge', async () => { + it.each(['missing', 'cyclic'] as const)('rejects %s creation evidence before restoring a target', async (evidence) => { const { ctx, parent } = await setup({ branch: ['hang'], leaf: [textResponse('done')] }) const branchId = await start(ctx, parent, 'branch') const branch = await running(ctx, branchId) @@ -284,7 +288,9 @@ describe('tree-wide messages', () => { const result = await observe(...args) return args[0] !== branchId ? result : { ...result, - projections: { ...result.projections!, values: { ...result.projections!.values, subagentCatalog: [] } }, + ...evidence === 'cyclic' + ? { header: { ...result.header, parentSession: leafId } } + : { projections: { ...result.projections!, values: { ...result.projections!.values, subagentCatalog: [] } } }, } }) try { diff --git a/packages/subagent/subagent/tests/structured.spec.ts b/packages/subagent/subagent/tests/structured.spec.ts index 8499682cfd..08950f7805 100644 --- a/packages/subagent/subagent/tests/structured.spec.ts +++ b/packages/subagent/subagent/tests/structured.spec.ts @@ -105,7 +105,7 @@ function deliverInput( const content: ContentBlock[] = [{ type: 'text', text: 'Correction: submit answer 42 instead.' }] return delivery === 'steer' ? ctx.subagents.sendMessage(parent, childId, content, { signal: testToolSignal }) - : queueHostSubagentPrompt(ctx.subagents, parent, childId, content, { kind: 'user' }, testToolSignal) + : queueHostSubagentPrompt(ctx.subagents, parent.id, childId, content, { kind: 'user' }, testToolSignal) } describe('in-process structured output', () => { diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index a79f560fb4..6298403a82 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -630,6 +630,20 @@ def test_session_subscription_keeps_descendant_relationships_across_subscription assert client._notifications.qsize() == 0 +def test_subtree_keeps_descendant_completion_after_creator_finishes() -> None: + client = HarnessClient() + with client.subscribe_session_notifications("child") as subtree: + for method, parent, child in [ + ("subagent.started", "root", "child"), + ("subagent.started", "child", "leaf"), + ("subagent.finished", "root", "child"), + ("subagent.finished", "child", "leaf"), + ]: + client._handle_message({"jsonrpc": "2.0", "method": method, + "params": {"parentSessionId": parent, "childSessionId": child}}) + assert subtree.next().payload == {"parentSessionId": parent, "childSessionId": child} + + def test_session_subscription_preserves_reused_child_ancestry_after_late_finish() -> None: client = HarnessClient() old_seen: list[Notification] = [] diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 0d6260b79c..1e5ddc02f7 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -1695,6 +1695,11 @@ def smoke_sdk_snapshot_mode( expected_children = 2 if mode == "native" else 0 if methods.count("subagent.started") != expected_children or methods.count("subagent.finished") != expected_children: raise AssertionError(f"{mode} advanced snapshot emitted unexpected subagent lifecycle: {methods}") + creators = {item.payload["childSessionId"]: item.payload["parentSessionId"] + for item in result.notifications if item.method == "subagent.started"} + for item in result.notifications: + if item.method == "subagent.finished": + assert item.payload["parentSessionId"] == creators[item.payload["childSessionId"]] ptc_events = [event for event in result.events if event.get("type") in ("tool/ptc-dispatch-start", "tool/ptc-dispatch")] expected_ptc_calls = 2 if mode == "ptc" else 0 diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 233e4915e5..87897562ac 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -1235,6 +1235,10 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { if (ordered.length > 1 && assertions.dshSdkChild === undefined) { expect(observedMethods.has('subagent.started')).toBe(true) expect(observedMethods.has('subagent.finished')).toBe(true) + for (const notification of notifications.filter(item => item.method === 'subagent.finished')) { + const child = ordered.find(log => log.header.id === notification.params.childSessionId) + expect(notification.params.parentSessionId).toBe(child?.header.parentSession) + } } }) } diff --git a/snapshots/session/team-tree-messages/snapshot.yml b/snapshots/session/team-tree-messages/snapshot.yml index f760586d57..9fff1ec5c3 100644 --- a/snapshots/session/team-tree-messages/snapshot.yml +++ b/snapshots/session/team-tree-messages/snapshot.yml @@ -1,6 +1,6 @@ version: 1 scenario: team-tree-messages -profile: headless +profile: sdk composition: team-targets recording: authored header: diff --git a/snapshots/web/subagent-conversation/nested.expected.md b/snapshots/web/subagent-conversation/nested.expected.md index 01e95cd04d..6e5dc50f10 100644 --- a/snapshots/web/subagent-conversation/nested.expected.md +++ b/snapshots/web/subagent-conversation/nested.expected.md @@ -14,6 +14,7 @@ - button "Copy" - status: Completed - button "Completed in {{duration}}" [disabled] -- status: - - strong: This subagent is read-only for now - - text: The parent session is offline; reopen it to continue sending messages. +- textbox "Message or run a task, / commands, @ files or sessions" +- button "Add files or run commands" +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Send message" [disabled] diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index 46af81e085..e054f9d318 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -20,8 +20,8 @@ - button "Edit queued message" - button "Remove queued message" - button "Steer queued message" -- textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled] -- button "Add files or run commands" [disabled] -- 'button "Access mode, current: Custom" [disabled]': Custom +- textbox "Cmd/Ctrl+Enter steers all queued messages" +- button "Add files or run commands" +- 'button "Access mode, current: Custom"': Custom - button "Stop generating" - button "Send message" [disabled]