test(web): exercise interrupt with parent offline
This commit is contained in:
+2
-2
@@ -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 中。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user