fix(acp): server crashed on connect — drop export default, read optional service cwd-independently

Two independent bugs made the ACP server crash the moment an editor (Zed)
connected, despite 178 green unit tests at 100% coverage:

1. `session/new` threw `cannot get property "agents" without inject`. Root
   cause: a stray `export default apply` made the cordis Loader's
   `unwrapExports` (`exports.default ?? exports`) collapse the module to the
   bare `apply` function, discarding the sibling `inject`/`name`/`Config`
   named exports. The plugin fiber was built with empty `inject`, so every
   `ctx.<service>` read in `apply` threw at load. Fix: remove the default
   export so the Loader uses the namespace.

2. `session/load` threw `cannot get property "sessionPersistence" without
   inject`. `AgentLoop.resume` read `this.ctx.sessionPersistence` (a service
   it deliberately does NOT inject); the property proxy's ancestor-only fiber
   walk fails through the bridge's traceable shadow. Fix: read it via
   `this.ctx.get('sessionPersistence', false)`, the topology-independent
   global-store lookup.

Why the suite missed both: every test mounted the plugin by hand
(`ctx.plugin({name,inject,apply})`), bypassing `unwrapExports` entirely, and
the only test driving these RPCs was key-gated (skipped in CI). Added a no-key
`session/new` e2e that boots the real example through the real Loader — it
fails loudly on bug #1 without an API key. Set `TSX_TSCONFIG_PATH` in the e2e
spawn so the subprocess resolves workspace `paths` from a temp cwd (it was
silently falling back to a stale built `lib/`).

Docs: post-mortem 0001; AGENTS.md "line coverage is not behavior coverage" +
with-key/smoke-test philosophy; packages/AGENTS.md plugin-export-shape and
ctx.get rules; dsh-code-review SKILL checks.
This commit is contained in:
Tianyi Cui
2026-06-18 03:12:37 +08:00
parent a9d5a5ba68
commit 6d37b6c33d
10 changed files with 221 additions and 40 deletions

View File

@@ -5,10 +5,12 @@ This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing
- **Effect-based registrations**: every contribution (tool, section, adapter, agent, event listener) goes through `ctx.effect()` / `ctx.on()`, and `register()` methods return disposers. Never use bare arrays or manual cleanup.
- **Declaration merging**: services declare their ctx key in `declare module 'cordis' { interface Context { } }` and their events in `interface Events`. Merge-extensible maps (`ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap`) are how plugins add new variants.
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`; call `next()` to delegate, or return without it to short-circuit (veto). Never call `next()` after returning.
- **Tests**: vitest in `packages/<name>/tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races.
- **Plugin export shape — namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **Read an optional (non-injected) service via `ctx.get(name, false)`, not `ctx.<name>`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.<name>` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name, false)` is the topology-independent global-store lookup (`false` skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.<name>`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **Tests**: vitest in `packages/<name>/tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races. A plugin shipped via `cordis.yml` also needs at least one test that drives it through the REAL Loader/export path (hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape) — see AGENTS.md § Defensive patterns "Line coverage is not behavior coverage". Real-API (with-key) e2e tests are cheap here (we are DeepSeek) and welcome — write many, especially smoke tests; see AGENTS.md § Secrets / .env.
Naming notes:
- Files `src/index.ts` export the service default + all public types
- A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (see the plugin-export-shape rule above)
- `src/types.ts` contain only types — no runtime code
- Tests live at package level under `tests/`, not `src/__tests__/`
- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/README.md` and verifies the event-taxonomy table — but it does NOT cover this file or prose drift (config keys, defaults, error codes), so those stay on the author.

View File

@@ -169,6 +169,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
const agentName = config.agentName ?? 'deepseek-harness-acp'
const agentVersion = config.agentVersion ?? '0.0.1'
// Capture the injected services NOW, during apply(), while we are inside this
// plugin's fiber (where `inject` grants access). The ACP method handlers run
// LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is
// NOT this fiber's injection scope — so reading `ctx.agents` / `ctx.logger` /
// `ctx.sessionPersistence` lazily inside a handler throws "cannot get property
// … without inject". Resolving the references here and closing over them keeps
// the handlers working regardless of which fiber later invokes them.
const agents = ctx.agents
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
// Single live session for the MVP. RFC 011 turns this into maps keyed by
// sessionId plus an agent→sessionId reverse map for the permission gate.
let record: SessionRecord | undefined
@@ -223,7 +234,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
failure (closed pipe), which the in-memory test transport never induces;
the swallow is a defensive best-effort guard like the loop's emit traps */
void Promise.resolve(conn.sessionUpdate(notification)).catch((error: unknown) => {
ctx.logger.warn(`acp: session/update failed: ${String(error)}`)
logger.warn(`acp: session/update failed: ${String(error)}`)
})
}
@@ -374,7 +385,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
validateWorkspaceParams(params)
const sessionId = randomUUID()
const agent = ctx.agents.create({
const agent = agents.create({
agentId: sessionId,
sessionId,
meta: { cwd: params.cwd },
@@ -407,13 +418,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
// launched in workspace B: it would replay A's history while tools run
// in B. (If the id is unknown to `list()`, fall through to resume,
// which rejects with the backend's not-found error.)
const meta = (await ctx.sessionPersistence.list()).find(m => m.id === params.sessionId)
const meta = (await sessionPersistence.list()).find(m => m.id === params.sessionId)
if (meta?.cwd !== undefined && meta.cwd !== process.cwd()) {
throw invalidParams(
`session was created in ${meta.cwd}, but the server's launch directory is ${process.cwd()}; honoring a different cwd is not yet supported — launch the server in the session's workspace`,
)
}
const agent = await ctx.agents.resume({
const agent = await agents.resume({
agentId: params.sessionId,
resumeSessionId: params.sessionId,
agentOptions: agentOptions(config),
@@ -577,7 +588,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
mid-run), and there is nothing else to act on once the connection is gone —
the swallow mirrors notify(). */
void conn.closed.then(quiesce).catch((error: unknown) => {
ctx.logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
})
/* v8 ignore stop */
@@ -733,5 +744,3 @@ function toolResultContent(blocks: ContentBlock[]): { type: 'content'; content:
}
return out
}
export default apply

View File

@@ -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

View File

@@ -146,8 +146,6 @@ 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
} = { storageDir: '' }): Promise<BridgeHarness> {
const adapter = new MockAdapter(options.script ?? [])
@@ -220,21 +218,20 @@ 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',
inject: ['agents', 'sessions', 'sessionPersistence'],
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
})
harness.client = new ClientSideConnection(makeClient, clientStream)
return harness

View File

@@ -152,12 +152,20 @@ export class AgentLoop extends Service implements AgentFactory {
* by the time this runs the service exists.
*/
async resume(options: ResumeAgentOptions): Promise<Agent> {
const persistence = this.ctx.sessionPersistence
// `sessionPersistence` is declaration-merged onto Context as non-optional,
// but the service is only present when a backend plugin is loaded — and
// AgentLoop deliberately does NOT inject it (that would pend non-persistent
// demos forever). So the runtime value can be undefined; the type cannot.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// Read the service through `ctx.get(name, false)` — a direct global-store
// lookup keyed by the isolate symbol — NOT `this.ctx.sessionPersistence`.
// AgentLoop deliberately does NOT inject `sessionPersistence` (injecting it
// would pend non-persistent demos forever). The property proxy resolves a
// service by walking the current fiber's parent chain; from AgentLoop's own
// fiber (which lacks the inject) that walk never reaches the sibling backend
// fiber and throws "cannot get property … without inject". Worse, when the
// call arrives via a traceable shadow (e.g. the ACP bridge child fiber →
// `ctx.agents.resume()` → `this.factory.resume()`), the walk starts at the
// SHADOW's root fiber and fails the same way. `ctx.get(…, false)` sidesteps
// the fiber walk entirely (the same bypass the proxy itself takes when
// `!ctx.fiber.runtime`), so resume works from any caller fiber. `false`
// skips the ACTIVE-state check, since the backend lives on another fiber.
const persistence = this.ctx.get('sessionPersistence', false)
if (persistence === undefined) {
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
}