Merge origin/master into feat/plan-mode

This commit is contained in:
Tianyi Cui
2026-07-20 23:28:07 +08:00
1439 changed files with 63420 additions and 24756 deletions

View File

@@ -1,8 +1,8 @@
/**
* Multi-session ACP server bridge over JSON-RPC stdio. Creates or resumes
* agents, routes their events, settles prompts by turn, and answers approvals.
* Each session keeps independent presentation and prompt-correlation state so
* concurrent streams cannot cross. Stdout is reserved for protocol frames.
* Multi-session ACP bridge over JSON-RPC stdio. Creates or resumes agents,
* routes session-scoped events and approvals, and settles prompts by turn.
* Stdout is reserved for protocol frames.
*
* @module @deepseek-ai/dsh-acp
*/
@@ -35,6 +35,8 @@ import {
type PromptResponse,
type SessionConfigOption,
type SessionModeState,
type SessionConfigSelectGroup,
type SessionConfigSelectOption,
type SessionNotification,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
@@ -43,10 +45,10 @@ import {
type Stream,
type StopReason,
} from '@agentclientprotocol/sdk'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
@@ -58,6 +60,12 @@ import type {} from '@deepseek-ai/dsh-session-persistence'
// Type-only edge: makes `ctx.get('modes')` resolve the ModesService type when
// @deepseek-ai/dsh-mode is composed; the runtime read stays opportunistic.
import type {} from '@deepseek-ai/dsh-mode'
// Side-effect type import: declaration-merges prompt assembly onto Context and
// the scoped waterfall used to keep persona variables aligned with requests.
import type {} from '@deepseek-ai/dsh-system-prompt'
// Side-effect type import: declaration-merges the `approval/request` waterfall
// the bridge answers for its own agents (see the approval answerer below).
import type {} from '@deepseek-ai/dsh-user-approval'
import {
UserInteractionError,
type AskUserQuestionAnswer,
@@ -74,16 +82,15 @@ import {
} from './codec.ts'
export const name = 'acp'
// Interface services back advertised loading, tool-owned presentation with a generic fallback, and interaction.
// TODO(acp-session-inject): remove `sessions`; the bridge never reads it, and ownership is already behind `agents`.
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
// Interface services back loading, presentation, interaction, and prompt assembly.
export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
/** Build an ACP invalid-params error with visible human detail. */
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
/** Build an ACP internal error with visible detail; plain handler errors are flattened on wire. */
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
}
@@ -204,25 +211,52 @@ function stringArrayContent(
/** Plugin config: the agent template ACP sessions are created from. */
export interface AcpConfig {
/** Provider route for created agents. */
provider?: string
/** Model name for created agents (must have a registered adapter). */
model?: string
/** Runtime-only transport override for tests; production uses stdio. */
/** Runtime-only transport override; production uses stdio. */
stream?: Stream
}
export const Config: Schema<AcpConfig> = Schema.object({
provider: Schema.string(),
model: Schema.string(),
})
/** Provider/model pair selected for one ACP session. */
interface LlmTarget {
provider: string
model: string
}
/** Mutable target shared by one agent's scoped assembly and request listeners. */
interface LlmTargetRef {
current: LlmTarget | undefined
/** Step snapshot captured by prompt assembly so target switches cannot split prompt and request. */
assembled: LlmTarget | undefined
}
/** One resolved ACP model selector plus its opaque value lookup. */
interface ModelDirectory {
option: Extract<SessionConfigOption, { type: 'select' }> | undefined
targets: ReadonlyMap<string, LlmTarget>
}
/** One provider and its adapter-advertised models, detached for one RPC. */
interface ModelCatalogEntry {
provider: LlmProviderInfo
models: LlmModelInfo[]
}
/** Per-session bridge state keyed by ACP session id. */
interface SessionRecord {
sessionId: SessionId
agent: Agent
/** Owned-agent disposer that reaches per-session quiescence. */
/** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */
dispose: () => Promise<void>
/** Per-session tool presenter and in-flight call correlation. */
/** Per-session tool presentation and call/result correlation. */
presenter: ToolPresenter
/** Session-creation snapshot of terminal-card support for call/result consistency. */
/** Terminal capability snapshot shared by matching call and result updates. */
terminalEnabled: boolean
/**
* The last mode id this session sent to the client (advertised at
@@ -231,16 +265,15 @@ interface SessionRecord {
* composed — no mode surface is advertised, so nothing is ever notified.
*/
lastModeId: string | undefined
/** Session-local provider/model selection and the current step snapshot. */
target: LlmTargetRef
/** In-flight prompt and its captured turn number for exact settlement. */
inflight: {
resolve: (reason: StopReason) => void
reject: (error: Error) => void
turn: number | undefined
} | undefined
/**
* Idle config changes awaiting a turn-enclosed log anchor; last write wins.
* Responses overlay them, but a restart before anchoring restores the logged fold.
*/
/** Last idle switch per knob, anchored before the next prompt assembles. */
pendingSwitches: { preset?: string }
}
@@ -251,25 +284,118 @@ interface SessionRecord {
* correlation in a `finally` so presentation failure cannot starve settlement.
*/
export function apply(ctx: Context, config: AcpConfig): void {
// Handlers run later outside this injection scope, so capture services now.
// ACP handlers execute outside this plugin's injection scope, so capture
// injected services during apply(); lazy service reads in a handler fail.
const agents = ctx.agents
const llm = ctx.llm
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
const tools = ctx.tools
const userInteraction = ctx.userInteraction
// Presenter failures are logged and contained per session or replay.
// Presenter callbacks are contained so display failures cannot break protocol handling.
const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent)
// TODO(derive-acp-session-id): derive event ids from `agent.session`, verify ownership, then remove the reverse map.
// Agent events currently carry only the Agent, so retain `SessionRecord.sessionId` and update both indexes together.
// Dropping the forward record lets the weak reverse entry expire.
/** Resolve a complete target only; partial config remains available to other request listeners. */
const configuredTarget = (): LlmTarget | undefined => config.provider !== undefined && config.model !== undefined
? { provider: config.provider, model: config.model }
: undefined
/** Install the ACP target as an agent-scoped prompt/request override. */
const installTarget = (agentCtx: Context, target: LlmTargetRef): void => {
const agent = agentCtx.agent
/* v8 ignore next -- setup is invoked only with the freshly created agent's scoped context. */
if (agent === undefined) throw new Error('acp: agent setup has no scoped agent')
const logged = agent.session.requestHeader()?.config
if (logged !== undefined) target.current = { provider: logged.provider, model: logged.model }
// Capture once at assembly entry and apply the same pair after downstream
// prompt listeners. A selector change during async assembly therefore takes
// effect on the following step instead of splitting {{model}} from routing.
agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const selected = target.current
const assembled = await next()
target.assembled = selected
if (selected === undefined) return assembled
return {
...assembled,
variables: {
...assembled.variables,
provider: selected.provider,
model: selected.model,
},
}
})
agentCtx.on('agent/request', async (_agent, _turn, _step, _callConfig, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = target.assembled
return selected === undefined ? resolved : {
...resolved,
provider: selected.provider,
model: selected.model,
}
})
}
/** Opaque ACP value preserving both routing dimensions. */
const targetValue = (target: LlmTarget): string => JSON.stringify([target.provider, target.model])
/** Read one detached advisory catalog snapshot before mutating session state. */
const readModelCatalog = async (): Promise<ModelCatalogEntry[]> => Promise.all(
llm.listProviders().map(async provider => ({
provider,
models: await llm.listModels(provider.id),
})),
)
/** Resolve one catalog snapshot into the ACP model selector for a session. */
const modelDirectory = (catalog: readonly ModelCatalogEntry[], current: LlmTarget | undefined): ModelDirectory => {
if (current === undefined) return { option: undefined, targets: new Map() }
const models = catalog.map(entry => ({ provider: entry.provider, models: [...entry.models] }))
const currentProvider = models.find(entry => entry.provider.id === current.provider)
if (currentProvider === undefined) return { option: undefined, targets: new Map() }
if (!currentProvider.models.some(model => model.id === current.model)) {
currentProvider.models = [...currentProvider.models, {
provider: current.provider,
id: current.model,
name: current.model,
}]
}
const targets = new Map<string, LlmTarget>()
const groups = models.flatMap(({ provider, models: entries }) => {
if (entries.length === 0) return []
const options = entries.map((model): SessionConfigSelectOption => {
const target = { provider: model.provider, model: model.id }
const value = targetValue(target)
targets.set(value, target)
return {
value,
name: model.name,
...model.description === undefined ? {} : { description: model.description },
}
})
return [{ group: provider.id, name: provider.name, options } satisfies SessionConfigSelectGroup]
})
return {
option: {
id: 'model',
name: 'Model',
description: 'Sets this session\'s provider and model.',
category: 'model',
type: 'select',
currentValue: targetValue(current),
options: groups.length === 1 ? groups.flatMap(group => group.options) : groups,
},
targets,
}
}
const sessions = new Map<SessionId, SessionRecord>()
const bySession = new WeakMap<Agent, SessionId>()
// Reserve ids across asynchronous resume; distinct ids still load concurrently.
// Reserve an id before resume so pipelined load/new requests cannot duplicate it.
const loadingIds = new Set<SessionId>()
// Post-await checks prevent a closing bridge from publishing resumed sessions.
// Async creation checks this after awaits to avoid publishing after teardown.
let closed = false
// Connection-level capability copied into each new session record.
// Each new or loaded session snapshots the latest connection capability.
let terminalOutputCap = false
// Assigned at the bottom, before any agent event can fire (a session only
@@ -277,20 +403,26 @@ export function apply(ctx: Context, config: AcpConfig): void {
// `notify` never observes it unset — no undefined guard needed.
let conn: AgentSideConnection
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
const ownedRecord = (agent: Agent): SessionRecord | undefined => {
const rec = sessions.get(agent.session.id)
return rec?.agent === agent ? rec : undefined
}
userInteraction.registerProvider({
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
if (request.agent === undefined) {
throw new UserInteractionError('ACP user questions must come from an agent-owned request', 'NO_AGENT')
}
const sessionId = bySession.get(request.agent)
if (sessionId === undefined) {
const rec = ownedRecord(request.agent)
if (rec === undefined) {
throw new UserInteractionError('ACP user question has no matching session', 'NO_SESSION')
}
const answers: AskUserQuestionAnswerItem[] = []
for (const question of request.questions) {
const options = question.options ?? []
const response = await withAbort(conn.unstable_createElicitation(
elicitationForQuestion(sessionId, question, options),
elicitationForQuestion(rec.agent.session.id, question, options),
), request.signal).catch((error: unknown) => {
if (error instanceof UserInteractionError) throw error
throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error })
@@ -363,7 +495,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
reason: TurnEndReason,
): void => {
if (reason.kind === 'error') {
inflight.reject(internalError(`turn failed: ${reason.message}`))
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
} else {
inflight.resolve(turnEndToStopReason(reason))
}
@@ -403,13 +535,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
// whose end arrives late is ignored (see
// SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP
// has no error stop reason); other reasons resolve via the codec. Demux
// strictly by session id: a `session/event` is routed to its own record, so
// two sessions streaming at once never cross-settle or interleave updates.
// strictly by session id: concurrent updates may alternate on the shared
// connection, but they retain the owning id and never cross-settle.
ctx.on('session/event', (session, event: SessionEvent) => {
const rec = sessions.get(session.header.id)
if (rec === undefined) return
try {
streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, {
streamSessionEventUpdate(rec.agent.session.id, event, notify, rec.presenter, {
enabled: rec.terminalEnabled,
cwd: session.header.cwd,
}, { includeUserMessages: false })
@@ -423,7 +555,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// must not desync the picker.
if (event.type === 'mode/set' && event.data.mode !== rec.lastModeId) {
rec.lastModeId = event.data.mode
notify({ sessionId: rec.sessionId, update: { sessionUpdate: 'current_mode_update', currentModeId: event.data.mode } })
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: event.data.mode } })
}
const inflight = rec.inflight
if (inflight !== undefined && event.type === 'turn/start') {
@@ -448,15 +580,15 @@ export function apply(ctx: Context, config: AcpConfig): void {
// the fail-closed `unavailable` default) takes the question. A rejected
// `requestPermission` (client gone, bridge torn down) propagates and the
// ApprovalService contains it as `unavailable`. Options are one-shot only:
// allow_always is a grant-storage design the approval RFC defers, so the
// allow_always is a grant-storage design the approval Agent Note defers, so the
// prompt never offers a durable grant the harness could not honor.
ctx.on('approval/request', (req, next) => {
const sessionId = bySession.get(req.agent)
const rec = ownedRecord(req.agent)
// The protocol requires `toolCall` (the prompt renders attached to it), so
// a request without a callId has nothing to attach to — delegate.
if (sessionId === undefined || req.callId === undefined) return next()
if (rec === undefined || req.callId === undefined) return next()
return conn.requestPermission({
sessionId,
sessionId: rec.agent.session.id,
toolCall: { toolCallId: req.callId },
options: [
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
@@ -472,38 +604,32 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- The ACP Agent method surface -----------------------------------------
/**
* Build the single Permissions option when `ctx.permission` is composed.
* Its value comes from the session log, overlaid by an unanchored idle
* switch, so `session/load` needs no catch-up state.
*/
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
/** Build every ACP session option from the model directory and live services. */
const configOptionsFor = (
agent: Agent,
directory: ModelDirectory,
pending: SessionRecord['pendingSwitches'] = {},
): SessionConfigOption[] => {
const options = directory.option === undefined ? [] : [directory.option]
const presets = ctx.get('permission')
if (presets === undefined) return []
if (presets === undefined) return options
const currentValue = pending.preset ?? presets.current(agent.session.events)
return [{
return [...options, {
id: 'permission',
name: 'Permissions',
description: 'Sets this session\'s sandbox and approval behavior.',
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
category: 'mode',
type: 'select',
currentValue,
options: [
...presets.names.map((name: string) => presets.optionOf(name)),
// `custom` is offered only as the current-value echo, never as a target.
// `custom` echoes the current derived state but is never a target.
...currentValue === 'custom' ? [presets.optionOf('custom')] : [],
],
}]
}
/**
* Whether the session's log currently has an open turn — the last boundary
* event is a `turn/start`. Decides whether a config switch may append NOW
* (enclosed) or must wait for the next prompt submission (see
* {@link SessionRecord.pendingSwitches}). Read from the LOG, not
* `agent.status`: status stays `running` across the gap between two queued
* turns, where a bare append would still land outside any turn.
*/
/** Whether the log has an open turn in which a config switch can be enclosed. */
const isTurnOpen = (agent: Agent): boolean => {
const events = agent.session.events
for (let index = events.length - 1; index >= 0; index -= 1) {
@@ -514,29 +640,22 @@ export function apply(ctx: Context, config: AcpConfig): void {
return false
}
/**
* Anchor a pending preset in the open turn. `PermissionService.set()` skips
* net-zero changes, so the log records switches rather than select clicks.
*/
/** Anchor last-write-wins idle switches into a just-opened turn. */
const flushPendingSwitches = (rec: SessionRecord): void => {
const pending = rec.pendingSwitches
rec.pendingSwitches = {}
if (pending.preset === undefined) return
const presets = ctx.get('permission')
/* v8 ignore next -- a pending preset exists only if the service answered the
switch; a valid composition cannot unmount it before anchoring. */
switch; it cannot unmount between that and the next turn in any composition. */
if (presets === undefined) return
presets.set(rec.agent.session, pending.preset)
}
// Anchor idle switches on the next prompt submission: its turn is open, but
// request assembly has not begun. This handler runs outside log emission, so
// invariants and persistence observe the events in log order; the first flush
// clears pending state. Promptless injection turns leave the switch pending,
// with no request or execution under stale settings.
// Prompt-submit is inside the new turn but before prompt assembly. Promptless
// injection turns leave the switch pending because they execute no request.
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
const sessionId = bySession.get(agent)
const rec = sessionId === undefined ? undefined : sessions.get(sessionId)
const rec = ownedRecord(agent)
if (rec !== undefined) flushPendingSwitches(rec)
return next()
})
@@ -551,7 +670,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
const protocolVersion = params.protocolVersion === PROTOCOL_VERSION ? params.protocolVersion : PROTOCOL_VERSION
// Remember the Zed terminal-output `_meta` capability: when set, bash and
// other shell tools render as a terminal card (see streamSessionEventUpdate
// + the terminal-rendering RFC). `_meta` is `{[k]: unknown} | null`, so
// + the terminal-rendering Agent Note). `_meta` is `{[k]: unknown} | null`, so
// narrow defensively to a strict boolean true.
terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true
return Promise.resolve({
@@ -580,35 +699,35 @@ export function apply(ctx: Context, config: AcpConfig): void {
validateWorkspaceParams(params)
validateMcpServers(params)
const sessionId = SessionId(randomUUID())
const target: LlmTargetRef = { current: configuredTarget(), assembled: undefined }
const directory = modelDirectory(await readModelCatalog(), target.current)
assertOpen()
const handle = await agents.create({
agentId: AgentId(sessionId),
sessionId,
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
setup: (agentCtx) => { installTarget(agentCtx, target) },
})
// Creation awaits the unpublished setup transaction. A client disconnect
// can therefore close this bridge
// after the entry check but before the handle resolves; never install a
// post-close record that quiesce() could not have seen.
// Agent creation may resolve after the bridge closes; dispose the handle
// instead of publishing a record that teardown could not observe.
/* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC
immediately on close; real stdio may let the handler resume */
if (closed) {
await handle.dispose()
throw internalError('connection closed during session/new')
}
bySession.set(handle.agent, sessionId)
const modes = modesStateFor(handle.agent)
sessions.set(sessionId, {
sessionId,
agent: handle.agent,
dispose: () => handle.dispose(),
presenter: makePresenter(handle.agent),
terminalEnabled: terminalOutputCap,
lastModeId: modes?.currentModeId,
target,
inflight: undefined,
pendingSwitches: {},
})
const configOptions = configOptionsFor(handle.agent)
const configOptions = configOptionsFor(handle.agent, directory)
return {
sessionId,
...modes !== undefined ? { modes } : {},
@@ -657,10 +776,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
throw invalidParams(`session ${sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`)
}
}
const catalog = await readModelCatalog()
assertOpen()
const target: LlmTargetRef = { current: configuredTarget(), assembled: undefined }
const handle = await agents.resume({
agentId: AgentId(sessionId),
resumeSessionId: sessionId,
agentOptions: agentOptions(config),
setup: (agentCtx) => { installTarget(agentCtx, target) },
})
// The bridge may have torn down (disposal / client disconnect) while
// resume() was pending. Its listeners are gone, so installing a record
@@ -676,20 +798,20 @@ export function apply(ctx: Context, config: AcpConfig): void {
await handle.dispose()
throw invalidParams('connection closed during session/load')
}
const directory = modelDirectory(catalog, target.current)
const agent = handle.agent
bySession.set(agent, sessionId)
// Snapshot the terminal capability ONCE for this session (used by both
// the replay below and the post-load live stream) so a later
// `initialize` can't desync the call/result of a tool card.
const terminalEnabled = terminalOutputCap
const modes = modesStateFor(agent)
const record: SessionRecord = {
sessionId,
agent,
dispose: () => handle.dispose(),
presenter: makePresenter(agent),
terminalEnabled,
lastModeId: modes?.currentModeId,
target,
inflight: undefined,
pendingSwitches: {},
}
@@ -715,7 +837,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
for (const event of agent.session.events) {
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
}
const configOptions = configOptionsFor(agent)
const configOptions = configOptionsFor(agent, directory)
return {
...modes !== undefined ? { modes } : {},
...configOptions.length > 0 ? { configOptions } : {},
@@ -741,7 +863,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// is not re-notified. A no-op selection (already current) echoes too —
// cheap, idempotent, and the picker settles regardless.
rec.lastModeId = params.modeId
notify({ sessionId: rec.sessionId, update: { sessionUpdate: 'current_mode_update', currentModeId: params.modeId } })
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: params.modeId } })
return Promise.resolve({})
},
@@ -779,10 +901,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
// session/cancel maps to the queue-aware agent.cancel(reason): it aborts
// a RUNNING step, clears the queued + steering FIFOs, and drops a
// turn that is about to start (the pre-step window) — so a queued-but-
// not-yet-started prompt never runs, and a prompt accepted right after
// cannot be batched into the cancelled turn. Scoped to THIS session's
// not-yet-started prompt never runs, while a prompt accepted afterward
// remains a separate queued turn. Scoped to THIS session's
// agent — a cancel in one session never touches another's stream or
// pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt
// pending prompt (multi-session isolation).
// We ALSO settle the in-flight prompt
// as cancelled directly here: do NOT rely on the resulting turn/end to
// settle it, because cancel() may drop the turn before any turn/end is
// emitted, and removing this direct settle would move the RPC's
@@ -792,25 +915,41 @@ export function apply(ctx: Context, config: AcpConfig): void {
return Promise.resolve()
},
setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
// The advertised option is a select, so the boolean-shaped variant of
// the request is a protocol misuse regardless of configId.
// Every advertised option is a select, so the boolean-shaped variant
// is a protocol misuse regardless of configId.
if (typeof params.value !== 'string') {
throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`)
}
let directory = modelDirectory(await readModelCatalog(), rec.target.current)
// Open-turn switches append immediately; idle switches wait for the
// next prompt-submit. Only values advertised by this composition are
// accepted, and the session log remains the durable store.
switch (params.configId) {
case 'model': {
const target = directory.targets.get(params.value)
if (target === undefined) {
throw invalidParams(`unknown model value ${JSON.stringify(params.value)}`)
}
rec.target.current = { ...target }
const option = directory.option
/* v8 ignore next -- `targets` is populated only while constructing
this selector; a found target therefore proves it exists. */
if (option === undefined) throw internalError('model directory target has no selector')
directory = {
...directory,
option: { ...option, currentValue: params.value },
}
break
}
case 'permission': {
const presets = ctx.get('permission')
if (presets === undefined) {
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
}
// Clients may re-send the current selection on session start. Accept
// that echo without logging; this is the only valid `custom` request.
// A current-value echo is acknowledged without recording a switch.
const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events)
if (params.value === current) break
if (!presets.names.includes(params.value)) {
@@ -825,7 +964,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
// The spec requires the COMPLETE refreshed config state in the response
// (a change may cascade); ours are independent, but the contract holds.
return Promise.resolve({ configOptions: configOptionsFor(rec.agent, rec.pendingSwitches) })
return { configOptions: configOptionsFor(rec.agent, directory, rec.pendingSwitches) }
},
}
}
@@ -921,11 +1060,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
* Build per-agent options from the plugin config, omitting absent fields
* (exactOptionalPropertyTypes: never assign `undefined` to an optional key).
* Exported for unit coverage of both the present and absent branches.
* @param config - the plugin config carrying the optional model name.
* @returns the per-agent options, with `model` present only when configured.
* @param config - the plugin config carrying the optional provider/model target.
* @returns the per-agent options, with each configured target field present.
*/
export function agentOptions(config: AcpConfig): { model?: string } {
export function agentOptions(config: AcpConfig): { provider?: string; model?: string } {
return {
...config.provider !== undefined ? { provider: config.provider } : {},
...config.model !== undefined ? { model: config.model } : {},
}
}
@@ -970,11 +1110,13 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
* identical update stream from the same event log.
*
* - `assistant/chunk` text-delta/reasoning-delta → message/thought chunks
* - `llm/retry` and terminal model failure → visible discarded-attempt markers
* - `user/message` → `user_message_chunk` during load replay only — so a
* loaded transcript reconstructs the USER side of each turn without echoing
* a live `session/prompt` back to the client
* - `tool/call` → `tool_call` (pending)
* - `tool/result` → `tool_call_update` (completed/failed)
* - appended `tool/result` → `tool_call_update` (completed/failed)
* - replacement `tool/result` → no update (context rewrite, not execution)
*
* Tool-call presentation (title/kind/rawInput, and the completed-state content)
* is owned by each TOOL via `presentCall`/`presentResult` — the bridge never
@@ -992,7 +1134,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
* zero or more times per event (best-effort UI feed, never load-bearing).
* @param presenter - resolves tool-owned render intent for tool events;
* defaults to the generic-fallback {@link nullToolPresenter}.
* @param terminal - the connection's terminal-rendering context; defaults to
* @param terminal - the session's terminal-rendering context; defaults to
* disabled (the plain-text console-block fallback).
* @param options - `includeUserMessages` (default `true`): live streaming
* passes `false` so a prompt the client just sent is not echoed back.
@@ -1016,6 +1158,13 @@ export function streamSessionEventUpdate(
}
return
}
case 'llm/retry': {
const text = '\n\n[Previous model attempt discarded; retrying '
+ `${event.data.retry}/${event.data.maxRetries} in ${event.data.delayMs}ms: `
+ `${event.data.failure.message}]\n\n`
notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } })
return
}
case 'user/message': {
if (!includeUserMessages) return
// Replay the user's prompt so a loaded session shows both sides of each
@@ -1035,6 +1184,10 @@ export function streamSessionEventUpdate(
return
}
case 'tool/result': {
// Replacements (for example model-free pruning) are transcript rewrites,
// not repeated tool executions. Re-presenting one would consume no
// pending call and could clobber the original terminal/diff completion.
if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return
const view = presenter.result(event.data.callId, event.data.content, event.data.isError, event.data.meta)
notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) })
return
@@ -1043,7 +1196,13 @@ export function streamSessionEventUpdate(
notify({ sessionId, update: { sessionUpdate: 'plan', ...todosToPlan(event.data.todos) } })
return
}
// turn/step boundaries, context/message, steering,
case 'turn/end': {
if (event.data.reason.kind !== 'error' || !('failure' in event.data.reason)) return
const text = `\n\n[Model attempt failed; any partial output above is discarded: ${event.data.reason.failure.message}]\n\n`
notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } })
return
}
// non-error turn/step boundaries, context/message, steering,
// assistant/message — no direct ACP client update.
default:
return
@@ -1051,16 +1210,16 @@ export function streamSessionEventUpdate(
}
/**
* Map a whole harness todo list to an ACP plan, assigning medium priority.
* Statuses map directly and ACP replaces its whole plan on each update.
* @param todos - the harness todo list (the whole list, not a diff).
* @returns the ACP plan body, one entry per todo.
* Map a whole harness todo list to an ACP replacement plan, using medium
* priority because harness todos do not carry one.
* @param todos - complete harness todo list.
* @returns one ACP plan entry per todo.
*/
export function todosToPlan(todos: TodoItem[]): Plan {
return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) }
}
/** Terminal-card capability and workspace context for event rendering. */
/** Per-session terminal capability and workspace used while translating updates. */
export interface TerminalRendering {
enabled: boolean
/** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */
@@ -1071,31 +1230,31 @@ export interface TerminalRendering {
const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
/**
* Resolve tool-owned call/result views with generic fallbacks. Per-session
* call-id state supplies the tool name and arguments omitted from result events.
* Each entry is consumed by its result; any remainder dies with the session.
* Resolve tool-owned call/result views with a generic fallback. Per-session
* state correlates results with call arguments; interrupted calls may retain an
* entry only until that session's presenter is discarded.
*/
export class ToolPresenter {
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
/**
* @param tools the registry to resolve tool definitions by name.
* @param onError receives contained presenter failures before generic fallback.
* @param tools - registry used to resolve executing definitions.
* @param onError - contained presenter-error sink before generic fallback.
* @param agent - optional scoped registry view for the executing agent.
*/
constructor(
private readonly tools: Pick<ToolRegistry, 'get'>,
private readonly onError: (message: string) => void = () => {},
/** Agent scope for tool lookup; absent during replay without a live agent. */
private readonly agent?: Agent,
) {}
/**
* Resolve a pending call and remember its state for the matching result.
* Pending-state render intent for a `tool/call`; remembers `(name, args, card)`
* for the matching result.
* @param callId - the call id the matching `tool/result` will look up.
* @param name - the tool name, resolved against the registry for `presentCall`.
* @param argsJson - the raw arguments JSON from the event; parsed for the view
* (a non-JSON string is surfaced raw).
* @returns the tool-owned view, or a generic parsed-input fallback.
* @param argsJson - raw event arguments parsed for presentation.
* @returns the tool-owned view or generic fallback.
*/
call(callId: CallId, name: string, argsJson: string): ToolCallView {
const args = parseToolArguments(argsJson)
@@ -1107,22 +1266,20 @@ export class ToolPresenter {
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
present = undefined
}
// No tool-owned presentation: fall back to the tool name as the title, the
// full parsed args as the raw input, and kind `other` (the generic card).
// The kind is never sniffed from the name — the bridge does not special-case
// tool names; a tool that wants a richer kind declares `presentCall`.
// Tool names never imply presentation kind; richer cards are tool-owned.
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args }
this.pending.set(callId, { name, args, card: view.card })
return view
}
/**
* Resolve a completed result and consume its remembered call state.
* Completed-state render intent for a `tool/result`; consumes the remembered
* `(name, args, card)`.
* @param callId - matching call id; unknown or late ids use raw content.
* @param content - the result's content blocks (the fallback and fill-in body).
* @param content - result content used by the fallback and fill-in body.
* @param isError - whether the result is an error, forwarded to `presentResult`.
* @param meta - the result's machine-readable meta, forwarded when present.
* @returns the normalized tool-owned view, or a raw-content generic fallback.
* @returns a normalized tool-owned view or raw-content fallback.
*/
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
const call = this.pending.get(callId)
@@ -1192,11 +1349,11 @@ type AcpToolCallContent =
| { type: 'diff'; path: string; oldText: string | null; newText: string }
| { type: 'terminal'; terminalId: string }
/** Relativize an in-workspace file path in a card title; keep target paths raw. */
/** Relativize only in-workspace title text; location and diff paths stay raw. */
function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string {
if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title
const rel = relativePath(sessionCwd, rawPath)
// Reject an empty relative path or a leading parent-directory segment.
// Test the `..` segment, not a character prefix: `..cache/x` is in-workspace.
if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title
return title.split(rawPath).join(rel)
}