|
@@ -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
|
|
* @module @deepseek-ai/dsh-acp
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
@@ -75,16 +75,15 @@ import {
|
|
|
} from './codec.ts'
|
|
} from './codec.ts'
|
|
|
|
|
|
|
|
export const name = 'acp'
|
|
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', 'llm', 'systemPrompt']
|
|
|
|
|
|
|
+// 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 {
|
|
function invalidParams(detail: string): RequestError {
|
|
|
return RequestError.invalidParams(undefined, detail)
|
|
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 {
|
|
function internalError(detail: string): RequestError {
|
|
|
return RequestError.internalError(undefined, detail)
|
|
return RequestError.internalError(undefined, detail)
|
|
|
}
|
|
}
|
|
@@ -209,7 +208,7 @@ export interface AcpConfig {
|
|
|
provider?: string
|
|
provider?: string
|
|
|
/** Model name for created agents (must have a registered adapter). */
|
|
/** Model name for created agents (must have a registered adapter). */
|
|
|
model?: string
|
|
model?: string
|
|
|
- /** Runtime-only transport override for tests; production uses stdio. */
|
|
|
|
|
|
|
+ /** Runtime-only transport override; production uses stdio. */
|
|
|
stream?: Stream
|
|
stream?: Stream
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -245,13 +244,12 @@ interface ModelCatalogEntry {
|
|
|
|
|
|
|
|
/** Per-session bridge state keyed by ACP session id. */
|
|
/** Per-session bridge state keyed by ACP session id. */
|
|
|
interface SessionRecord {
|
|
interface SessionRecord {
|
|
|
- sessionId: SessionId
|
|
|
|
|
agent: Agent
|
|
agent: Agent
|
|
|
- /** Owned-agent disposer that reaches per-session quiescence. */
|
|
|
|
|
|
|
+ /** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */
|
|
|
dispose: () => Promise<void>
|
|
dispose: () => Promise<void>
|
|
|
- /** Per-session tool presenter and in-flight call correlation. */
|
|
|
|
|
|
|
+ /** Per-session tool presentation and call/result correlation. */
|
|
|
presenter: ToolPresenter
|
|
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
|
|
terminalEnabled: boolean
|
|
|
/** Session-local provider/model selection and the current step snapshot. */
|
|
/** Session-local provider/model selection and the current step snapshot. */
|
|
|
target: LlmTargetRef
|
|
target: LlmTargetRef
|
|
@@ -261,10 +259,7 @@ interface SessionRecord {
|
|
|
reject: (error: Error) => void
|
|
reject: (error: Error) => void
|
|
|
turn: number | undefined
|
|
turn: number | undefined
|
|
|
} | 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 }
|
|
pendingSwitches: { preset?: string }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -275,14 +270,15 @@ interface SessionRecord {
|
|
|
* correlation in a `finally` so presentation failure cannot starve settlement.
|
|
* correlation in a `finally` so presentation failure cannot starve settlement.
|
|
|
*/
|
|
*/
|
|
|
export function apply(ctx: Context, config: AcpConfig): void {
|
|
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 agents = ctx.agents
|
|
|
const llm = ctx.llm
|
|
const llm = ctx.llm
|
|
|
const sessionPersistence = ctx.sessionPersistence
|
|
const sessionPersistence = ctx.sessionPersistence
|
|
|
const logger = ctx.logger
|
|
const logger = ctx.logger
|
|
|
const tools = ctx.tools
|
|
const tools = ctx.tools
|
|
|
const userInteraction = ctx.userInteraction
|
|
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)
|
|
const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent)
|
|
|
|
|
|
|
|
/** Resolve a complete target only; partial config remains available to other request listeners. */
|
|
/** Resolve a complete target only; partial config remains available to other request listeners. */
|
|
@@ -380,16 +376,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 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.
|
|
|
|
|
const sessions = new Map<SessionId, SessionRecord>()
|
|
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>()
|
|
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
|
|
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
|
|
let terminalOutputCap = false
|
|
|
|
|
|
|
|
// Assigned at the bottom, before any agent event can fire (a session only
|
|
// Assigned at the bottom, before any agent event can fire (a session only
|
|
@@ -397,20 +389,26 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
// `notify` never observes it unset — no undefined guard needed.
|
|
// `notify` never observes it unset — no undefined guard needed.
|
|
|
let conn: AgentSideConnection
|
|
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({
|
|
userInteraction.registerProvider({
|
|
|
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
|
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
|
|
if (request.agent === undefined) {
|
|
if (request.agent === undefined) {
|
|
|
throw new UserInteractionError('ACP user questions must come from an agent-owned request', 'NO_AGENT')
|
|
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')
|
|
throw new UserInteractionError('ACP user question has no matching session', 'NO_SESSION')
|
|
|
}
|
|
}
|
|
|
const answers: AskUserQuestionAnswerItem[] = []
|
|
const answers: AskUserQuestionAnswerItem[] = []
|
|
|
for (const question of request.questions) {
|
|
for (const question of request.questions) {
|
|
|
const options = question.options ?? []
|
|
const options = question.options ?? []
|
|
|
const response = await withAbort(conn.unstable_createElicitation(
|
|
const response = await withAbort(conn.unstable_createElicitation(
|
|
|
- elicitationForQuestion(sessionId, question, options),
|
|
|
|
|
|
|
+ elicitationForQuestion(rec.agent.session.id, question, options),
|
|
|
), request.signal).catch((error: unknown) => {
|
|
), request.signal).catch((error: unknown) => {
|
|
|
if (error instanceof UserInteractionError) throw error
|
|
if (error instanceof UserInteractionError) throw error
|
|
|
throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error })
|
|
throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error })
|
|
@@ -505,13 +503,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
// whose end arrives late is ignored (see
|
|
// whose end arrives late is ignored (see
|
|
|
// SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP
|
|
// SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP
|
|
|
// has no error stop reason); other reasons resolve via the codec. Demux
|
|
// 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) => {
|
|
ctx.on('session/event', (session, event: SessionEvent) => {
|
|
|
const rec = sessions.get(session.header.id)
|
|
const rec = sessions.get(session.header.id)
|
|
|
if (rec === undefined) return
|
|
if (rec === undefined) return
|
|
|
try {
|
|
try {
|
|
|
- streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, {
|
|
|
|
|
|
|
+ streamSessionEventUpdate(rec.agent.session.id, event, notify, rec.presenter, {
|
|
|
enabled: rec.terminalEnabled,
|
|
enabled: rec.terminalEnabled,
|
|
|
cwd: session.header.cwd,
|
|
cwd: session.header.cwd,
|
|
|
}, { includeUserMessages: false })
|
|
}, { includeUserMessages: false })
|
|
@@ -542,12 +540,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
// allow_always is a grant-storage design the approval RFC defers, so the
|
|
// allow_always is a grant-storage design the approval RFC defers, so the
|
|
|
// prompt never offers a durable grant the harness could not honor.
|
|
// prompt never offers a durable grant the harness could not honor.
|
|
|
ctx.on('approval/request', (req, next) => {
|
|
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
|
|
// The protocol requires `toolCall` (the prompt renders attached to it), so
|
|
|
// a request without a callId has nothing to attach to — delegate.
|
|
// 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({
|
|
return conn.requestPermission({
|
|
|
- sessionId,
|
|
|
|
|
|
|
+ sessionId: rec.agent.session.id,
|
|
|
toolCall: { toolCallId: req.callId },
|
|
toolCall: { toolCallId: req.callId },
|
|
|
options: [
|
|
options: [
|
|
|
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
|
|
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
|
|
@@ -576,26 +574,19 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
return [...options, {
|
|
return [...options, {
|
|
|
id: 'permission',
|
|
id: 'permission',
|
|
|
name: 'Permissions',
|
|
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',
|
|
category: 'mode',
|
|
|
type: 'select',
|
|
type: 'select',
|
|
|
currentValue,
|
|
currentValue,
|
|
|
options: [
|
|
options: [
|
|
|
...presets.names.map((name: string) => presets.optionOf(name)),
|
|
...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')] : [],
|
|
...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 isTurnOpen = (agent: Agent): boolean => {
|
|
|
const events = agent.session.events
|
|
const events = agent.session.events
|
|
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
@@ -606,29 +597,22 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
return false
|
|
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 flushPendingSwitches = (rec: SessionRecord): void => {
|
|
|
const pending = rec.pendingSwitches
|
|
const pending = rec.pendingSwitches
|
|
|
rec.pendingSwitches = {}
|
|
rec.pendingSwitches = {}
|
|
|
if (pending.preset === undefined) return
|
|
if (pending.preset === undefined) return
|
|
|
const presets = ctx.get('permission')
|
|
const presets = ctx.get('permission')
|
|
|
/* v8 ignore next -- a pending preset exists only if the service answered the
|
|
/* 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
|
|
if (presets === undefined) return
|
|
|
presets.set(rec.agent.session, pending.preset)
|
|
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) => {
|
|
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)
|
|
if (rec !== undefined) flushPendingSwitches(rec)
|
|
|
return next()
|
|
return next()
|
|
|
})
|
|
})
|
|
@@ -681,19 +665,15 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
agentOptions: agentOptions(config),
|
|
agentOptions: agentOptions(config),
|
|
|
setup: (agentCtx) => { installTarget(agentCtx, target) },
|
|
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
|
|
/* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC
|
|
|
immediately on close; real stdio may let the handler resume */
|
|
immediately on close; real stdio may let the handler resume */
|
|
|
if (closed) {
|
|
if (closed) {
|
|
|
await handle.dispose()
|
|
await handle.dispose()
|
|
|
throw internalError('connection closed during session/new')
|
|
throw internalError('connection closed during session/new')
|
|
|
}
|
|
}
|
|
|
- bySession.set(handle.agent, sessionId)
|
|
|
|
|
sessions.set(sessionId, {
|
|
sessions.set(sessionId, {
|
|
|
- sessionId,
|
|
|
|
|
agent: handle.agent,
|
|
agent: handle.agent,
|
|
|
dispose: () => handle.dispose(),
|
|
dispose: () => handle.dispose(),
|
|
|
presenter: makePresenter(handle.agent),
|
|
presenter: makePresenter(handle.agent),
|
|
@@ -771,13 +751,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
}
|
|
}
|
|
|
const directory = modelDirectory(catalog, target.current)
|
|
const directory = modelDirectory(catalog, target.current)
|
|
|
const agent = handle.agent
|
|
const agent = handle.agent
|
|
|
- bySession.set(agent, sessionId)
|
|
|
|
|
// Snapshot the terminal capability ONCE for this session (used by both
|
|
// Snapshot the terminal capability ONCE for this session (used by both
|
|
|
// the replay below and the post-load live stream) so a later
|
|
// the replay below and the post-load live stream) so a later
|
|
|
// `initialize` can't desync the call/result of a tool card.
|
|
// `initialize` can't desync the call/result of a tool card.
|
|
|
const terminalEnabled = terminalOutputCap
|
|
const terminalEnabled = terminalOutputCap
|
|
|
const record: SessionRecord = {
|
|
const record: SessionRecord = {
|
|
|
- sessionId,
|
|
|
|
|
agent,
|
|
agent,
|
|
|
dispose: () => handle.dispose(),
|
|
dispose: () => handle.dispose(),
|
|
|
presenter: makePresenter(agent),
|
|
presenter: makePresenter(agent),
|
|
@@ -896,8 +874,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
if (presets === undefined) {
|
|
if (presets === undefined) {
|
|
|
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
|
|
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)
|
|
const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events)
|
|
|
if (params.value === current) break
|
|
if (params.value === current) break
|
|
|
if (!presets.names.includes(params.value)) {
|
|
if (!presets.names.includes(params.value)) {
|
|
@@ -1080,7 +1057,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
|
|
|
* zero or more times per event (best-effort UI feed, never load-bearing).
|
|
* zero or more times per event (best-effort UI feed, never load-bearing).
|
|
|
* @param presenter - resolves tool-owned render intent for tool events;
|
|
* @param presenter - resolves tool-owned render intent for tool events;
|
|
|
* defaults to the generic-fallback {@link nullToolPresenter}.
|
|
* 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).
|
|
* disabled (the plain-text console-block fallback).
|
|
|
* @param options - `includeUserMessages` (default `true`): live streaming
|
|
* @param options - `includeUserMessages` (default `true`): live streaming
|
|
|
* passes `false` so a prompt the client just sent is not echoed back.
|
|
* passes `false` so a prompt the client just sent is not echoed back.
|
|
@@ -1139,16 +1116,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 {
|
|
export function todosToPlan(todos: TodoItem[]): Plan {
|
|
|
return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) }
|
|
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 {
|
|
export interface TerminalRendering {
|
|
|
enabled: boolean
|
|
enabled: boolean
|
|
|
/** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */
|
|
/** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */
|
|
@@ -1159,31 +1136,31 @@ export interface TerminalRendering {
|
|
|
const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
|
|
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 {
|
|
export class ToolPresenter {
|
|
|
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
|
|
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(
|
|
constructor(
|
|
|
private readonly tools: Pick<ToolRegistry, 'get'>,
|
|
private readonly tools: Pick<ToolRegistry, 'get'>,
|
|
|
private readonly onError: (message: string) => void = () => {},
|
|
private readonly onError: (message: string) => void = () => {},
|
|
|
- /** Agent scope for tool lookup; absent during replay without a live agent. */
|
|
|
|
|
private readonly agent?: 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 callId - the call id the matching `tool/result` will look up.
|
|
|
* @param name - the tool name, resolved against the registry for `presentCall`.
|
|
* @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 {
|
|
call(callId: CallId, name: string, argsJson: string): ToolCallView {
|
|
|
const args = parseToolArguments(argsJson)
|
|
const args = parseToolArguments(argsJson)
|
|
@@ -1195,22 +1172,20 @@ export class ToolPresenter {
|
|
|
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
|
|
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
|
|
|
present = undefined
|
|
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 }
|
|
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args }
|
|
|
this.pending.set(callId, { name, args, card: view.card })
|
|
this.pending.set(callId, { name, args, card: view.card })
|
|
|
return view
|
|
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 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 isError - whether the result is an error, forwarded to `presentResult`.
|
|
|
* @param meta - the result's machine-readable meta, forwarded when present.
|
|
* @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 {
|
|
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
|
|
|
const call = this.pending.get(callId)
|
|
const call = this.pending.get(callId)
|
|
@@ -1280,11 +1255,11 @@ type AcpToolCallContent =
|
|
|
| { type: 'diff'; path: string; oldText: string | null; newText: string }
|
|
| { type: 'diff'; path: string; oldText: string | null; newText: string }
|
|
|
| { type: 'terminal'; terminalId: 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 {
|
|
function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string {
|
|
|
if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title
|
|
if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title
|
|
|
const rel = relativePath(sessionCwd, rawPath)
|
|
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
|
|
if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title
|
|
|
return title.split(rawPath).join(rel)
|
|
return title.split(rawPath).join(rel)
|
|
|
}
|
|
}
|