fix(acp): isolate prompt admission from agent work
This commit is contained in:
@@ -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 packages/acp/acp/README.md
|
||||
README.md: 40d4b2df18f8102a352d8a8eb438e88da7fe720c
|
||||
README.zh.md: 57b7e5a3f987861cfe0c5453f5d5a26d565f77ed
|
||||
README.md: aaabb0c824e12c250851985e92c0473f147e8efa
|
||||
README.zh.md: e722dbf06404f453dc746c7daf61d3e7b5b68fc2
|
||||
|
||||
@@ -24,8 +24,8 @@ Both fields are optional so another agent/request listener may supply the target
|
||||
| `initialize` | Negotiates the supported version. Image prompts are advertised only when a durable attachment store is mounted and the configured exact provider/model resolves with explicit image input; audio and embedded context stay false. No session, editor, terminal, filesystem, or MCP capability is advertised. |
|
||||
| `authenticate` | No-op because the server advertises no authentication methods. |
|
||||
| `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. |
|
||||
| `session/prompt` | Preserves ordered text and supported inline image blocks, renders resource links as bracketed textual references, and rejects audio, embedded resources, malformed/empty input, or an image when capability was not advertised. It validates the whole image batch and rechecks the session's latest exact route before any save, commits every image before the user event, permits one in-flight request per session, and waits for admission, whole-agent idle, and ordered output delivery. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. |
|
||||
| `session/cancel` | Cancels only the addressed agent and marks any already-started admission so the pending prompt waits for it to quiesce, publishes no late user message, and settles as `cancelled`; unknown ids are no-ops. |
|
||||
| `session/prompt` | Preserves ordered text and supported inline image blocks, renders resource links as bracketed textual references, and rejects audio, embedded resources, malformed/empty input, or an image when capability was not advertised. It validates the whole image batch and rechecks the session's latest exact route before any save, commits every image before the user event, permits one in-flight request per session, and waits for admission plus, once queued, whole-Agent idle and ordered output delivery. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. |
|
||||
| `session/cancel` | Marks and aborts any in-progress admission without cancelling or waiting for unrelated Agent work; once this prompt has entered the Agent inbox, it cancels the addressed Agent and waits for the owned interval to quiesce. No late user message is published and the prompt settles as `cancelled`. With no in-flight prompt it cancels autonomous work; unknown ids are no-ops. |
|
||||
| `session/update` | Emits one `agent_message_chunk` per non-empty text or image block in a committed `assistant/message`, preserving order. Images are re-read and integrity-verified before inline base64 delivery. Raw deltas and non-message events are omitted. |
|
||||
| `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. |
|
||||
|
||||
@@ -37,7 +37,7 @@ Committed-message output intentionally trades token-by-token latency for a clean
|
||||
|
||||
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, cancels and quiesces prompt admission, agent activity, and ordered output delivery, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
|
||||
|
||||
ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit turn endings therefore do not become prompt-level ACP stop reasons (they settle as `end_turn`); a model error on the correlated turn rejects the prompt immediately.
|
||||
ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. The operation interval starts when the prompt enters the Agent inbox and ends after admission, whole-Agent idle, and ordered output delivery all quiesce; failures from unrelated Agent work before that inbox receipt are not attributed to the prompt. Committed assistant messages stream across the owned interval, and steering or injected work may contribute before idle. Settlement precedence is explicit cancellation, output-delivery failure, interval-wide Agent failure, then the correlated turn ending. Token-limit endings settle as `end_turn`; a correlated model error rejects only at the same quiescence boundary.
|
||||
|
||||
## Running
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
| `initialize` | 协商受支持的版本。只有挂载持久附件存储,且配置的确切提供方/模型解析后明确支持图片输入时,才公布图片提示词能力;音频与嵌入上下文保持 false。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
|
||||
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
|
||||
| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
|
||||
| `session/prompt` | 保留文本与受支持内联图片块的顺序,将资源链接渲染为带方括号的文本引用,并拒绝音频、嵌入资源、格式错误/空输入,或在未公布能力时提交图片。它会先校验完整图片批次并重新检查会话的最新确切路由,再保存任一成员;在用户事件前提交全部图片;每个会话只允许一个正在处理的请求,并等待准入、整个 agent 空闲和有序输出交付全部停稳。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 |
|
||||
| `session/cancel` | 仅取消指定的 agent,并标记已经启动的准入工作,使待处理提示词等待其停稳、不发布迟到的用户消息,随后以 `cancelled` 结算;未知 id 为空操作。 |
|
||||
| `session/prompt` | 保留文本与受支持内联图片块的顺序,将资源链接渲染为带方括号的文本引用,并拒绝音频、嵌入资源、格式错误/空输入,或在未公布能力时提交图片。它会先校验完整图片批次并重新检查会话的最新确切路由,再保存任一成员;在用户事件前提交全部图片;每个会话只允许一个正在处理的请求,并等待准入,以及消息入队后的整个 Agent 空闲和有序输出交付全部停稳。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 |
|
||||
| `session/cancel` | 标记并中止正在进行的准入,但不会取消或等待同一 Agent 上无关的既有工作;该提示词进入 Agent inbox 后,才会取消指定的 Agent 并等待自有区间停稳。不发布迟到的用户消息,提示词以 `cancelled` 结算。没有进行中的提示词时会取消自主工作;未知 id 为空操作。 |
|
||||
| `session/update` | 为已提交 `assistant/message` 中的每个非空文本或图片块发出一个 `agent_message_chunk`,并保留顺序。图片在以内联 base64 交付前会重新读取并校验完整性。省略原始增量和非消息事件。 |
|
||||
| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,取消并等待提示词准入、agent 活动和有序输出交付全部停稳,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入空闲状态前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即拒绝该提示词。
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。操作区间从提示词进入 Agent inbox 开始,在准入、整个 Agent 空闲和有序输出交付全部停稳后结束;inbox 接收前无关 Agent 工作的失败不会归因给该提示词。已提交的 assistant 消息会在自有区间内流式输出,Agent 进入空闲状态前发生的 steering(中途引导)或注入工作也可能参与其中。结算优先级依次为显式取消、输出交付失败、区间内 Agent 失败、关联轮次结束。因 token 上限而结束时以 `end_turn` 结算;关联模型错误也只会在同一个完全停稳边界拒绝提示词。
|
||||
|
||||
## 运行
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ interface SessionRecord {
|
||||
reject: (error: Error) => void
|
||||
/** Set only after rich-content admission succeeds and the message is built. */
|
||||
messageId: string | undefined
|
||||
/** Whether this prompt has entered the Agent's durable inbox interval. */
|
||||
messageQueued: boolean
|
||||
turn: number | undefined
|
||||
/** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */
|
||||
endReason: TurnEndReason | undefined
|
||||
@@ -106,7 +108,7 @@ interface SessionRecord {
|
||||
settlementStarted: boolean
|
||||
/** Conversion failure for committed output owned by this prompt's turn. */
|
||||
outputError: Error | undefined
|
||||
/** Failure before a correlated turn exists. */
|
||||
/** Interval-wide failure outside the correlated turn. */
|
||||
agentError: Error | undefined
|
||||
} | undefined
|
||||
}
|
||||
@@ -172,10 +174,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
inflight.settlementStarted = true
|
||||
void (async () => {
|
||||
await inflight.admissionDone
|
||||
await record.agent.whenIdle()
|
||||
// session/event enqueues synchronously before the agent becomes idle;
|
||||
// reading the live tail here includes every committed output task.
|
||||
await record.outputTail
|
||||
if (inflight.messageQueued) {
|
||||
await record.agent.whenIdle()
|
||||
// session/event enqueues synchronously before the agent becomes idle;
|
||||
// reading the live tail here includes every committed output task.
|
||||
await record.outputTail
|
||||
}
|
||||
/* v8 ignore next -- this prompt owns the slot until this exact settlement clears it. */
|
||||
if (record.inflight !== inflight) return
|
||||
record.inflight = undefined
|
||||
@@ -202,7 +206,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
|
||||
}
|
||||
})()
|
||||
/* v8 ignore start -- admissionDone only resolves, whenIdle is a quiescence gate, and outputTail contains its own failures. */
|
||||
/* v8 ignore start -- admissionDone only resolves, and the queued path's idle/output gates contain their own failures. */
|
||||
.catch((error: unknown) => {
|
||||
if (record.inflight !== inflight) return
|
||||
record.inflight = undefined
|
||||
@@ -256,7 +260,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
ctx.on('agent/error', ({ agent, turn, error }) => {
|
||||
const record = ownedRecord(agent)
|
||||
const inflight = record?.inflight
|
||||
if (record === undefined || inflight === undefined || inflight.turn === turn) return
|
||||
if (record === undefined || inflight === undefined || !inflight.messageQueued || inflight.turn === turn) return
|
||||
inflight.agentError = new Error(errorChain(error))
|
||||
settleAfterQuiescence(record, inflight)
|
||||
})
|
||||
@@ -341,6 +345,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
resolve: completion.resolve,
|
||||
reject: completion.reject,
|
||||
messageId: undefined,
|
||||
messageQueued: false,
|
||||
turn: undefined,
|
||||
endReason: undefined,
|
||||
admissionDone: admission.promise,
|
||||
@@ -379,7 +384,15 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
}
|
||||
const message = createUserMessage({ content, source: { kind: 'user' } })
|
||||
inflight.messageId = message.id
|
||||
record.agent.followup(message)
|
||||
inflight.messageQueued = true
|
||||
try {
|
||||
record.agent.followup(message)
|
||||
} catch (error: unknown) {
|
||||
// The typed same-process seam may fail synchronously before durable
|
||||
// inbox receipt; restore the pre-operation boundary for mapping.
|
||||
inflight.messageQueued = false
|
||||
throw error
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
admissionFailed = true
|
||||
admissionFailure = error
|
||||
@@ -418,7 +431,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
inflight.admissionController.abort(new Error('ACP prompt cancelled'))
|
||||
settleAfterQuiescence(record, inflight)
|
||||
}
|
||||
record.agent.cancel({ kind: 'user' })
|
||||
// Admission is not Agent work. Preserve unrelated producers until this
|
||||
// prompt has entered the durable inbox; without a prompt, cancellation
|
||||
// continues to target autonomous work on the addressed Agent.
|
||||
if (inflight === undefined || inflight.messageQueued) record.agent.cancel({ kind: 'user' })
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -287,6 +287,70 @@ describe('ACP prompt lifecycle', () => {
|
||||
expect(events.some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not cancel unrelated Agent work while its prompt is still in admission', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true, script: ['hang'] })
|
||||
const validationStarted = Promise.withResolvers<undefined>()
|
||||
const releaseValidation = Promise.withResolvers<undefined>()
|
||||
harness.attachments!.beforeValidate = () => {
|
||||
validationStarted.resolve(undefined)
|
||||
return releaseValidation.promise
|
||||
}
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'unrelated work' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
await vi.waitFor(() => { expect(harness!.adapter.requests).toHaveLength(1) })
|
||||
|
||||
const prompt = harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
|
||||
})
|
||||
await validationStarted.promise
|
||||
await harness.client.cancel({ sessionId })
|
||||
|
||||
expect(harness.adapter.requests[0]?.signal?.aborted).toBe(false)
|
||||
releaseValidation.resolve(undefined)
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
expect(agent.status).toBe('running')
|
||||
agent.cancel({ kind: 'hook', reason: 'test cleanup' })
|
||||
await agent.whenIdle()
|
||||
})
|
||||
|
||||
it('does not attribute an unrelated Agent failure during prompt admission', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('answer')] })
|
||||
const validationStarted = Promise.withResolvers<undefined>()
|
||||
const releaseValidation = Promise.withResolvers<undefined>()
|
||||
harness.attachments!.beforeValidate = () => {
|
||||
validationStarted.resolve(undefined)
|
||||
return releaseValidation.promise
|
||||
}
|
||||
let failUnrelatedWork = true
|
||||
harness.ctx.on('agent/pre-step', (_payload, next) => {
|
||||
if (!failUnrelatedWork) return next()
|
||||
failUnrelatedWork = false
|
||||
throw new Error('unrelated pre-step failure')
|
||||
})
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
const prompt = harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
|
||||
})
|
||||
await validationStarted.promise
|
||||
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'unrelated work' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
await agent.whenIdle()
|
||||
releaseValidation.resolve(undefined)
|
||||
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' })
|
||||
expect(messageText(harness)).toBe('answer')
|
||||
})
|
||||
|
||||
it('does not queue admitted content into an agent retired during storage', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true, script: [] })
|
||||
const validationStarted = Promise.withResolvers<undefined>()
|
||||
|
||||
Reference in New Issue
Block a user