fix(acp): release cancelled child waits before new root work

An ACP prompt can be waiting for descendants while its root is idle.
Cancellation aborts that wait and calls agent.cancel(), but a child's
settlement message can wake the root in the next microtask. The wait race
then returns with the root running, causing settlement to enter another
whenIdle() before checking cancelRequested. An unrelated long-running
summary consequently retains the cancelled prompt slot indefinitely.

Recheck cancellation immediately after the descendant wait, before any
new root idle wait. Cancellation stops work present at the cancellation
call and releases that prompt; child messages accepted afterward remain
autonomous work. Do not re-cancel those later turns or drain descendants,
which would either race further wakeups or close future child admission.
Document this cancellation boundary in the bilingual ACP README.

Add a deterministic regression that delivers a child message in the
microtask after agent.cancel(), gates the new root turn, and verifies the
original prompt returns cancelled while that turn is still gated. After
releasing the autonomous turn, a second ACP prompt invokes a real local
subagent activation through a tool and completes without another cancel.
Declare the test's subagent dependency and retain only its workspace
importer change in the lockfile.

Validation:
- The new focused regression failed before the fix: the prompt result was
  undefined while the late root turn remained gated.
- pnpm exec vitest run packages/acp/acp/tests/turns.spec.ts (38 passed)
- pnpm run verify-translation-pairing --write packages/acp/acp/README.md
- git diff --check
This commit is contained in:
Tianyi Cui committed 2026-10-06 00:51:28 +08:00
1 parent 45369161cb
commit 323ec4db77
7 files changed
+89 -4

No files matched your search

+2 -2
View File
@@ -27,8 +27,8 @@
en: 4ed8e37834267edb
zh: a32d5dedb4e8e919
/deepseek-ai-dsh-acp/use-this-package/protocol-contract:
en: d1bb30b0dec7c57f
zh: 836e5219e77cc83f
en: 1c967cf1f18d8c5d
zh: 45f29fecafd52c7f
/deepseek-ai-dsh-acp/understand-the-implementation:
en: ea8afe89dbdd1934
zh: 6567979f92f34afd
+1 -1
View File
@@ -73,7 +73,7 @@ One connection can run several sessions at once, each independent. The calls a c
| `session/update` | Committed assistant messages and thoughts, generic tool lifecycle, configuration changes, and context usage, serialized per session. |
| `session/request_permission` | A permission prompt with one-shot allow/reject choices; your client can answer automatically. |
A prompt remains in flight while managed descendants run and while their results trigger later root turns. Its completion includes the latest root stop reason and any assistant output delivery failure; only the original prompt turn pins the model selection captured at admission. Cancellation stops the root and releases the prompt wait promptly, leaving the session usable for later prompts. Closing the session also drains its descendants.
A prompt remains in flight while managed descendants run and while their results trigger later root turns. Its completion includes the latest root stop reason and any assistant output delivery failure; only the original prompt turn pins the model selection captured at admission. Cancellation stops root work present at that call and releases the prompt wait, leaving the session usable for later prompts; subsequent child messages may start autonomous root work. Closing the session also drains its descendants.
Session configuration offers opaque provider/model choices from the live LLM service catalog and a `reasoning_effort` selector when the exact model declares one. A prompt snapshots that selection before asynchronous image admission and pins it across every model step in that turn; a concurrent option change applies to the next turn. ACP clients are trusted controllers: stdio MCP entries authorize their absolute commands and environment, HTTP entries authorize their absolute HTTP(S) URLs and headers, and any initial connection or discovery failure rolls back the unpublished Agent. Unsupported surfaces are omitted or rejected: `session/load`, deletion, fork, additional directories, SSE or ACP-transport MCP, modes, commands, plans, terminals, client filesystem operations, and elicitation.
+1 -1
View File
@@ -73,7 +73,7 @@ kind: "package-reference"
| `session/update` | 已提交 assistant 消息与 thought、通用工具生命周期、配置变化与上下文用量,按会话串行交付。 |
| `session/request_permission` | 带一次性允许/拒绝选项的权限提示;你的客户端可以自动回答。 |
受管理后代运行期间,以及它们的结果触发根 Agent 后续轮次期间,提示词保持进行中。最终结果包含最新根轮次的停止原因与所有助手输出交付错误;只有原始提示词轮次固定使用接受时捕获的模型选择。取消会停止根 Agent 并及时释放提示词等待,会话仍可接受后续提示词。关闭会话还会释放其后代。
受管理后代运行期间,以及它们的结果触发根 Agent 后续轮次期间,提示词保持进行中。最终结果包含最新根轮次的停止原因与所有助手输出交付错误;只有原始提示词轮次固定使用接受时捕获的模型选择。取消会停止调用时已有的根 Agent 工作并释放提示词等待,会话仍可接受后续提示词;之后到达的子 Agent 消息可以启动根 Agent 的自主工作。关闭会话还会释放其后代。
会话配置从实时 LLM(大语言模型)服务目录提供不透明的提供方/模型选项,并在确切模型声明推理选项时提供 `reasoning_effort`。提示词会在异步图片准入前快照该选择,并在该轮次的每个模型步骤中固定它;并发选项变更从下一轮次开始生效。ACP 客户端是受信控制器:stdio MCP 条目授权其绝对命令与环境,HTTP 条目授权其绝对 HTTP(S) URL 与 header;初始连接或发现失败会回滚尚未发布的 Agent。不支持的界面会被省略或拒绝:`session/load`、删除、fork、附加目录、SSE(Server-Sent Events)或 ACP 传输 MCP、mode、命令、计划、终端、客户端文件系统操作与 elicitation。
+1
View File
@@ -59,6 +59,7 @@
"@deepseek-ai/dsh-session-persistence": "workspace:*",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-user-approval": "workspace:*"
+1
View File
@@ -504,6 +504,7 @@ export class AcpSession {
if (inflight.cancelRequested) break
const idleSeq = this.agent.session.seq
const children = await Promise.race([subagents?.waitForChildren(this.agent), cancelled.promise])
if (inflight.cancelRequested) break
if (!children && this.agent.status === 'idle' && this.agent.session.seq === idleSeq) break
}
} finally {
+80
View File
@@ -3,6 +3,10 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { setImmediate } from 'node:timers/promises'
import SubagentRuntime, { NO_START_CAPABILITIES } from '@deepseek-ai/dsh-subagent'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import {
errorResponse,
makeBridgeHarness,
@@ -575,6 +579,82 @@ describe('ACP prompt lifecycle', () => {
expect(messageText(harness)).toBe('waitingnext')
})
it('releases a cancelled prompt before a late child message finishes and permits another delegation', async () => {
harness = await makeBridgeHarness({ script: [
textResponse('waiting'), textResponse('late autonomous answer'),
toolCallResponse('delegate-again', 'delegate_again', {}),
textResponse('later child answer'), textResponse('later parent answer'),
] })
const ctx = harness.ctx
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'local-test', capabilities: NO_START_CAPABILITIES, inheritsParentContext: false,
prepareContinuable: () => Promise.resolve({}),
})
let delegated = false
ctx.tools.register(defineContentToolFixture({
name: 'delegate_again', description: 'Run a later child.', parameters: {},
execute: async (_args, exec) => {
const child = await ctx.subagents.startActivation({
provider: 'local-test', label: 'later child', delivery: 'caller', signal: exec.signal,
request: { parent: exec.agent, prompt: [{ type: 'text', text: 'later child task' }] },
})
const result = await child.result
delegated = result.stopReason === 'completed'
return [...result.output]
},
}))
const sessionId = await newSession(harness)
const parent = ctx.agents.get(SessionId(sessionId))!
const waiting = Promise.withResolvers<undefined>()
const children = Promise.withResolvers<boolean>()
vi.spyOn(ctx.subagents, 'waitForChildren').mockImplementationOnce(() => {
waiting.resolve(undefined)
return children.promise
})
const lateStarted = Promise.withResolvers<undefined>()
const releaseLate = Promise.withResolvers<undefined>()
const stream = harness.adapter.stream.bind(harness.adapter)
vi.spyOn(harness.adapter, 'stream').mockImplementation(async function* (options) {
if (options.messages.at(-1)?.content.some(block => block.type === 'text' && block.text === 'late child result')) {
lateStarted.resolve(undefined)
await releaseLate.promise
}
yield* stream(options)
})
const cancel = parent.cancel.bind(parent)
let delivered = false
vi.spyOn(parent, 'cancel').mockImplementation((...args) => {
cancel(...args)
if (delivered) return
delivered = true
queueMicrotask(() => {
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'late child result' }], source: { kind: 'test' } }))
children.resolve(true)
})
})
let result: { stopReason: string } | undefined
const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'delegate' }] })
.then((value) => { result = value; return value })
try {
await waiting.promise
await harness.client.cancel({ sessionId })
await lateStarted.promise
await setImmediate()
expect(result).toEqual({ stopReason: 'cancelled' })
expect(parent.status).toBe('running')
releaseLate.resolve(undefined)
await parent.whenIdle()
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'delegate again' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
expect(delegated).toBe(true)
expect(messageText(harness)).toContain('later parent answer')
} finally {
releaseLate.resolve(undefined)
await first
}
})
it.each([false, true])('waits for the later summary output and reports its delivery failure (%s)', async (fail) => {
const script: StreamChunk[][] = [textResponse('waiting')]
harness = await makeBridgeHarness({ script })
+3
View File
@@ -1084,6 +1084,9 @@ importers:
'@deepseek-ai/dsh-session-projection':
specifier: workspace:*
version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:*
version: link:../../subagent/subagent
'@deepseek-ai/dsh-token-meter':
specifier: workspace:*
version: link:../../llm/token-meter