fix(acp): retain failures without a durable turn ending
A later summary can fail while appending turn/start, before AgentLoop advances phase.turn. Its agent/error event therefore carries the previous completed turn number. ACP ignored that matching number and reported the old completed outcome. A failed turn/end append was also ignored and returned cancelled despite no cancellation. Retain every live Agent failure observed while the prompt owns queued work. A subsequently committed turn/end supersedes that live failure and provides the authoritative terminal reason. If the start/end append fails, no terminal event clears the error, so ACP rejects the prompt. Remove the unused turn-number argument from the bridge callback; numeric equality is not evidence that a failure has a durable outcome. Add deterministic regressions for later turn/start and turn/end append failures after an earlier completed turn. Both failed before this change: start failure returned end_turn, while end failure returned cancelled. Normal represented model/turn errors retain their existing error mapping, and the existing cancellation/recovery cases remain covered. Update the bilingual ACP behavior documentation. Validation: - pnpm exec vitest run packages/acp/acp/tests/turns.spec.ts -t 'rejects a later' rejected both pre-fix outcomes (2 regressions reproduced) - pnpm exec vitest run packages/acp/acp/tests (148 passed) - pnpm exec tsc -b packages/acp/acp/tsconfig.json - pnpm run verify-translation-pairing --write packages/acp/acp/README.md - git diff --check
This commit is contained in:
1 parent
7304af5738
commit
57226b392b
6 files changed
+39
-9
No files matched your search
@@ -27,8 +27,8 @@
|
||||
en: 4ed8e37834267edb
|
||||
zh: a32d5dedb4e8e919
|
||||
/deepseek-ai-dsh-acp/use-this-package/protocol-contract:
|
||||
en: 1eb1efff747cfb94
|
||||
zh: 3e18ed13b5379ad2
|
||||
en: 94621b27ba7e86b0
|
||||
zh: 4f6fb5556839893f
|
||||
/deepseek-ai-dsh-acp/understand-the-implementation:
|
||||
en: ea8afe89dbdd1934
|
||||
zh: 6567979f92f34afd
|
||||
|
||||
@@ -75,6 +75,8 @@ One connection can run several sessions at once, each independent. The calls a c
|
||||
|
||||
A prompt remains in flight while managed descendants run, while their results trigger later root turns, and while committed output drains. Root work accepted during that output drain joins the same activity interval before completion. 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.
|
||||
|
||||
A failure to append `turn/start` or `turn/end` rejects the prompt even when a preceding turn completed.
|
||||
|
||||
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.
|
||||
|
||||
-----
|
||||
|
||||
@@ -75,6 +75,8 @@ kind: "package-reference"
|
||||
|
||||
受管理后代运行期间、它们的结果触发根 Agent 后续轮次期间,以及已提交输出仍在交付期间,提示词保持进行中。输出交付期间接受的根 Agent 工作会加入同一活动区间,并在该区间完成后结算。最终结果包含最新根轮次的停止原因与所有助手输出交付错误;只有原始提示词轮次固定使用接受时捕获的模型选择。取消会停止调用时已有的根 Agent 工作并释放提示词等待,会话仍可接受后续提示词;之后到达的子 Agent 消息可以启动根 Agent 的自主工作。关闭会话还会释放其后代。
|
||||
|
||||
即使前一轮已经完成,追加 `turn/start` 或 `turn/end` 失败仍会使提示词请求失败。
|
||||
|
||||
会话配置从实时 LLM(大语言模型)服务目录提供不透明的提供方/模型选项,并在确切模型声明推理选项时提供 `reasoning_effort`。提示词会在异步图片准入前快照该选择,并在该轮次的每个模型步骤中固定它;并发选项变更从下一轮次开始生效。ACP 客户端是受信控制器:stdio MCP 条目授权其绝对命令与环境,HTTP 条目授权其绝对 HTTP(S) URL 与 header;初始连接或发现失败会回滚尚未发布的 Agent。不支持的界面会被省略或拒绝:`session/load`、删除、fork、附加目录、SSE(Server-Sent Events)或 ACP 传输 MCP、mode、命令、计划、终端、客户端文件系统操作与 elicitation。
|
||||
|
||||
-----
|
||||
|
||||
@@ -141,8 +141,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
ownedRecord(agent)?.onInboxClaimed(message, turn)
|
||||
})
|
||||
|
||||
ctx.on('agent/error', ({ agent, turn, error }) => {
|
||||
ownedRecord(agent)?.onAgentError(turn, error)
|
||||
ctx.on('agent/error', ({ agent, error }) => {
|
||||
ownedRecord(agent)?.onAgentError(error)
|
||||
})
|
||||
|
||||
ctx.on('llm/adapters-updated', () => {
|
||||
|
||||
@@ -389,6 +389,7 @@ export class AcpSession {
|
||||
const inflight = this.inflight
|
||||
if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
|
||||
inflight.endReason = event.data.reason
|
||||
inflight.agentError = undefined
|
||||
}
|
||||
if (event.type === 'turn/end') this.modelControl.releaseTurn(event.data.turn)
|
||||
}
|
||||
@@ -408,15 +409,13 @@ export class AcpSession {
|
||||
|
||||
/**
|
||||
* Correlate an Agent interval failure with the active ACP prompt.
|
||||
* @param turn - failed turn number.
|
||||
* @param error - original same-process failure.
|
||||
*/
|
||||
onAgentError(turn: number, error: unknown): void {
|
||||
onAgentError(error: unknown): void {
|
||||
const inflight = this.inflight
|
||||
if (inflight === undefined || !inflight.messageQueued) return
|
||||
// AgentLoop balances an in-turn failure with durable turn/end; settlement
|
||||
// reads that exact error reason. This slot records interval failures outside it.
|
||||
if (inflight.turn === turn) return
|
||||
// A later committed turn/end supersedes this live failure. Failed turn
|
||||
// start/end appends have no terminal record and keep the failure observable.
|
||||
inflight.agentError = new Error(errorChain(error))
|
||||
this.settleAfterQuiescence(inflight)
|
||||
}
|
||||
|
||||
@@ -204,6 +204,33 @@ describe('ACP prompt lifecycle', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each(['turn/start', 'turn/end'] as const)('rejects a later %s append failure after an earlier completed turn', async (failedEvent) => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('waiting'), textResponse('summary')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const parent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
const append = parent.session.append.bind(parent.session)
|
||||
const appendSpy = vi.spyOn(parent.session, 'append')
|
||||
const failures: number[] = []
|
||||
harness.ctx.on('agent/error', ({ turn }) => { failures.push(turn) })
|
||||
const waitForChildren = vi.fn().mockImplementationOnce(() => {
|
||||
appendSpy.mockImplementation(((type: string, ...rest: never[]) => {
|
||||
if (type === failedEvent) throw new Error(`summary ${failedEvent} unavailable`)
|
||||
return (append as (...args: never[]) => unknown)(type as never, ...rest)
|
||||
}) as never)
|
||||
parent.followup(createUserMessage({ content: [{ type: 'text', text: 'child result' }], source: { kind: 'test' } }))
|
||||
return Promise.resolve(true)
|
||||
}).mockResolvedValue(false)
|
||||
harness.ctx.provide('subagents', { waitForChildren, drainDescendants: vi.fn() } as never)
|
||||
try {
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'delegate' }] }))
|
||||
.rejects.toThrow(`summary ${failedEvent} unavailable`)
|
||||
expect(failures).toEqual([failedEvent === 'turn/start' ? 1 : 2])
|
||||
expect(harness.adapter.requests).toHaveLength(failedEvent === 'turn/start' ? 1 : 2)
|
||||
} finally {
|
||||
appendSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('settles even when an earlier turn observer throws', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
|
||||
harness.ctx.on('session/event', (_session, event) => {
|
||||
|
||||
Reference in new issue
Block a user