fix(commands): harden registry and UI ordering

This commit is contained in:
Tianyi Cui
2026-07-20 11:24:02 +08:00
parent 38abe7cac6
commit 2dd3b1dfba
67 changed files with 284 additions and 101 deletions

View File

@@ -41,9 +41,9 @@ One id-keyed record map plus exact agent-object checks route every event, prompt
## Human commands
After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`.
After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A new session's server-generated id is introduced by the RPC response before its snapshot enters the connection write queue. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`.
ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands).
ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model; prefix whitespace when literal slash-leading text must reach the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands).
## Session config options

View File

@@ -17,6 +17,7 @@ import {
PROTOCOL_VERSION,
RequestError,
type Agent as AcpAgent,
type AnyMessage,
type AuthenticateRequest,
type AvailableCommand,
type CancelNotification,
@@ -94,6 +95,34 @@ function renderThrown(value: unknown): string {
}
}
/** Return a server-created session id carried by an outbound success response. */
function responseSessionId(message: AnyMessage): SessionId | undefined {
if (!('result' in message) || typeof message.result !== 'object' || message.result === null
|| !('sessionId' in message.result) || typeof message.result.sessionId !== 'string') {
return undefined
}
return SessionId(message.result.sessionId)
}
/** Observe messages only after the wrapped ACP transport has written them. */
function observeOutbound(stream: Stream, onWritten: (message: AnyMessage) => void): Stream {
const writer = stream.writable.getWriter()
return {
readable: stream.readable,
writable: new WritableStream<AnyMessage>({
async write(message) {
await writer.write(message)
onWritten(message)
},
/* v8 ignore start -- the ACP SDK never closes or aborts its outbound stream;
preserve the wrapped Stream contract for other consumers nonetheless */
close: () => writer.close(),
abort: (reason: unknown) => writer.abort(reason),
/* v8 ignore stop */
}),
}
}
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
@@ -393,6 +422,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
const sessions = new Map<SessionId, SessionRecord>()
// Reserve an id before resume so pipelined load/new requests cannot duplicate it.
const loadingIds = new Set<SessionId>()
// A new-session response introduces its server-generated id to the client;
// keep its initial command snapshot pending until that response is written.
const pendingCommandSnapshots = new Map<SessionId, SessionRecord>()
// Async creation checks this after awaits to avoid publishing after teardown.
let closed = false
// Each new or loaded session snapshots the latest connection capability.
@@ -499,10 +531,23 @@ export function apply(ctx: Context, config: AcpConfig): void {
})
}
/** Enqueue a new session's first command snapshot behind its written RPC response. */
const announceInitialCommands = (message: AnyMessage): void => {
const sessionId = responseSessionId(message)
if (sessionId === undefined) return
const rec = pendingCommandSnapshots.get(sessionId)
if (rec === undefined) return
pendingCommandSnapshots.delete(sessionId)
notifyCommands(rec)
}
// Registration and HMR removal can affect global or one scoped view; refresh
// every bridge-owned session and let the registry resolve each exact agent.
// every announced bridge-owned session and let the registry resolve each
// exact agent. A pending new-session snapshot will read the latest registry.
ctx.on('commands/change', () => {
for (const rec of sessions.values()) notifyCommands(rec)
for (const rec of sessions.values()) {
if (!pendingCommandSnapshots.has(rec.agent.session.id)) notifyCommands(rec)
}
})
/** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */
@@ -711,7 +756,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
await handle.dispose()
throw internalError('connection closed during session/new')
}
sessions.set(sessionId, {
const record: SessionRecord = {
agent: handle.agent,
dispose: () => handle.dispose(),
presenter: makePresenter(handle.agent),
@@ -720,8 +765,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
inflight: undefined,
commandAbort: undefined,
pendingSwitches: {},
})
notifyCommands(requireSession(sessionId))
}
sessions.set(sessionId, record)
pendingCommandSnapshots.set(sessionId, record)
const configOptions = configOptionsFor(handle.agent, directory)
return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} }
},
@@ -998,7 +1044,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
)
conn = new AgentSideConnection(makeAgent, stream)
conn = new AgentSideConnection(makeAgent, observeOutbound(stream, announceInitialCommands))
/**
* Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach
@@ -1036,6 +1082,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// installed yet) must observe this after its await and refuse to install a
// post-teardown record. Set even when there are no live sessions.
closed = true
pendingCommandSnapshots.clear()
const recs = [...sessions.values()]
sessions.clear()
if (recs.length === 0) return Promise.resolve()

View File

@@ -41,13 +41,15 @@ describe('ACP plugin commands', () => {
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(commandUpdates(harness, sessionId).at(-1)?.update).toEqual({
sessionUpdate: 'available_commands_update',
availableCommands: [{
name: 'inspect',
description: 'Inspect the session',
input: { hint: '<target>' },
}],
await vi.waitFor(() => {
expect(commandUpdates(harness!, sessionId).at(-1)?.update).toEqual({
sessionUpdate: 'available_commands_update',
availableCommands: [{
name: 'inspect',
description: 'Inspect the session',
input: { hint: '<target>' },
}],
})
})
const dispose = harness.ctx.commands.register({
@@ -88,6 +90,23 @@ describe('ACP plugin commands', () => {
})
})
it('coalesces registry changes before a new session command snapshot is announced', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
harness.ctx.commands.register({
name: 'raced', description: 'Registered after the response', handler: () => ({ kind: 'success' }),
})
await vi.waitFor(() => {
expect(commandUpdates(harness!, sessionId)).toHaveLength(1)
expect(commandUpdates(harness!, sessionId)[0]?.update).toMatchObject({
availableCommands: [{ name: 'raced' }],
})
})
})
it('executes a known single-text command directly and never sends it to the model', async () => {
harness = await makeBridgeHarness({ storageDir })
const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' }))

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -24,6 +24,9 @@ describe('acp bridge — demux & config edges', () => {
harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await vi.waitFor(() => {
expect(harness!.updates.some(update => update.sessionUpdate === 'available_commands_update')).toBe(true)
})
const before = harness.updates.length
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })