|
|
@@ -60,6 +60,7 @@ import {
|
|
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
|
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
+import type { ToolCallKind, ToolRegistry } from '@deepseek-ai/dsh-tools'
|
|
|
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
|
|
// Context (the bridge injects it and reads `list()` for load cwd validation).
|
|
|
import type {} from '@deepseek-ai/dsh-session-persistence'
|
|
|
@@ -73,8 +74,10 @@ import {
|
|
|
export const name = 'acp'
|
|
|
// The bridge programs against the interface packages only (architecture rule:
|
|
|
// plugins never depend on dsh-agent-loop). `sessionPersistence` is required
|
|
|
-// because `initialize` advertises `loadSession: true`.
|
|
|
-export const inject = ['agents', 'sessions', 'sessionPersistence']
|
|
|
+// because `initialize` advertises `loadSession: true`. `tools` lets a tool own
|
|
|
+// how its calls render (`presentCall`/`presentResult`); the bridge looks up the
|
|
|
+// definition by name and falls back to a generic presentation when absent.
|
|
|
+export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools']
|
|
|
|
|
|
/**
|
|
|
* Build an ACP "invalid params" error whose human detail rides in the message.
|
|
|
@@ -131,6 +134,13 @@ export const Config: Schema<AcpConfig> = Schema.object({
|
|
|
interface SessionRecord {
|
|
|
sessionId: string
|
|
|
agent: Agent
|
|
|
+ /**
|
|
|
+ * Resolves tool-owned presentation for THIS session's tool calls and remembers
|
|
|
+ * each in-flight call's `(name, args)` so the matching `tool/result` can find
|
|
|
+ * its tool. Per-session so two concurrent sessions never cross their in-flight
|
|
|
+ * tool state.
|
|
|
+ */
|
|
|
+ presenter: ToolPresenter
|
|
|
/**
|
|
|
* The in-flight `session/prompt`, or `undefined` when none is pending. A
|
|
|
* prompt resolves with a {@link StopReason} or rejects with an Error (a
|
|
|
@@ -182,6 +192,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
const agents = ctx.agents
|
|
|
const sessionPersistence = ctx.sessionPersistence
|
|
|
const logger = ctx.logger
|
|
|
+ const tools = ctx.tools
|
|
|
|
|
|
// Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId
|
|
|
// reverse map so `agent/*` events (which carry only the Agent) demux in O(1).
|
|
|
@@ -270,7 +281,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
ctx.on('session/event', (session, event: SessionEvent) => {
|
|
|
const rec = sessions.get(session.header.id)
|
|
|
if (rec === undefined) return
|
|
|
- streamSessionEventUpdate(rec.sessionId, event, notify)
|
|
|
+ streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter)
|
|
|
const inflight = rec.inflight
|
|
|
if (inflight === undefined) return
|
|
|
if (event.type === 'turn/start') {
|
|
|
@@ -395,7 +406,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
agentOptions: agentOptions(config),
|
|
|
})
|
|
|
bySession.set(agent, sessionId)
|
|
|
- sessions.set(sessionId, { sessionId, agent, inflight: undefined })
|
|
|
+ sessions.set(sessionId, { sessionId, agent, presenter: new ToolPresenter(tools), inflight: undefined })
|
|
|
return Promise.resolve({ sessionId })
|
|
|
},
|
|
|
|
|
|
@@ -448,14 +459,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|
|
throw invalidParams('connection closed during session/load')
|
|
|
}
|
|
|
bySession.set(agent, params.sessionId)
|
|
|
- sessions.set(params.sessionId, { sessionId: params.sessionId, agent, inflight: undefined })
|
|
|
+ const record: SessionRecord = { sessionId: params.sessionId, agent, presenter: new ToolPresenter(tools), inflight: undefined }
|
|
|
+ sessions.set(params.sessionId, record)
|
|
|
// Replay the persisted event log to the client as session/update. Use
|
|
|
// the raw event log (NOT deriveMessages, which drops assistant/chunk
|
|
|
// and trace events): RFC 010's load contract reconstructs the streamed
|
|
|
// turns — user prompts (user/message → user_message_chunk), assistant
|
|
|
- // text and reasoning (assistant/chunk), and tool calls/results.
|
|
|
+ // text and reasoning (assistant/chunk), and tool calls/results. The
|
|
|
+ // record's presenter pairs each tool/call with its tool/result as the
|
|
|
+ // log replays in order, so the replayed tool cards render identically
|
|
|
+ // to the live ones.
|
|
|
for (const event of agent.session.events) {
|
|
|
- streamSessionEventUpdate(params.sessionId, event, notify)
|
|
|
+ streamSessionEventUpdate(params.sessionId, event, notify, record.presenter)
|
|
|
}
|
|
|
return {}
|
|
|
} finally {
|
|
|
@@ -659,6 +674,14 @@ function validateWorkspaceParams(params: { cwd: string; additionalDirectories?:
|
|
|
* - `tool/call` → `tool_call` (pending)
|
|
|
* - `tool/result` → `tool_call_update` (completed/failed)
|
|
|
*
|
|
|
+ * Tool-call presentation (title/kind/rawInput, and the completed-state content)
|
|
|
+ * is owned by each TOOL via `presentCall`/`presentResult` — the bridge never
|
|
|
+ * special-cases tool names. `presenter` resolves those from the tool registry
|
|
|
+ * and remembers each call's `(name, args)` so the completed `tool/result` (which
|
|
|
+ * carries neither) can find its tool. A {@link nullToolPresenter} gives the
|
|
|
+ * generic fallback (title = tool name, raw args as input) when no registry is
|
|
|
+ * available (e.g. pure translator tests).
|
|
|
+ *
|
|
|
* Other event types (turn/step boundaries, context/message, usage, …) produce
|
|
|
* no client update.
|
|
|
*/
|
|
|
@@ -666,6 +689,7 @@ export function streamSessionEventUpdate(
|
|
|
sessionId: string,
|
|
|
event: SessionEvent,
|
|
|
notify: (notification: SessionNotification) => void,
|
|
|
+ presenter: Pick<ToolPresenter, 'call' | 'result'> = nullToolPresenter,
|
|
|
): void {
|
|
|
switch (event.type) {
|
|
|
case 'assistant/chunk': {
|
|
|
@@ -690,27 +714,30 @@ export function streamSessionEventUpdate(
|
|
|
return
|
|
|
}
|
|
|
case 'tool/call': {
|
|
|
+ const present = presenter.call(event.data.callId, event.data.name, event.data.arguments)
|
|
|
notify({
|
|
|
sessionId,
|
|
|
update: {
|
|
|
sessionUpdate: 'tool_call',
|
|
|
toolCallId: event.data.callId,
|
|
|
- title: event.data.name,
|
|
|
- kind: toolKindFor(event.data.name),
|
|
|
+ title: present.title,
|
|
|
+ kind: present.kind,
|
|
|
status: 'in_progress',
|
|
|
- rawInput: parseToolArguments(event.data.arguments),
|
|
|
+ ...present.rawInput !== undefined ? { rawInput: present.rawInput } : {},
|
|
|
},
|
|
|
})
|
|
|
return
|
|
|
}
|
|
|
case 'tool/result': {
|
|
|
+ const present = presenter.result(event.data.callId, event.data.content, event.data.isError)
|
|
|
notify({
|
|
|
sessionId,
|
|
|
update: {
|
|
|
sessionUpdate: 'tool_call_update',
|
|
|
toolCallId: event.data.callId,
|
|
|
status: event.data.isError ? 'failed' : 'completed',
|
|
|
- content: toolResultContent(event.data.content),
|
|
|
+ content: toolResultContent(present.content),
|
|
|
+ ...present.title !== undefined ? { title: present.title } : {},
|
|
|
},
|
|
|
})
|
|
|
return
|
|
|
@@ -722,8 +749,84 @@ export function streamSessionEventUpdate(
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * Resolved pending-state presentation the bridge feeds into a `tool_call`
|
|
|
+ * update: a title is always present (tool name when the tool gives none), `kind`
|
|
|
+ * and `rawInput` are optional.
|
|
|
+ */
|
|
|
+interface ResolvedCallPresentation {
|
|
|
+ title: string
|
|
|
+ kind: ToolCallKind
|
|
|
+ rawInput?: unknown
|
|
|
+}
|
|
|
+
|
|
|
+/** Resolved completed-state presentation fed into a `tool_call_update`. */
|
|
|
+interface ResolvedResultPresentation {
|
|
|
+ /** UI content for the result (harness blocks; the tool may reformat, else the raw result). */
|
|
|
+ content: ContentBlock[]
|
|
|
+ /** Optional replacement title for the completed call. */
|
|
|
+ title?: string
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Resolves tool-owned presentation for a session's tool-call events. A tool
|
|
|
+ * declares `presentCall`/`presentResult` (see `dsh-tools`); this looks them up
|
|
|
+ * by name in the registry and applies the generic fallback when a tool defines
|
|
|
+ * neither.
|
|
|
+ *
|
|
|
+ * The `tool/result` session event carries only `{ callId, content, isError }` —
|
|
|
+ * NOT the tool name or args — so to call a tool's `presentResult` (which needs
|
|
|
+ * both), the presenter remembers each `tool/call`'s `{ name, args }` keyed by
|
|
|
+ * callId and looks it up on the matching result. The map is bridge-LOCAL (not a
|
|
|
+ * change to the event schema or a core service): one presenter per live session
|
|
|
+ * (and a throwaway per `session/load` replay), entries removed as each result
|
|
|
+ * arrives, so it holds only the currently-in-flight calls.
|
|
|
+ */
|
|
|
+export class ToolPresenter {
|
|
|
+ private readonly pending = new Map<string, { name: string; args: unknown }>()
|
|
|
+
|
|
|
+ constructor(private readonly tools: Pick<ToolRegistry, 'get'>) {}
|
|
|
+
|
|
|
+ /** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */
|
|
|
+ call(callId: string, name: string, argsJson: string): ResolvedCallPresentation {
|
|
|
+ const args = parseToolArguments(argsJson)
|
|
|
+ this.pending.set(callId, { name, args })
|
|
|
+ const present = this.tools.get(name)?.presentCall?.(args)
|
|
|
+ if (present === undefined) {
|
|
|
+ // No tool-owned presentation: fall back to the tool name as the title and
|
|
|
+ // the full parsed args as the raw input (the pre-seam behavior).
|
|
|
+ return { title: name, kind: toolKindFor(name), rawInput: args }
|
|
|
+ }
|
|
|
+ return { title: present.title, kind: present.kind ?? 'other', rawInput: present.rawInput }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */
|
|
|
+ result(callId: string, content: ContentBlock[], isError: boolean): ResolvedResultPresentation {
|
|
|
+ const call = this.pending.get(callId)
|
|
|
+ this.pending.delete(callId)
|
|
|
+ const present = call !== undefined
|
|
|
+ ? this.tools.get(call.name)?.presentResult?.(call.args, { content, isError })
|
|
|
+ : undefined
|
|
|
+ if (present === undefined) return { content }
|
|
|
+ return {
|
|
|
+ content: present.content ?? content,
|
|
|
+ ...present.title !== undefined ? { title: present.title } : {},
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * The no-op presenter used when no tool registry is available (e.g. the pure
|
|
|
+ * translator tests): every tool gets the generic fallback presentation, and
|
|
|
+ * results pass their raw content through unchanged.
|
|
|
+ */
|
|
|
+export const nullToolPresenter: Pick<ToolPresenter, 'call' | 'result'> = {
|
|
|
+ call: (_callId, name, argsJson) => ({ title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }),
|
|
|
+ result: (_callId, content) => ({ content }),
|
|
|
+}
|
|
|
+
|
|
|
/** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */
|
|
|
-function toolKindFor(name: string): 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other' {
|
|
|
+function toolKindFor(name: string): ToolCallKind {
|
|
|
if (name === 'bash' || name === 'bash_output' || name === 'bash_kill') return 'execute'
|
|
|
if (name === 'read' || name.startsWith('read')) return 'read'
|
|
|
if (name === 'write' || name === 'edit' || name.startsWith('edit')) return 'edit'
|