test(web): exercise interrupt with parent offline

This commit is contained in:
Tianyi Cui
2026-08-08 18:25:20 +08:00
parent 5f755d5717
commit ce16cf1b3b
4 changed files with 73 additions and 27 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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.md: 79397ac58d9bc1a117053f05f1a5b7971c48cfbb
2026-08-06-continuable-subagent-interrupt.zh.md: de1556acbc182e79e2afa8b7f587bb9fcc0d08e0 2026-08-06-continuable-subagent-interrupt.zh.md: 2b29895c21bf2290b750bdce6979dbe2d76635a7
@@ -45,4 +45,4 @@ The model-facing `interrupt_agent(agent_id)` tool in `dsh-tool-subagent-control`
## Testing ## 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.
@@ -45,4 +45,4 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回
## Testing ## 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 中。
+69 -23
View File
@@ -19,7 +19,7 @@ import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright' import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' 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 { import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
@@ -33,8 +33,11 @@ const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.
const MODE = webSnapshotMode() const MODE = webSnapshotMode()
const LABEL = 'event-sourcing researcher' const LABEL = 'event-sourcing researcher'
const INITIAL = 'Explain event sourcing in one sentence.' 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 FOLLOWUP = 'Now give the same explanation to a human reader.'
const WAKING = 'And add one concrete example.' const WAKING = 'And add one concrete example.'
const REARMED_ANSWER = 're-armed setup answer'
const PARKED_ANSWER = 'parked follow-up answer' const PARKED_ANSWER = 'parked follow-up answer'
const WAKING_ANSWER = 'waking 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<void> {
return new Promise<void>((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). */ /** One text-only scripted model completion (no tool calls: real tools are mounted). */
function textCompletion(text: string): object { function textCompletion(text: string): object {
return { return {
@@ -66,6 +86,8 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
let browser: Browser let browser: Browser
let page: Page let page: Page
let sidecarRoot: string let sidecarRoot: string
let rearmedReadyFile: string
let parent: Agent
let childId: SessionId let childId: SessionId
let tripwire: ReturnType<typeof watchConsole> let tripwire: ReturnType<typeof watchConsole>
const apiCalls: string[] = [] const apiCalls: string[] = []
@@ -73,10 +95,13 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
beforeAll(async () => { beforeAll(async () => {
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-ui-')) sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-ui-'))
const readyFile = join(sidecarRoot, 'hang-ready') const readyFile = join(sidecarRoot, 'hang-ready')
// The child claims this whole-script replacement: held turn 1, then the rearmedReadyFile = join(sidecarRoot, 'hang-rearmed-ready')
// parked follow-up and waking turns. // 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([ await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([
{ kind: 'hang', readyFile }, { kind: 'hang', readyFile },
{ kind: 'hang', readyFile: rearmedReadyFile },
textCompletion(REARMED_ANSWER),
textCompletion(PARKED_ANSWER), textCompletion(PARKED_ANSWER),
textCompletion(WAKING_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 page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd) await connectFreshWorkspace(page, scaffold.workspaceCwd)
const parent = scaffold.ctx.agents.roots()[0] const root = scaffold.ctx.agents.roots()[0]
if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent') 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. // The child's first model call claims the primary override and holds.
const started = await scaffold.ctx.subagents.startContinuable({ const started = await scaffold.ctx.subagents.startContinuable({
provider: 'spawn', 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') 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')) onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline'))
// Simulate a parent that went offline: the catalog delivers // Simulate a parent that went offline: the catalog delivers
// parentAvailable: false while the child Activation stays live (the // 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), await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
MODE, 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 { } finally {
await page.unroute(pattern) 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) expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
.toMatchObject({ ok: true }) .toMatchObject({ ok: true })
const aborted = new Promise<void>((resolve, reject) => { const aborted = waitForAbortedTurn(scaffold, childId)
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 stop = page.getByRole('button', { name: 'Stop generating' }) const stop = page.getByRole('button', { name: 'Stop generating' })
expect(await stop.count()).toBe(1) expect(await stop.count()).toBe(1)
const interruptResponse = page.waitForResponse(response => 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') await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
const child = scaffold.ctx.agents.get(childId) const child = scaffold.ctx.agents.get(childId)
expect(child).toBeDefined() expect(child).toBeDefined()
expect(child!.inbox.nextTurn).toHaveLength(1) expect(child!.inbox.nextTurn).toHaveLength(2)
expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 }) await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 })
// Only the waking send resumes the parked queue, FIFO, to settlement. // Only the waking send resumes the parked queue, FIFO, to settlement.
await input.fill(WAKING) await input.fill(WAKING)
await input.press('Enter') 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(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(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined() 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.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) ? 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 const turnEndKinds = loaded.events
.filter(event => event.type === 'turn/end') .filter(event => event.type === 'turn/end')
.map(event => event.data.reason.kind) .map(event => event.data.reason.kind)
expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed']) expect(turnEndKinds).toEqual(['aborted', 'aborted', 'completed', 'completed', 'completed'])
expect(tripwire.pageErrors).toEqual([]) expect(tripwire.pageErrors).toEqual([])
}, 120_000) }, 120_000)