refactor(agent): complete inbox lifecycle migration

This commit is contained in:
_Kerman
2026-08-03 12:25:33 +08:00
parent dc1d542092
commit 49e90695cc
214 changed files with 6019 additions and 4235 deletions

View File

@@ -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: 162e88265762652b5629c0e04786311c5b62583f
README.zh.md: 3fd914ecc5bf7b0e7069ba933b09db0a4a3cdfcd
README.md: 4632c00398d0870d3682d8c6241e26de006ad233
README.zh.md: 1bfd16def64f7973ae4e517dfc96e32478b49aa2

View File

@@ -3,7 +3,31 @@
* @module @deepseek-ai/dsh-acp/codec
*/
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
/**
* Map a harness turn ending to ACP's terminal reason vocabulary.
* @param reason - harness turn outcome.
* @returns the closest legal ACP stop reason.
*/
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
switch (reason.kind) {
case 'completed':
return 'end_turn'
case 'max-tokens':
return 'max_tokens'
case 'aborted':
case 'interrupted':
return 'cancelled'
case 'blocked':
case 'error':
return 'end_turn'
/* v8 ignore next 2 -- TurnEndReason is closed and every member is handled above */
default:
return 'end_turn'
}
}
/**
* Flatten an ACP prompt's baseline blocks to text. Text blocks concatenate

View File

@@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto'
import { isAbsolute } from 'node:path'
import { Readable, Writable } from 'node:stream'
import Schema from 'schemastery'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
import {
AgentSideConnection,
ndJsonStream,
@@ -30,18 +30,32 @@ import {
type PromptRequest,
type PromptResponse,
type SessionNotification,
type StopReason,
type Stream,
} from '@agentclientprotocol/sdk'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
// Side-effect type import: declaration-merges the approval waterfall answered below.
import type {} from '@deepseek-ai/dsh-user-approval'
import { acpPromptToText, promptHasUnsupportedContent } from './codec.ts'
import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from './codec.ts'
export const name = 'acp'
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
export const inject = ['agents']
/**
* The single continuable-subagent teardown the bridge needs. Declared
* structurally so this package does not depend on the subagent seam for one
* shutdown hook; an absent service means nothing continuable was materialized.
*/
interface ContinuableDrain {
/**
* Close admission below exact host-owned parents, then dispose only their
* continuable descendants child-first.
*/
drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
}
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
@@ -74,7 +88,10 @@ interface SessionRecord {
dispose: () => Promise<void>
/** In-flight prompt and its captured turn number for exact settlement. */
inflight: {
cancelled: boolean
resolve: (reason: StopReason) => void
reject: (error: Error) => void
messageId: string
turn: number | undefined
} | undefined
}
@@ -116,10 +133,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
})
}
const cancelPrompt = (record: SessionRecord): void => {
const settlePrompt = (record: SessionRecord, reason: StopReason): void => {
const inflight = record.inflight
if (inflight === undefined) return
inflight.cancelled = true
record.inflight = undefined
inflight.resolve(reason)
}
const rejectFromError = (
inflight: NonNullable<SessionRecord['inflight']>,
reason: Extract<TurnEndReason, { kind: 'error' }>,
): void => {
inflight.reject(internalError(`turn failed: ${errorChain(reason.error)}`))
}
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
@@ -128,21 +153,48 @@ export function apply(ctx: Context, config: AcpConfig): void {
ctx.on('session/event', (session, event: SessionEvent) => {
const record = sessions.get(session.header.id)
if (record === undefined || record.agent.session !== session) return
if (record.inflight !== undefined && event.type === 'assistant/message') {
for (const block of event.data.message.content) {
if (block.type === 'text' && block.text.length > 0) {
notify({
sessionId: record.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: block.text },
},
})
try {
if (event.type === 'assistant/message') {
for (const block of event.data.message.content) {
if (block.type === 'text' && block.text.length > 0) {
notify({
sessionId: record.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: block.text },
},
})
}
}
}
} finally {
const inflight = record.inflight
if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
if (event.data.reason.kind === 'error') {
record.inflight = undefined
rejectFromError(inflight, event.data.reason)
} else {
record.inflight = undefined
inflight.resolve(turnEndToStopReason(event.data.reason))
}
}
}
})
ctx.on('agent/inbox/claimed', (agent, { message, turn }) => {
const record = ownedRecord(agent)
const inflight = record?.inflight
if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn
})
ctx.on('agent/error', (agent, turn, _step, error) => {
const record = ownedRecord(agent)
const inflight = record?.inflight
if (record === undefined || inflight === undefined || inflight.turn === turn) return
record.inflight = undefined
inflight.reject(internalError(`turn failed: ${errorChain(error)}`))
})
// Permission requests are a machine policy channel for ACP clients such as
// dsh-subagent-acp. The bridge offers one-shot choices only and never infers a
// durable grant from an unknown client response.
@@ -223,22 +275,44 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (ctx.agents.get(record.agent.id) !== record.agent) {
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
}
const inflight: NonNullable<SessionRecord['inflight']> = { cancelled: false }
record.inflight = inflight
try {
record.agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
await record.agent.whenIdle()
return { stopReason: inflight.cancelled ? 'cancelled' : 'end_turn' }
} finally {
record.inflight = undefined
}
const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
const stopReason = await new Promise<StopReason>((resolve, reject) => {
// Arm the slot before followup() so a listener-driven synchronous
// turn cannot slip past correlation; a synchronous followup()
// failure (invalid input) must free the slot again or the session
// would reject every later prompt as already in flight.
const inflight: NonNullable<SessionRecord['inflight']> = {
resolve, reject, messageId: message.id, turn: undefined,
}
record.inflight = inflight
try {
record.agent.followup(message)
// The machine's send() contains listener failures and accepts
// any typed input; this guards a future synchronous throw so the
// slot cannot wedge.
/* v8 ignore start -- future-proofing guard, see above */
} catch (error: unknown) {
record.inflight = undefined
const detail = error instanceof Error ? error.message : String(error)
throw internalError(`prompt was not queued: ${detail}`)
}
/* v8 ignore stop */
// A turnless slot settles only at quiescence: admission discarded
// the prompt before it could open a turn.
void record.agent.whenIdle().then(() => {
if (record.inflight !== inflight) return
record.inflight = undefined
inflight.resolve('cancelled')
})
})
return { stopReason }
},
cancel(params: CancelNotification): Promise<void> {
const record = sessions.get(SessionId(params.sessionId))
if (record === undefined) return Promise.resolve()
cancelPrompt(record)
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
return Promise.resolve()
},
}
@@ -257,10 +331,45 @@ export function apply(ctx: Context, config: AcpConfig): void {
closed = true
const records = [...sessions.values()]
sessions.clear()
quiescing = Promise.all(records.map(async (record) => {
cancelPrompt(record)
await record.dispose()
})).then(() => {})
// Stop the bridge's own work before any await: a descendant drain can block
// on persistence or scoped cleanup, and the top-level agents must not keep
// running model and tool calls for its whole duration.
for (const record of records) {
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
}
quiescing = (async () => {
// Continuable subagents outlive the turn that started them, and their
// Activations own descendant teardown. Drain only these sessions' forests
// child-first BEFORE disposing the top-level agents, so no descendant is
// left holding a runtime its owner already released and another frontend
// sharing this Context remains live.
// Read the one teardown method structurally: the bridge needs no other
// part of the subagent seam, so it does not depend on that package.
const subagents = ctx.get('subagents') as ContinuableDrain | undefined
if (subagents !== undefined) {
try {
await subagents.drainContinuableDescendants(records.map(record => record.agent))
} catch (error: unknown) {
logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`)
}
}
const disposals = await Promise.allSettled(records.map(record => record.dispose()))
const failures: unknown[] = []
for (const result of disposals) {
if (result.status === 'rejected') failures.push(result.reason as unknown)
}
if (failures.length > 0) {
// The production consumer logs this AggregateError through `String`,
// which renders only its message. Embed every per-session diagnostic,
// including nested causes and aggregate members, in that message.
const detail = failures.map(failure => errorChain(failure)).join('; ')
throw new AggregateError(
failures,
`ACP agent teardown failed for ${failures.length} session(s): ${detail}`,
)
}
})()
return quiescing
}

View File

@@ -1,20 +1,24 @@
import { describe, expect, it } from 'vitest'
import { acpPromptToText, promptHasUnsupportedContent } from '../src/codec.ts'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import { acpPromptToText, turnEndToStopReason } from '../src/codec.ts'
describe('ACP automation codec', () => {
it('flattens baseline blocks and rejects everything richer', () => {
expect(acpPromptToText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab')
expect(acpPromptToText([
{ type: 'text', text: 'see' },
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
])).toBe('see\n[resource_link name="x" uri="file:///x"]\n')
expect(acpPromptToText([{ type: 'image', data: '', mimeType: 'image/png' }])).toBe('')
expect(promptHasUnsupportedContent([
{ type: 'text', text: 'ok' },
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
])).toBe(false)
expect(promptHasUnsupportedContent([
{ type: 'image', data: '', mimeType: 'image/png' },
])).toBe(true)
describe('ACP codec', () => {
it.each([
[{ kind: 'completed' }, 'end_turn'],
[{ kind: 'max-tokens' }, 'max_tokens'],
[{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
[{ kind: 'interrupted' }, 'cancelled'],
[{ kind: 'blocked' }, 'end_turn'],
[{ kind: 'error', error: 'failed' }, 'end_turn'],
] satisfies Array<[TurnEndReason, string]>)('maps %o to %s', (reason, expected) => {
expect(turnEndToStopReason(reason)).toBe(expected)
})
it('drops unsupported blocks from baseline text conversion', () => {
expect(acpPromptToText([{
type: 'image',
data: '',
mimeType: 'image/png',
}])).toBe('')
})
})

View File

@@ -67,7 +67,15 @@ describe('ACP connection ownership', () => {
const agent = harness.ctx.agents.get(SessionId(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await vi.waitFor(() => { expect(agent.status).toBe('running') })
harness.ctx.on('agent/cancel-requested', () => { order.push('parent cancelled') })
const cancel = agent.cancel.bind(agent)
let cancelObserved = false
vi.spyOn(agent, 'cancel').mockImplementation((...args) => {
if (!cancelObserved) {
cancelObserved = true
order.push('parent cancelled')
}
cancel(...args)
})
const disposal = harness.acpFiber.dispose()
// A drain can block on persistence, so the bridge's own turn must already be

View File

@@ -31,28 +31,28 @@ describe('ACP prompt lifecycle', () => {
harness = undefined
})
it('settles after a max-token turn without losing its committed text', async () => {
it('maps a max-token turn without losing its committed text', async () => {
harness = await makeBridgeHarness({ script: [maxTokensResponse('cut off')] })
const sessionId = await newSession(harness)
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(result.stopReason).toBe('end_turn')
expect(result.stopReason).toBe('max_tokens')
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
})
it('settles after a failed turn and never publishes its partial chunks', async () => {
it('rejects a failed turn and never publishes its partial chunks', async () => {
harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
.rejects.toThrow(/turn failed: provider boom/)
expect(messageText(harness)).toBe('')
})
it('settles after an ordinary plugin failure', async () => {
it('rejects an ordinary plugin failure through the same prompt boundary', async () => {
harness = await makeBridgeHarness({ script: [textResponse('must not run')] })
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
.rejects.toThrow(/turn failed: plugin pre-step failed/)
})
it('settles even when an earlier turn observer throws', async () => {
@@ -221,13 +221,13 @@ describe('ACP prompt lifecycle', () => {
await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') })
})
it('a failed turn with no retry settles at quiescence', async () => {
it('a failed turn with no retry still rejects', async () => {
harness = await makeBridgeHarness({ script: [errorResponse('terminal boom')] })
let offered = 0
harness.ctx.on('agent/request-error', async () => { offered += 1 })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
.rejects.toThrow(/turn failed: terminal boom/)
expect(offered).toBe(1)
})
@@ -238,7 +238,7 @@ describe('ACP prompt lifecycle', () => {
}))
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
.resolves.toEqual({ stopReason: 'cancelled' })
// The rejected prompt opened no turn and streamed nothing.
expect(messageText(harness)).toBe('')
})
@@ -249,6 +249,6 @@ describe('ACP prompt lifecycle', () => {
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
.rejects.toThrow(/turn failed: pre-step exploded/)
})
})