fix(commands): harden registry and UI ordering
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
Reference in New Issue
Block a user