diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml index 237d9fe4e4..6a415c2516 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md -2026-08-06-continuable-subagent-interrupt.md: 5727d43526842b492633cc198f2a3bd01add126a -2026-08-06-continuable-subagent-interrupt.zh.md: de1556acbc182e79e2afa8b7f587bb9fcc0d08e0 +2026-08-06-continuable-subagent-interrupt.md: 79397ac58d9bc1a117053f05f1a5b7971c48cfbb +2026-08-06-continuable-subagent-interrupt.zh.md: 2b29895c21bf2290b750bdce6979dbe2d76635a7 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md index 5727d43526..79397ac58d 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md @@ -45,4 +45,4 @@ The model-facing `interrupt_agent(agent_id)` tool in `dsh-tool-subagent-control` ## Testing -Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/host/apiproxy/tests` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. Client coverage pins the address-routed `Session.cancel()`, the InputBar's independent Send and Stop actions with the parent-offline locked-input/Send state, and the read-only-composer selector's running exception; the keyless assembled Web scenarios (`apps/web/tests/subagent-interrupt.e2e.ts`, `subagent-interrupt-ui.e2e.ts`) hold a real child turn open with a replay hang entry and prove queued Send, the interrupt transport, the aborted `turn/end`, the parked follow-up, and the FIFO resume end to end. Tool coverage in `packages/subagent/tool-subagent-control/tests` proves direct and deep ancestor interrupts with the `parent` cause and parked queue, self/sibling/stranger rejection without touching the target, absent-target no-ops without cold resume, and the descendants listing's pre-order positions; the keyless ACP snapshots pin the new tool schemas in every recorded request header. +Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/host/apiproxy/tests` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. Client coverage pins the address-routed `Session.cancel()`, the InputBar's independent Send and Stop actions with the parent-offline locked-input/Send state, and the read-only-composer selector's running exception; the keyless assembled Web scenarios (`apps/web/tests/subagent-interrupt.e2e.ts`, `subagent-interrupt-ui.e2e.ts`) hold real child turns open with replay hang entries and prove the parent-offline UI-to-RPC abort path, queued Send, the parked follow-up, and the FIFO resume end to end. Tool coverage in `packages/subagent/tool-subagent-control/tests` proves direct and deep ancestor interrupts with the `parent` cause and parked queue, self/sibling/stranger rejection without touching the target, absent-target no-ops without cold resume, and the descendants listing's pre-order positions; the keyless ACP snapshots pin the new tool schemas in every recorded request header. diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md index de1556acbc..2b29895c21 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md @@ -45,4 +45,4 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回 ## Testing -`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/host/apiproxy/tests` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。客户端覆盖固定按地址路由的 `Session.cancel()`、InputBar 的独立 Send 与 Stop 操作及 parent 离线时锁定输入区和 Send 的状态,以及只读 composer selector 的运行例外;keyless 组装 Web 场景(`apps/web/tests/subagent-interrupt.e2e.ts`、`subagent-interrupt-ui.e2e.ts`)用 replay hang 条目保持真实 child 轮次打开,端到端证明 Send 入队、中断传输、中止的 `turn/end`、follow-up 暂停以及 FIFO 恢复。`packages/subagent/tool-subagent-control/tests` 中的工具覆盖证明直接与更深 ancestor 以 `parent` cause 中断并暂停队列、self/sibling/陌生调用方被拒绝且不触碰目标、目标不存在时 no-op 且不冷恢复,以及 descendants 列表的 pre-order 位置;keyless ACP 快照把新工具 schema 固定在每个已录制请求 header 中。 +`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/host/apiproxy/tests` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。客户端覆盖固定按地址路由的 `Session.cancel()`、InputBar 的独立 Send 与 Stop 操作及 parent 离线时锁定输入区和 Send 的状态,以及只读 composer selector 的运行例外;keyless 组装 Web 场景(`apps/web/tests/subagent-interrupt.e2e.ts`、`subagent-interrupt-ui.e2e.ts`)通过多条 replay hang 条目保持多个真实 child 轮次打开,端到端证明 parent 离线时从 UI 到 RPC 的中止路径、Send 入队、follow-up 暂停以及 FIFO 恢复。`packages/subagent/tool-subagent-control/tests` 中的工具覆盖证明直接与更深 ancestor 以 `parent` cause 中断并暂停队列、self/sibling/陌生调用方被拒绝且不触碰目标、目标不存在时 no-op 且不冷恢复,以及 descendants 列表的 pre-order 位置;keyless ACP 快照把新工具 schema 固定在每个已录制请求 header 中。 diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 265462bdf7..f289b062e3 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -19,7 +19,7 @@ import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, @@ -33,8 +33,11 @@ const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected. const MODE = webSnapshotMode() const LABEL = 'event-sourcing researcher' const INITIAL = 'Explain event sourcing in one sentence.' +const REARM = 'Keep working until I stop you again.' +const REARM_WAKE = 'Start that queued work now.' const FOLLOWUP = 'Now give the same explanation to a human reader.' const WAKING = 'And add one concrete example.' +const REARMED_ANSWER = 're-armed setup answer' const PARKED_ANSWER = 'parked follow-up answer' const WAKING_ANSWER = 'waking answer' @@ -47,6 +50,23 @@ async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_00 } } +/** Resolve on one exact child's next aborted turn end. */ +function waitForAbortedTurn(scaffold: WebScaffold, childId: SessionId): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + off() + reject(new Error('interrupt did not reach an aborted turn/end')) + }, 30_000) + const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => { + if (session.id !== childId || event.type !== 'turn/end') return + clearTimeout(timer) + off() + if (event.data.reason.kind === 'aborted') resolve() + else reject(new Error(`expected an aborted turn/end, got ${event.data.reason.kind}`)) + }) + }) +} + /** One text-only scripted model completion (no tool calls: real tools are mounted). */ function textCompletion(text: string): object { return { @@ -66,6 +86,8 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co let browser: Browser let page: Page let sidecarRoot: string + let rearmedReadyFile: string + let parent: Agent let childId: SessionId let tripwire: ReturnType const apiCalls: string[] = [] @@ -73,10 +95,13 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co beforeAll(async () => { sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-ui-')) const readyFile = join(sidecarRoot, 'hang-ready') - // The child claims this whole-script replacement: held turn 1, then the - // parked follow-up and waking turns. + rearmedReadyFile = join(sidecarRoot, 'hang-rearmed-ready') + // The child claims this whole-script replacement: the offline and online + // interrupt paths each hold one turn, then the parked and waking turns settle. await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([ { kind: 'hang', readyFile }, + { kind: 'hang', readyFile: rearmedReadyFile }, + textCompletion(REARMED_ANSWER), textCompletion(PARKED_ANSWER), textCompletion(WAKING_ANSWER), ])) @@ -113,8 +138,9 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) - const parent = scaffold.ctx.agents.roots()[0] - if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent') + const root = scaffold.ctx.agents.roots()[0] + if (root === undefined) throw new Error('fresh workspace did not publish its parent Agent') + parent = root // The child's first model call claims the primary override and holds. const started = await scaffold.ctx.subagents.startContinuable({ provider: 'spawn', @@ -155,7 +181,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('locks Send but keeps independent Stop when the parent is offline', async () => { + it('interrupts the live child through the parent-offline composer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline')) // Simulate a parent that went offline: the catalog delivers // parentAvailable: false while the child Activation stays live (the @@ -188,6 +214,37 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE, ) + + // Keep the continuable Activation resident after this first abort. The + // direct setup queue does not change the parent-offline UI contract: its + // input and Send remain disabled throughout the exercised browser path. + await scaffold.ctx.subagents.followup( + parent, + childId, + [{ type: 'text', text: REARM }], + { source: { kind: 'user' }, signal: new AbortController().signal }, + ) + const aborted = waitForAbortedTurn(scaffold, childId) + const interruptResponse = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/subagent.interrupt') + await stop.click() + expect(((await (await interruptResponse).json()) as { + result: { ok: boolean; value?: { accepted: boolean } } + }).result).toMatchObject({ ok: true, value: { accepted: true } }) + expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([]) + await aborted + 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. + await scaffold.ctx.subagents.followup( + parent, + childId, + [{ type: 'text', text: REARM_WAKE }], + { source: { kind: 'user' }, signal: new AbortController().signal }, + ) + await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open') + expect(scaffold.ctx.agents.get(childId)?.status).toBe('running') } finally { await page.unroute(pattern) } @@ -212,19 +269,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result) .toMatchObject({ ok: true }) - const aborted = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - off() - reject(new Error('interrupt did not reach an aborted turn/end')) - }, 30_000) - const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => { - if (session.id !== childId || event.type !== 'turn/end') return - clearTimeout(timer) - off() - if (event.data.reason.kind === 'aborted') resolve() - else reject(new Error(`expected an aborted turn/end, got ${event.data.reason.kind}`)) - }) - }) + const aborted = waitForAbortedTurn(scaffold, childId) const stop = page.getByRole('button', { name: 'Stop generating' }) expect(await stop.count()).toBe(1) const interruptResponse = page.waitForResponse(response => @@ -242,13 +287,14 @@ 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') const child = scaffold.ctx.agents.get(childId) expect(child).toBeDefined() - expect(child!.inbox.nextTurn).toHaveLength(1) - expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(child!.inbox.nextTurn).toHaveLength(2) + expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 }) // Only the waking send resumes the parked queue, FIFO, to settlement. await input.fill(WAKING) await input.press('Enter') + await expect.poll(() => page.getByText(REARMED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1) await expect.poll(() => page.getByText(PARKED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1) await expect.poll(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1) await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined() @@ -258,11 +304,11 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co && event.data.source.kind === 'user' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) - expect(userTexts).toEqual([INITIAL, FOLLOWUP, WAKING]) + expect(userTexts).toEqual([INITIAL, REARM, REARM_WAKE, FOLLOWUP, WAKING]) const turnEndKinds = loaded.events .filter(event => event.type === 'turn/end') .map(event => event.data.reason.kind) - expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed']) + expect(turnEndKinds).toEqual(['aborted', 'aborted', 'completed', 'completed', 'completed']) expect(tripwire.pageErrors).toEqual([]) }, 120_000)