Merge remote-tracking branch 'origin/master' into codex/pr48-repo-hardening-rfcs
# Conflicts: # docs/adr/README.md # docs/rfc/009-session-persistence-and-resumability.md # docs/rfc/README.md # docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md # docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md # examples/acp-agent/tests/acp.e2e.ts # packages/acp/README.md # packages/acp/src/index.ts # packages/acp/tests/stream-update.spec.ts # packages/agent-loop/src/loop.ts # packages/tools/src/index.ts
This commit is contained in:
@@ -38,7 +38,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// stay up and the transport is still live. A late session/new must hit the
|
||||
// `closed` guard and reject — NOT create an agent the disposed bridge can no
|
||||
// longer stream or settle. Verify the world: no agent appeared.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [], childFiber: true })
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const before = harness.ctx.agents.list().length
|
||||
await harness.acpFiber.dispose() // tear down ONLY the bridge
|
||||
@@ -48,6 +48,25 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
|
||||
// The factory (`ctx.agents.create`) is reached through the bridge's
|
||||
// traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)`
|
||||
// registration binds to the CALLER context — the bridge fiber — not the
|
||||
// AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload)
|
||||
// must therefore reclaim the agent's registry entry, even though agents/
|
||||
// agent-loop stay up. This pins the fiber-ownership the bridge's teardown
|
||||
// doc comment relies on; if a refactor rebinds the registration to the
|
||||
// AgentLoop fiber, the agent would survive bridge dispose and this fails.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(harness.ctx.agents.get(sessionId)).toBeDefined()
|
||||
|
||||
await harness.acpFiber.dispose() // tear down ONLY the bridge
|
||||
expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
|
||||
// After teardown (here a client disconnect sets `closed`), a late
|
||||
// `session/new` must NOT create an orphan agent the bridge can no longer
|
||||
|
||||
@@ -18,6 +18,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
@@ -148,8 +150,14 @@ export async function makeBridgeHarness(options: {
|
||||
script?: (StreamChunk[] | 'hang')[]
|
||||
config?: Partial<AcpConfig>
|
||||
storageDir: string
|
||||
/** Mount the bridge in a disposable child fiber (for the ACP-only-HMR test). */
|
||||
childFiber?: boolean
|
||||
/**
|
||||
* Plug the REAL `dsh-bash-local` executor + `dsh-tool-bash` tools (instead of
|
||||
* a test's own inline tool). Lets a test drive the actual `bash` tool — its
|
||||
* real `presentCall`/`presentResult` — through the bridge, so tool-call UI
|
||||
* tests verify the SHIPPING tool, not a stand-in (AGENTS.md "prefer the real
|
||||
* implementation over a mock in tests").
|
||||
*/
|
||||
withBash?: boolean
|
||||
} = { storageDir: '' }): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
|
||||
@@ -161,6 +169,10 @@ export async function makeBridgeHarness(options: {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
|
||||
if (options.withBash) {
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
}
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the
|
||||
@@ -225,21 +237,24 @@ export async function makeBridgeHarness(options: {
|
||||
// override means "no model at all".
|
||||
const cfg: AcpConfig = { stream: agentStream, ...options.config }
|
||||
if (!(options.config && 'model' in options.config)) cfg.model = 'mock'
|
||||
// By default apply the bridge directly on the root ctx (services ungated). For
|
||||
// the ACP-only-HMR test, `childFiber: true` mounts it in a CHILD fiber instead
|
||||
// so the test can dispose JUST the bridge while the rest of the harness stays
|
||||
// up — its disposer (`harness.acpFiber.dispose()`) tears down only the
|
||||
// bridge's listeners/effect. (Child-fiber service tracing gates the async
|
||||
// persistence path, so the load-replay tests use the default direct mount.)
|
||||
if (options.childFiber) {
|
||||
harness.acpFiber = await ctx.plugin({
|
||||
name: 'acp-test',
|
||||
inject: ['agents', 'sessions', 'sessionPersistence'],
|
||||
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
|
||||
})
|
||||
} else {
|
||||
AcpPlugin.apply(ctx, cfg)
|
||||
}
|
||||
// Mount the bridge the way production does: as a cordis PLUGIN (via
|
||||
// `ctx.plugin` with the real `inject`), NOT `AcpPlugin.apply(ctx, cfg)`
|
||||
// directly on the root ctx. The plugin fiber is the faithful reproduction —
|
||||
// the bridge's `apply` runs inside the fiber's injection scope, and its ACP
|
||||
// handlers later run from the JSON-RPC read loop OUTSIDE that scope, exactly
|
||||
// as under the example's cordis.yml. (Mounting directly on root made every
|
||||
// service an ungated property and hid the "cannot get property … without
|
||||
// inject" failure that bit a real Zed session.) `harness.acpFiber.dispose()`
|
||||
// tears down JUST the bridge (its listeners + effect) for the HMR test.
|
||||
harness.acpFiber = await ctx.plugin({
|
||||
name: 'acp-test',
|
||||
// Use the bridge's REAL exported `inject` so this never drifts from the
|
||||
// plugin's actual dependency list (adding a service to the bridge must not
|
||||
// require editing the harness — a hardcoded list silently broke when `tools`
|
||||
// was added). The bridge programs against the interface packages only.
|
||||
inject: [...AcpPlugin.inject],
|
||||
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
|
||||
})
|
||||
harness.client = new ClientSideConnection(makeClient, clientStream)
|
||||
|
||||
return harness
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
|
||||
/** Concatenate the text of all agent_message_chunk updates. */
|
||||
function messageText(updates: CapturedUpdate[]): string {
|
||||
@@ -56,6 +56,79 @@ describe('acp bridge — session/load replay', () => {
|
||||
expect(userText).toBe('remember this')
|
||||
})
|
||||
|
||||
it('replays a persisted tool call with the TOOL-OWNED presentation (title/rawInput/console output)', async () => {
|
||||
// A turn with a REAL bash tool call is persisted, then loaded by a fresh
|
||||
// bridge. The replayed tool_call/tool_call_update must carry the tool's OWN
|
||||
// presentation — identical to how it streamed live — via a throwaway
|
||||
// presenter that pairs call→result as the log replays in order. Uses the
|
||||
// shipping tool (withBash), not a stand-in (AGENTS.md "prefer the real
|
||||
// implementation over a mock in tests").
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hello', description: 'Print a greeting' }), textResponse('done')],
|
||||
})
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
// A fresh bridge — also with the real bash tool, since the presentation is
|
||||
// resolved from the live registry at replay time — loads the session.
|
||||
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
expect(call).toMatchObject({ toolCallId: 'c1', title: 'echo hello', kind: 'execute', rawInput: 'echo hello' })
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Capability OFF on this loader: the description renders as a content block, no terminal block.
|
||||
expect(call.content).toEqual([{ type: 'content', content: { type: 'text', text: 'Print a greeting' } }])
|
||||
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(update?.sessionUpdate).toBe('tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
const content = update.content as { content: { text: string } }[]
|
||||
expect(content[0]?.content.text).toBe('```console\nhello\n```')
|
||||
})
|
||||
|
||||
it('replays a persisted bash call as a TERMINAL card when the loader advertises the capability', async () => {
|
||||
// The presentation is resolved at replay time, so a loader that advertised
|
||||
// _meta.terminal_output must reconstruct the terminal card (content + _meta)
|
||||
// from the persisted log — identical to how it would have streamed live.
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
|
||||
})
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Replay reconstructs the terminal card: description block, then terminal block.
|
||||
expect(call.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'Greet' } },
|
||||
{ type: 'terminal', terminalId: 'c1' },
|
||||
])
|
||||
expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
|
||||
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
// Terminal mode: content omitted, output + exit on _meta — matching live.
|
||||
expect(update.content).toBeUndefined()
|
||||
const meta = update._meta as { terminal_output?: { data: string }; terminal_exit?: { exit_code?: number } }
|
||||
expect(meta.terminal_output?.data).toBe('hi\n')
|
||||
expect(meta.terminal_exit?.exit_code).toBe(0)
|
||||
})
|
||||
|
||||
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
|
||||
// A session/load is mid-resume() when the client transport closes. The load
|
||||
// must NOT end up with a live registered agent for the connection that is
|
||||
|
||||
@@ -2,21 +2,29 @@ import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import { streamSessionEventUpdate, agentOptions } from '../src/index.ts'
|
||||
import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools'
|
||||
import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index.ts'
|
||||
|
||||
/** Collect the updates a single event produces. */
|
||||
/** Collect the updates a single event produces (no presenter → generic fallback). */
|
||||
function updatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
streamSessionEventUpdate('s1', event, n => out.push(n.update))
|
||||
return out
|
||||
}
|
||||
|
||||
/** Collect the updates emitted by the live prompt stream (user echo suppressed). */
|
||||
function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
streamSessionEventUpdate('s1', event, n => out.push(n.update), { includeUserMessages: false })
|
||||
streamSessionEventUpdate('s1', event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false })
|
||||
return out
|
||||
}
|
||||
|
||||
/** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */
|
||||
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistry, 'get'> {
|
||||
const map = new Map(tools.map(t => [t.name, t]))
|
||||
return { get: name => map.get(name) }
|
||||
}
|
||||
|
||||
function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent, { type: T }>['data']): SessionEvent {
|
||||
return { type, seq: 0, time: 0, data } as SessionEvent
|
||||
}
|
||||
@@ -37,7 +45,7 @@ describe('streamSessionEventUpdate', () => {
|
||||
.toEqual([])
|
||||
})
|
||||
|
||||
it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput', () => {
|
||||
it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput (generic fallback, no presenter)', () => {
|
||||
const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }))
|
||||
expect(updates).toEqual([{
|
||||
sessionUpdate: 'tool_call',
|
||||
@@ -112,6 +120,291 @@ describe('streamSessionEventUpdate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolPresenter (tool-owned presentation via the tool registry)', () => {
|
||||
/** A tool whose presentCall/presentResult mirror what tool-bash declares. */
|
||||
const bashLike: ToolDefinition = {
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: (args: unknown) => {
|
||||
const a = args as { command: string; description: string }
|
||||
return { title: a.description, kind: 'execute', rawInput: a.command }
|
||||
},
|
||||
presentResult: (_args: unknown, result: { content: { type: string }[] }) => ({
|
||||
content: [{ type: 'text', text: `wrapped:${result.content.length}` }],
|
||||
}),
|
||||
}
|
||||
|
||||
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter)
|
||||
return out
|
||||
}
|
||||
|
||||
it('tool/call uses the tool: description→title, command→rawInput, tool kind', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
const [update] = updatesWith(presenter, evt('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), name: 'bash',
|
||||
arguments: JSON.stringify({ command: 'ls -la', description: 'List files' }),
|
||||
}))
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
title: 'List files',
|
||||
kind: 'execute',
|
||||
status: 'in_progress',
|
||||
rawInput: 'ls -la',
|
||||
})
|
||||
})
|
||||
|
||||
it('tool/result uses the tool to reformat content (resolved by the remembered tool/call)', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }),
|
||||
)
|
||||
expect(updates[1]).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'wrapped:1' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('a result with NO preceding call (unknown callId) falls back to the raw content', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
// No tool/call for c9 → presenter has nothing remembered → generic fallback.
|
||||
const [update] = updatesWith(presenter, evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c9'), content: [{ type: 'text', text: 'raw' }], isError: false,
|
||||
}))
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c9',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'raw' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('a tool with no presentCall/presentResult gets the generic fallback (title = name)', () => {
|
||||
const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, execute: async () => [] }
|
||||
const presenter = new ToolPresenter(registryOf(plain))
|
||||
const [update] = updatesWith(presenter, evt('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), name: 'plain', arguments: '{"a":1}',
|
||||
}))
|
||||
expect(update).toMatchObject({ title: 'plain', kind: 'other', rawInput: { a: 1 } })
|
||||
})
|
||||
|
||||
it('a presentation that omits kind/content/rawInput uses the defaults (kind other, raw result content kept)', () => {
|
||||
// A minimal tool-owned presentation: presentCall returns only a title (no
|
||||
// kind → defaults to `other`, no rawInput → omitted); presentResult returns
|
||||
// only a title (no content → the raw result content is kept).
|
||||
const minimal: ToolDefinition = {
|
||||
name: 'mini',
|
||||
description: 'm',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: () => ({ title: 'Doing a thing' }),
|
||||
presentResult: () => ({ title: 'Did the thing' }),
|
||||
}
|
||||
const presenter = new ToolPresenter(registryOf(minimal))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'mini', arguments: '{}' }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'kept' }], isError: false }),
|
||||
)
|
||||
// No kind → 'other'; no rawInput key at all.
|
||||
expect(updates[0]).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'Doing a thing', kind: 'other', status: 'in_progress' })
|
||||
// Title replaced; content falls back to the raw result content.
|
||||
expect(updates[1]).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'kept' } }],
|
||||
title: 'Did the thing',
|
||||
})
|
||||
})
|
||||
|
||||
it('holds ONLY in-flight calls: the callId entry is removed once its result is presented', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'o' }], isError: false }),
|
||||
)
|
||||
// A SECOND result for the same callId now finds nothing remembered, so it
|
||||
// falls back to raw content (proving the first result consumed the entry —
|
||||
// the map does not retain finished calls).
|
||||
const [late] = updatesWith(presenter, evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'late' }], isError: false,
|
||||
}))
|
||||
expect(late).toMatchObject({ content: [{ type: 'content', content: { type: 'text', text: 'late' } }] })
|
||||
})
|
||||
|
||||
it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => {
|
||||
// A buggy tool whose display callbacks throw must NOT fail a live turn or a
|
||||
// session/load replay (AGENTS.md "contain callback exceptions at the
|
||||
// boundary"). The presenter swallows the throw, reports via onError, and
|
||||
// falls back to the generic presentation.
|
||||
const boom: ToolDefinition = {
|
||||
name: 'boom',
|
||||
description: 'b',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: () => { throw new Error('call boom') },
|
||||
presentResult: () => { throw new Error('result boom') },
|
||||
}
|
||||
const errors: string[] = []
|
||||
const presenter = new ToolPresenter(registryOf(boom), msg => errors.push(msg))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{"a":1}' }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }),
|
||||
)
|
||||
// tool/call fell back to title=name, raw args as rawInput.
|
||||
expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom', kind: 'other', rawInput: { a: 1 } })
|
||||
// tool/result fell back to the raw content.
|
||||
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
|
||||
// Both throws were reported, not propagated.
|
||||
expect(errors).toHaveLength(2)
|
||||
expect(errors[0]).toContain('presentCall threw')
|
||||
expect(errors[1]).toContain('presentResult threw')
|
||||
})
|
||||
|
||||
it('contains a throwing presenter even with the DEFAULT (no-op) onError sink', () => {
|
||||
// Constructed without an onError sink (the default `() => {}`): a throwing
|
||||
// presenter is still swallowed and falls back generically — the absence of a
|
||||
// logger must not turn a display bug into a propagated exception.
|
||||
const boom: ToolDefinition = {
|
||||
name: 'boom',
|
||||
description: 'b',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: () => { throw new Error('call boom') },
|
||||
presentResult: () => { throw new Error('result boom') },
|
||||
}
|
||||
const presenter = new ToolPresenter(registryOf(boom))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{}' }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }),
|
||||
)
|
||||
expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom' })
|
||||
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal-card mapping (capability-gated)', () => {
|
||||
// A tool that asks to render as a terminal — a stand-in for tool-bash's shape,
|
||||
// letting us drive the bridge's terminal mapping without the real executor.
|
||||
type CallTerm = { cwd?: string } | undefined
|
||||
type ResultTerm = { output?: string; exitCode?: number; signal?: string } | undefined
|
||||
const termTool = (callTerminal: CallTerm, resultTerminal: ResultTerm): ToolDefinition => ({
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: (args: unknown) => ({
|
||||
title: (args as { command: string }).command,
|
||||
kind: 'execute',
|
||||
rawInput: (args as { command: string }).command,
|
||||
content: [{ type: 'text', text: (args as { description: string }).description }],
|
||||
...callTerminal !== undefined ? { terminal: callTerminal } : {},
|
||||
}),
|
||||
presentResult: () => ({
|
||||
content: [{ type: 'text', text: 'fallback' }],
|
||||
...resultTerminal !== undefined ? { terminal: resultTerminal } : {},
|
||||
}),
|
||||
})
|
||||
|
||||
const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) })
|
||||
const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false })
|
||||
|
||||
function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
|
||||
const presenter = new ToolPresenter(registryOf(tool))
|
||||
const out: SessionNotification['update'][] = []
|
||||
for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter, { enabled, cwd })
|
||||
return out
|
||||
}
|
||||
|
||||
it('capability ON: description content THEN terminal block; cwd from the session header when the tool gives none', () => {
|
||||
const [call, update] = termUpdates(termTool({}, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent)
|
||||
expect(call).toMatchObject({
|
||||
sessionUpdate: 'tool_call',
|
||||
content: [
|
||||
{ type: 'content', content: { type: 'text', text: 'Greet' } },
|
||||
{ type: 'terminal', terminalId: 'c1' },
|
||||
],
|
||||
_meta: { terminal_info: { terminal_id: 'c1', cwd: '/work/proj' } },
|
||||
})
|
||||
// The update OMITS content (it would clobber the terminal block) and carries output + exit.
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
_meta: { terminal_output: { terminal_id: 'c1', data: 'hi\n' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
|
||||
const [absCall] = termUpdates(termTool({ cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
|
||||
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
|
||||
const [relCall] = termUpdates(termTool({ cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent)
|
||||
// Relative workdir resolved against the session cwd — the card header matches
|
||||
// where execution actually ran (tool-bash resolves the same way).
|
||||
expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/work/proj/sub/dir')
|
||||
// No session cwd to resolve against → the relative tool cwd is passed through as-is.
|
||||
const [noSessionCwd] = termUpdates(termTool({ cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent)
|
||||
expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only')
|
||||
})
|
||||
|
||||
it('capability ON: a signal kill maps to terminal_exit.signal', () => {
|
||||
const [, update] = termUpdates(termTool({}, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent)
|
||||
expect((update as unknown as { _meta: { terminal_exit: unknown } })._meta.terminal_exit).toEqual({ terminal_id: 'c1', signal: 'SIGKILL' })
|
||||
})
|
||||
|
||||
it('capability ON: a terminal result with output but NO exit/signal emits terminal_output and NO exit pill', () => {
|
||||
// A terminal-rendering tool that reports no structured exit (neither exitCode
|
||||
// nor signal) — the card shows output but no exit pill.
|
||||
const [, update] = termUpdates(termTool({}, { output: 'partial' }), true, '/w', callEvent, resultEvent)
|
||||
const meta = (update as unknown as { _meta: { terminal_output?: unknown; terminal_exit?: unknown } })._meta
|
||||
expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'partial' })
|
||||
expect(meta.terminal_exit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('capability OFF: no terminal block or _meta; the description content and fenced result still render', () => {
|
||||
const [call, update] = termUpdates(termTool({}, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent)
|
||||
expect(call).toEqual({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
title: 'echo hi',
|
||||
kind: 'execute',
|
||||
status: 'in_progress',
|
||||
rawInput: 'echo hi',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'Greet' } }],
|
||||
})
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'fallback' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('orphan guard: a result-side terminal with NO call-side terminal is dropped (no orphan terminal_output)', () => {
|
||||
// presentCall declares NO terminal, but presentResult returns one — the
|
||||
// bridge must not emit _meta.terminal_output for a terminal Zed never made.
|
||||
const [call, update] = termUpdates(termTool(undefined, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent)
|
||||
// The call had no terminal → ordinary tool_call (description content, no _meta).
|
||||
expect((call as { _meta?: unknown })._meta).toBeUndefined()
|
||||
expect((call as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'Greet' } }])
|
||||
// The result falls back to text content; NO terminal _meta.
|
||||
expect((update as { _meta?: unknown })._meta).toBeUndefined()
|
||||
expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'fallback' } }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentOptions', () => {
|
||||
it('includes only the fields present in config', () => {
|
||||
expect(agentOptions({})).toEqual({})
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from './harness.ts'
|
||||
|
||||
/** Boilerplate: initialize + create one session, returning its id. */
|
||||
async function newSession(h: BridgeHarness): Promise<string> {
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
async function newSession(h: BridgeHarness, clientCapabilities: Record<string, unknown> = {}): Promise<string> {
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
return sessionId
|
||||
}
|
||||
@@ -75,6 +75,145 @@ describe('acp bridge — turn outcomes', () => {
|
||||
expect(callIdx).toBeLessThan(updIdx)
|
||||
})
|
||||
|
||||
it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => {
|
||||
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline
|
||||
// stand-in, so this verifies the actual presentCall/presentResult the editor
|
||||
// sees (AGENTS.md "prefer the real implementation over a mock in tests").
|
||||
// The mock MODEL still scripts the tool call (no real LLM needed), but the
|
||||
// tool and executor are real: a real `echo` runs and its real output flows
|
||||
// back through the bridge.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [
|
||||
toolCallResponse('c1', 'bash', { command: 'echo hello', description: 'Print a greeting' }),
|
||||
textResponse('done'),
|
||||
],
|
||||
})
|
||||
const sessionId = await newSession(harness)
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
|
||||
// presentCall: execute kind, title IS the command (an execute card hides
|
||||
// rawInput, so the command is the title), the description rides as a content
|
||||
// text block, the command is also rawInput for non-terminal UIs.
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
expect(call).toMatchObject({
|
||||
toolCallId: 'c1',
|
||||
title: 'echo hello',
|
||||
kind: 'execute',
|
||||
rawInput: 'echo hello',
|
||||
status: 'in_progress',
|
||||
})
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Capability OFF: the description renders as the only content block (no terminal block).
|
||||
expect(call.content).toEqual([{ type: 'content', content: { type: 'text', text: 'Print a greeting' } }])
|
||||
// presentResult: the REAL command output, wrapped in a fenced console block.
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(update?.sessionUpdate).toBe('tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
const content = update.content as { content: { type: string; text: string } }[]
|
||||
expect(content[0]?.content.text).toBe('```console\nhello\n```')
|
||||
// Capability OFF (the default newSession): NO terminal _meta on either update.
|
||||
expect((call as { _meta?: unknown })._meta).toBeUndefined()
|
||||
expect((update as { _meta?: unknown })._meta).toBeUndefined()
|
||||
})
|
||||
|
||||
it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => {
|
||||
// Drive the REAL bash tool, and advertise the Zed `_meta.terminal_output`
|
||||
// capability in initialize. The bridge must then emit the terminal CARD: the
|
||||
// description content block THEN a terminal content block + `_meta.terminal_info`
|
||||
// (cwd header) on the call, and `_meta.terminal_output`/`terminal_exit` on the
|
||||
// result — and OMIT the update's text content (it would clobber the card).
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
|
||||
})
|
||||
// Capability lives under clientCapabilities._meta.terminal_output.
|
||||
const sessionId = await newSession(harness, { _meta: { terminal_output: true } })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// The description content block FIRST (renders above the card), then a
|
||||
// terminal content block keyed by the callId; terminal_info carries the
|
||||
// session cwd (the bridge fills it from the session header).
|
||||
expect(call.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'Greet' } },
|
||||
{ type: 'terminal', terminalId: 'c1' },
|
||||
])
|
||||
expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
|
||||
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
// In terminal mode the text content is OMITTED (a tool_call_update.content
|
||||
// REPLACES the call's content — it would clobber the terminal block).
|
||||
expect(update.content).toBeUndefined()
|
||||
// Output rides on _meta.terminal_output; the parsed exit on _meta.terminal_exit.
|
||||
const meta = update._meta as {
|
||||
terminal_output?: { terminal_id: string; data: string }
|
||||
terminal_exit?: { terminal_id: string; exit_code?: number; signal?: string }
|
||||
}
|
||||
expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'hi\n' })
|
||||
expect(meta.terminal_exit).toEqual({ terminal_id: 'c1', exit_code: 0 })
|
||||
})
|
||||
|
||||
it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => {
|
||||
// The session is created with the capability ON. A SECOND initialize then
|
||||
// turns it OFF at the connection level — but this session keeps its snapshot,
|
||||
// so its bash call STILL renders as a terminal card (call + result agree).
|
||||
// Without the snapshot, the result path would re-read the now-OFF capability
|
||||
// and either clobber the card (content sent) or be inconsistent with the call.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
// A re-initialize that DROPS the capability after the session exists.
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Still a terminal card (the session's snapshot, not the mutated connection cap).
|
||||
expect((call._meta as { terminal_info?: unknown }).terminal_info).toBeDefined()
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
// The result AGREES with the call: terminal output present, content omitted.
|
||||
expect(update.content).toBeUndefined()
|
||||
expect((update._meta as { terminal_output?: unknown }).terminal_output).toBeDefined()
|
||||
})
|
||||
|
||||
it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => {
|
||||
// A buggy tool whose presentCall throws must not fail the live turn — the
|
||||
// bridge's presenter contains the throw (logging via its onError sink) and
|
||||
// falls back to the generic title=name presentation. Exercises the real
|
||||
// bridge wiring of the per-session presenter's error sink.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [toolCallResponse('c1', 'kaboom', { x: 1 }), textResponse('done')],
|
||||
})
|
||||
harness.ctx.tools.register(defineTool({
|
||||
name: 'kaboom',
|
||||
description: 'explodes when presented',
|
||||
parameters: { x: { type: 'number' } },
|
||||
async execute() { return [{ type: 'text', text: 'ok' }] },
|
||||
presentCall: () => { throw new Error('present boom') },
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('end_turn') // the turn completed despite the throw
|
||||
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
// Generic fallback: title is the tool name, raw args as rawInput.
|
||||
expect(call).toMatchObject({ toolCallId: 'c1', title: 'kaboom', kind: 'other', rawInput: { x: 1 } })
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
})
|
||||
|
||||
it('a failing tool yields a failed tool_call_update', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
|
||||
Reference in New Issue
Block a user