docs: rebalance prose cleanup and add trimming skill

This commit is contained in:
Tianyi Cui
2026-07-13 23:27:00 +08:00
parent fcdc318dda
commit 148046b9c8
392 changed files with 2801 additions and 1754 deletions

View File

@@ -1,5 +1,5 @@
/**
* Pure translation between harness vocabulary and ACP wire types.
* Pure, total translation between harness vocabulary and ACP wire types.
* @module @deepseek-ai/dsh-acp/codec
*/
@@ -10,6 +10,11 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
/**
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
*
* `completed` and the defensive `error` case map to `end_turn`;
* `max-tokens` maps to `max_tokens`; `aborted`, `disposed`, and `rejected` map
* to `cancelled`. The bridge rejects error turns before this mapping. Unknown
* merge-extensible kinds use legal fallback `end_turn` rather than breaking
* the prompt RPC.
* @param reason - the harness turn-end reason to translate.
* @returns the legal ACP wire value per the mapping above.
*/

View File

@@ -72,7 +72,7 @@ import {
} from './codec.ts'
export const name = 'acp'
// Interface services required by advertised ACP capabilities.
// Interface services required by advertised load, presentation, and interaction capabilities.
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
/** Build an ACP invalid-params error with visible human detail. */
@@ -80,7 +80,7 @@ function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
/** Build an ACP internal error with visible human detail. */
/** Build an ACP internal error with visible detail; plain handler errors are flattened on wire. */
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
}
@@ -570,8 +570,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
})
// Creation is now asynchronous because it awaits the unpublished setup
// transaction. A client disconnect can therefore close this bridge
// 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.
/* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC

View File

@@ -24,7 +24,8 @@ describe('acp bridge — disposal & HMR safety', () => {
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
// A resolved teardown is the quiescence boundary.
// Teardown must abort and await the loop: once it resolves the agent is settled, and the
// hanging prompt itself completes as cancelled rather than remaining pending.
await harness.ctx.fiber.dispose()
expect(agent.status).not.toBe('running')
@@ -33,7 +34,8 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => {
// An ACP-only unload must close creation while shared services remain live.
// Unload only the bridge while transport and shared services remain live. Its closed guard must
// reject late creation before an orphan agent can enter the registry.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const before = harness.ctx.agents.list().length
@@ -45,7 +47,8 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
// The caller fiber owns agents created through its traced service proxy.
// The traced service proxy binds loop registration to the caller (bridge) fiber. ACP-only
// disposal must therefore reclaim the agent even while agent-loop itself remains mounted.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -57,7 +60,8 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
// Assert registry state because the closed transport rejects the RPC.
// Disconnect sets the closed guard and severs the RPC, so registry state—not the rejection
// shape—proves a late request did not create an undriveable agent.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const before = harness.ctx.agents.list().length
@@ -69,7 +73,8 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => {
// Disconnect must dispose, not merely idle, the owned agent.
// Disconnect mid-stream must dispose, not merely idle, the owned agent; otherwise updates would
// be swallowed while a registered session survived without a client.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -83,7 +88,8 @@ describe('acp bridge — disposal & HMR safety', () => {
await agent.whenIdle()
expect(agent.status).toBe('disposed')
// The shared bridge teardown also removes registry state.
// Await the same memoized bridge teardown without removing root services. It must finish the
// AgentHandle teardown and remove both registry records, not just stop the loop.
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
@@ -91,7 +97,8 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => {
// Both teardown callers must await the same quiescence boundary.
// Transport close and fiber disposal can race. Both must await one memoized teardown; a guard
// based only on record removal could let the second caller return while the first still drains.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -122,7 +129,8 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => {
// Reload from storage to verify final flush precedes session detach.
// AgentHandle teardown stops and awaits the loop, flushes through still-attached store hooks,
// then detaches the session. Reloading verifies that order from durable state.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -141,7 +149,8 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => {
// A mid-turn dispose must flush its real closer before detaching storage.
// Here disposal itself makes the loop append `turn/end {disposed}` and flush. Reload must find
// that real closer, not crash recovery's synthetic `interrupted`, proving detach ran last.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -162,7 +171,8 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('per-session AgentHandle dispose leaves sibling agents untouched', async () => {
// Dispose one handle and assert the sibling remains published.
// A per-session handle owns exactly one agent and session. Dispose A and assert B remains fully
// published, which guards against context-wide teardown.
const harness = await makeBridgeHarness({ storageDir, script: [] })
const handleA = await harness.ctx.agents.create({
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
@@ -184,7 +194,8 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => {
// Listener failure cannot skip the later session-detach disposer.
// Composite disposers run in sequence. A throwing `agent/disposed` listener must be contained or
// it would skip later session detach, leaking publication hooks and creating a durability hole.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
const handle = await harness.ctx.agents.create({
@@ -201,12 +212,14 @@ describe('acp bridge — disposal & HMR safety', () => {
})
it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => {
// Concurrent callers must share the in-flight teardown promise.
// The Cordis effect disposer is single-shot and would let a second call return after its epoch
// clears. AgentHandle must memoize the whole async teardown so every caller awaits quiescence.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
const handle = await harness.ctx.agents.create({
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
})
// Gate the final flush to keep teardown observably in flight.
// A hanging turn makes disposal produce a final flush; gate it so the second call arrives while
// teardown is observably in flight.
handle.agent.send([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
expect(handle.agent.status).toBe('running')

View File

@@ -1,5 +1,7 @@
/**
* Shared test fixtures for the ACP bridge specs.
* Shared non-spec fixture that mounts the full in-memory agent/persistence stack and connects the
* ACP bridge to a real SDK client over memory streams. Tests exercise the same protocol path as an
* editor without a subprocess or stdio.
*/
import { Context } from 'cordis'
@@ -213,7 +215,8 @@ export async function makeBridgeHarness(options: {
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the agent writes flow
// to the client's reader and vice versa. (ndJsonStream takes (output, input): the agent
// writes to a2c and reads from c2a; the client writes to c2a and reads from a2c.)
// writes to a2c and reads from c2a; the client writes to c2a and reads from a2c.) Holding the c2a
// writer lets tests EOF the agent reader and simulate editor disconnect.
const a2c = new TransformStream<Uint8Array, Uint8Array>()
const c2a = new TransformStream<Uint8Array, Uint8Array>()
const c2aWriter = c2a.writable.getWriter()
@@ -268,16 +271,19 @@ export async function makeBridgeHarness(options: {
},
})
// Wire the bridge (agent side) and the client (test side).
// Default to `mock` only when the caller omitted the key; explicit `model: undefined` means no
// model and must survive the object spread.
const cfg: AcpConfig = { stream: agentStream, ...options.config }
if (!(options.config && 'model' in options.config)) cfg.model = 'mock'
// 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.
// real `inject`), not `AcpPlugin.apply(ctx, cfg)` on the ungated root. Later JSON-RPC callbacks run
// outside apply's injection scope, matching production and exposing missing-inject failures.
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).
// hardcoded list silently broke when `tools` was added). The returned fiber permits ACP-only
// disposal while root services remain live for HMR assertions.
inject: [...AcpPlugin.inject],
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
})

View File

@@ -58,7 +58,8 @@ describe('acp bridge — session/load replay', () => {
})
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.
// Persist a real bash call, then replay it through a fresh bridge. A throwaway presenter pairs
// call and result in log order so replay uses the shipping tool's same cards as live streaming.
live = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -90,7 +91,8 @@ describe('acp bridge — session/load replay', () => {
})
it('replays a persisted todo/write as a plan sessionUpdate on load', async () => {
// A turn whose model called todo_write persists a todo/write event.
// A persisted `todo/write` must replay as an ACP plan update so a reopened editor sees the
// current plan, not just the tool transcript.
live = await makeBridgeHarness({
storageDir,
withTodo: true,
@@ -161,7 +163,8 @@ describe('acp bridge — session/load replay', () => {
})
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.
// Stall persistence so transport closes while resume is pending. Whether the SDK rejects first
// or the bridge's post-await guard fires, no agent may survive for the dead connection.
live = await makeBridgeHarness({ storageDir, script: [textResponse('x')] })
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
@@ -187,7 +190,8 @@ describe('acp bridge — session/load replay', () => {
it('rejects load when the requested cwd does not match the persisted session cwd', async () => {
// Seed a session on disk whose header.cwd is a DIFFERENT absolute path than the server's
// launch dir.
// launch dir. Resume must retain the header cwd and route bash there rather than reject the
// mismatch or substitute the server cwd.
loader = await makeBridgeHarness({ storageDir, script: [] })
const otherCwd = '/some/other/workspace'
await loader.ctx.sessionPersistence.create({
@@ -223,7 +227,8 @@ describe('acp bridge — session/load replay', () => {
})
it('rejects loading a persisted session that has NO cwd (would silently run in the launch dir)', async () => {
// A legacy / externally-created session log with no header.cwd.
// A legacy/external log without `header.cwd` must be rejected; the request cwd does not override
// it, and accepting would let bash silently fall back to the server launch directory.
loader = await makeBridgeHarness({ storageDir, script: [] })
await loader.ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION, id: SessionId('legacy'), createdAt: 1, // no cwd

View File

@@ -1,7 +1,9 @@
/**
* Property-based protocol-shape tests for the ACP update stream (RFC 001 → ADR 0013
* precedent). Fuzz arbitrary harness `SessionEvent` sequences through the pure
* `streamSessionEventUpdate` translator and assert the invariants an ACP client relies on.
* `streamSessionEventUpdate` translator and assert legal update variants, call-before-result order
* per tool id, and deterministic event-to-update translation. Keeping this pure makes live and
* replay equivalence deterministic rather than a timing property.
*/
import { describe, expect, it } from 'vitest'

View File

@@ -289,7 +289,8 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
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 (docs/defensive-patterns.md "contain callback exceptions at the boundary").
// replay (docs/defensive-patterns.md "contain callback exceptions at the boundary"). The
// presenter reports the error and falls back to generic rendering.
const boom: ToolDefinition = {
name: 'boom',
description: 'b',
@@ -622,7 +623,7 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
// Drive the SHIPPING fs edit tool through the bridge: the pending tool/call installs the
// call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`,
// which presentResult narrows into a `diff` result card the bridge forwards as `{ type:
// 'diff' }` content blocks.
// 'diff' }` content blocks. The real tool is required because its result metadata is the contract.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -676,7 +677,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
it('the completed diff TITLE relativizes against the session cwd (the result title replaces the card header)', async () => {
// A `tool_call_update.title` replaces the card header, so the result-side diff must
// relativize its title exactly as the pending card did — otherwise a completed
// absolute-path edit flips `Edit src/b.ts` back to the raw absolute path.
// absolute-path edit flips `Edit src/b.ts` back to the raw absolute path. Diff and location
// paths remain absolute so the editor can open the real file.
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' })
@@ -698,7 +700,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
})
it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => {
// A synthetic empty diff covers branches shipping filesystem tools cannot emit.
// Shipping edit always has a hunk and write falls back to a whole-file diff, so a synthetic
// tool is required to cover both absent-title and empty-content result branches.
const emptyDiffTool: ToolDefinition = {
name: 'writer',
description: 'writes a file',
@@ -725,7 +728,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => {
// The bridge relativizes a file card's TITLE against the session workspace cwd (mirroring the
// reference adapter's toDisplayPath), while leaving locations/ diff paths RAW.
// reference adapter's `toDisplayPath`), while leaving location/diff paths raw. Use real fs tools
// and the absolute paths an editor supplies; presentation itself is args-only and lacks cwd.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -777,7 +781,8 @@ describe('relative-path display titles (bridge relativizes the title against the
it('an in-workspace file whose relative form starts with `..` chars (a sibling name) still relativizes', async () => {
// `/work/proj/..cache/x` is inside the workspace — its relative form `..cache/x` begins
// with the chars `..` but is not a parent segment.
// with the chars `..` but is not a parent segment. Segment-aware guarding must relativize it,
// matching targets under `cwd + sep` in the reference adapter.
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' })
expect((update as { title: string }).title).toBe('Read ..cache/x.ts')

View File

@@ -124,7 +124,9 @@ describe('acp bridge — turn outcomes', () => {
})
it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => {
// Terminal capability moves output to card metadata.
// With terminal output advertised, a real bash call emits description then terminal content
// plus cwd metadata; its result uses terminal output/exit metadata and omits text that would
// clobber the card.
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -160,7 +162,8 @@ describe('acp bridge — turn outcomes', () => {
})
it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => {
// Session creation snapshots the capability for both call and result.
// Create the session with terminal support, then disable it connection-wide. The session's
// snapshot must keep call and result rendering consistent instead of re-reading changed state.
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -316,7 +319,9 @@ describe('acp bridge — turn outcomes', () => {
})
it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => {
// The cancelled prompt must not leave queued work for another turn.
// JSON-RPC timing normally makes this a running mid-step cancellation; pre-step dropping is
// covered in agent-loop. Here the prompt must settle cancelled, return idle, and clear queued
// work so the scripted second response cannot leak into another turn.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] })
const sessionId = await newSession(harness)
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
@@ -330,7 +335,8 @@ describe('acp bridge — turn outcomes', () => {
})
it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => {
// Exercise cancel→prompt without an intervening quiescence wait.
// The bridge settles cancel synchronously, so exercise the production cancel→prompt race with
// no `whenIdle()`. An idle cancel must not mark or drop the following prompt.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
// Cancel while idle (no prompt in flight) — a no-op.
@@ -346,7 +352,8 @@ describe('acp bridge — turn outcomes', () => {
})
it('mid-stream cancel then an IMMEDIATE next prompt runs (no intervening whenIdle)', async () => {
// A cancel marker must not leak onto an immediate next prompt.
// Cancel a running turn and immediately send another prompt without awaiting quiescence. The
// cancellation marker belongs only to the first turn and must not drop the next request.
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('next answer')] })
const sessionId = await newSession(harness)
const a = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'A' }] })
@@ -364,7 +371,8 @@ describe('acp bridge — turn outcomes', () => {
})
it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => {
// Correlation must keep A's late turn/end from settling B.
// Cancellation frees A's slot before its aborted turn/end is appended. Send B in that window;
// correlation by turn number must prevent A's late closer from settling B as cancelled.
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] })
const sessionId = await newSession(harness)
@@ -373,7 +381,7 @@ describe('acp bridge — turn outcomes', () => {
await harness.client.cancel({ sessionId })
expect((await a).stopReason).toBe('cancelled')
// B owns a later turn number than A.
// B owns the later turn and must complete on its own turn/end.
const b = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'B' }] })
expect(b.stopReason).toBe('end_turn')
const text = harness.updates