Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/fetch-carrier.spec.ts # packages/llm/llm-deepseek/src/adapter.ts # packages/llm/llm-deepseek/tests/adapter.spec.ts # packages/llm/llm-deepseek/tests/serialize.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm-pi-ai/src/index.ts # packages/llm/llm-pi-ai/tests/adapter.spec.ts # packages/llm/llm/src/types.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/src/index.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
@@ -19,7 +19,6 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
return 'max_tokens'
|
||||
case 'aborted':
|
||||
case 'disposed':
|
||||
case 'rejected':
|
||||
case 'interrupted':
|
||||
return 'cancelled'
|
||||
case 'error':
|
||||
|
||||
@@ -77,6 +77,12 @@ interface SessionRecord {
|
||||
resolve: (reason: StopReason) => void
|
||||
reject: (error: Error) => void
|
||||
turn: number | undefined
|
||||
/**
|
||||
* A failed turn's terminal reason, held until quiescence: a retry action
|
||||
* closes the failed turn and opens a successor that adopts the prompt, so
|
||||
* rejecting at `turn/end` would race the recovery.
|
||||
*/
|
||||
pendingError: Extract<TurnEndReason, { kind: 'error' }> | undefined
|
||||
} | undefined
|
||||
}
|
||||
|
||||
@@ -125,15 +131,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
inflight.resolve(reason)
|
||||
}
|
||||
|
||||
const settleFromTurnEnd = (
|
||||
const rejectFromError = (
|
||||
inflight: NonNullable<SessionRecord['inflight']>,
|
||||
reason: TurnEndReason,
|
||||
reason: Extract<TurnEndReason, { kind: 'error' }>,
|
||||
): void => {
|
||||
if (reason.kind === 'error') {
|
||||
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
|
||||
return
|
||||
}
|
||||
inflight.resolve(turnEndToStopReason(reason))
|
||||
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
|
||||
}
|
||||
|
||||
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
|
||||
@@ -173,10 +175,22 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
if (inflight.turn === undefined && event.data.trigger.kind === 'message'
|
||||
&& event.data.trigger.source.kind === 'user') {
|
||||
inflight.turn = event.data.turn
|
||||
} else if (inflight.pendingError !== undefined && event.data.trigger.kind === 'retry') {
|
||||
// A recovery policy opened a retry turn on the failed history: the
|
||||
// prompt rides it instead of rejecting on the failed turn's end.
|
||||
inflight.turn = event.data.turn
|
||||
inflight.pendingError = undefined
|
||||
}
|
||||
} else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
|
||||
record.inflight = undefined
|
||||
settleFromTurnEnd(inflight, event.data.reason)
|
||||
if (event.data.reason.kind === 'error') {
|
||||
// Hold the rejection: request recovery may adopt the prompt with a
|
||||
// successor turn; quiescence without one delivers this error.
|
||||
inflight.turn = undefined
|
||||
inflight.pendingError = event.data.reason
|
||||
} else {
|
||||
record.inflight = undefined
|
||||
inflight.resolve(turnEndToStopReason(event.data.reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -254,23 +268,47 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const text = acpPromptToText(params.prompt)
|
||||
if (text.trim().length === 0) throw invalidParams('empty prompt')
|
||||
|
||||
// Not driving a retired agent is this bridge's contract: an
|
||||
// agent-loop-only reload disposes the loop's agents while the bridge
|
||||
// record survives, so validate the record against the live registry
|
||||
// before sending — a disposed machine would accept the item silently.
|
||||
if (ctx.agents.get(record.agent.id) !== record.agent) {
|
||||
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
|
||||
}
|
||||
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 (an agent disposed outside the bridge, e.g. an
|
||||
// agent-loop-only reload) must free the slot again or the session
|
||||
// failure (invalid input) must free the slot again or the session
|
||||
// would reject every later prompt as already in flight.
|
||||
record.inflight = { resolve, reject, turn: undefined }
|
||||
const inflight: NonNullable<SessionRecord['inflight']> = {
|
||||
resolve, reject, turn: undefined, pendingError: undefined,
|
||||
}
|
||||
record.inflight = inflight
|
||||
try {
|
||||
record.agent.followup([{ type: 'text', text }])
|
||||
record.agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
// 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
|
||||
// followup() throws only Errors (disposed agent / invalid input);
|
||||
// the String arm is a defensive fallback for a non-Error throw.
|
||||
/* v8 ignore next */
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw internalError(`prompt was not queued: ${detail}`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
// Admission is pre-turn and retries outlive their failed turn, so a
|
||||
// turnless slot settles only at quiescence: a held failure rejects
|
||||
// (no retry adopted the prompt); no turn at all means admission
|
||||
// discarded the prompt — report cancelled.
|
||||
void record.agent.whenIdle().then(() => {
|
||||
if (record.inflight !== inflight || inflight.turn !== undefined) return
|
||||
record.inflight = undefined
|
||||
if (inflight.pendingError !== undefined) {
|
||||
rejectFromError(inflight, inflight.pendingError)
|
||||
return
|
||||
}
|
||||
inflight.resolve('cancelled')
|
||||
})
|
||||
})
|
||||
return { stopReason }
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ describe('ACP automation codec', () => {
|
||||
[{ kind: 'max-tokens' }, 'max_tokens'],
|
||||
[{ kind: 'aborted' }, 'cancelled'],
|
||||
[{ kind: 'disposed' }, 'cancelled'],
|
||||
[{ kind: 'rejected', reason: 'blocked' }, 'cancelled'],
|
||||
[{ kind: 'interrupted' }, 'cancelled'],
|
||||
[{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'],
|
||||
]
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('ACP connection ownership', () => {
|
||||
|
||||
await harness.acpFiber.dispose()
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('ACP connection ownership', () => {
|
||||
|
||||
await harness.closeClientTransport()
|
||||
await harness.acpFiber.dispose()
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
@@ -58,10 +58,10 @@ describe('ACP connection ownership', () => {
|
||||
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
||||
|
||||
await harness.abortClientTransport()
|
||||
await vi.waitFor(() => { expect(agent.status).toBe('disposed') })
|
||||
await vi.waitFor(() => {
|
||||
expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true)
|
||||
})
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('disconnect and plugin disposal share one quiescence boundary', async () => {
|
||||
@@ -73,7 +73,7 @@ describe('ACP connection ownership', () => {
|
||||
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
||||
|
||||
await Promise.all([harness.closeClientTransport(), harness.acpFiber.dispose()])
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('ACP automation output boundary', () => {
|
||||
sessionId: SessionId('foreign'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
expect(harness.updates).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('ACP prompt lifecycle', () => {
|
||||
|
||||
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') })
|
||||
harness.ctx.on('agent/step', () => { throw new Error('plugin pre-step failed') })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: plugin pre-step failed/)
|
||||
@@ -101,7 +101,7 @@ describe('ACP prompt lifecycle', () => {
|
||||
harness.ctx.on('agent/inbox/enqueue', (subject) => {
|
||||
if (subject === agent && !injected) {
|
||||
injected = true
|
||||
agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -195,4 +195,40 @@ describe('ACP prompt lifecycle', () => {
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') })
|
||||
})
|
||||
|
||||
it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => {
|
||||
harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] })
|
||||
// A recovery policy: schedule one retry for the failed request.
|
||||
let retried = false
|
||||
harness.ctx.on('agent/request-error', async (_subject) => {
|
||||
if (!retried) {
|
||||
retried = true
|
||||
return { kind: 'retry' }
|
||||
}
|
||||
})
|
||||
const sessionId = await newSession(harness)
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') })
|
||||
})
|
||||
|
||||
it('a failed turn with no retry still rejects, at quiescence', 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' }] }))
|
||||
.rejects.toThrow(/turn failed: terminal boom/)
|
||||
expect(offered).toBe(1)
|
||||
})
|
||||
|
||||
it('an admission-blocked prompt settles cancelled instead of hanging', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy said no' }))
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.resolves.toEqual({ stopReason: 'cancelled' })
|
||||
// The blocked prompt opened no turn and streamed nothing.
|
||||
expect(messageText(harness)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user