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,8 +1,10 @@
#!/usr/bin/env node
/**
* The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that loads the {@link
* @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter and a bash executor), speaking
* ACP JSON-RPC on stdio.
* Boot an ACP stdio server from `cordis.yml`; usage is `dsh-acp-agent [config]`, defaulting to the
* cwd file. Shared env loading, Loader guards, snapshot config selection, and settled-tree boot live
* in dsh-app-boot. Replay skips `.env` and selects sibling `cordis.snapshot.yml` so a stray key
* cannot trigger a model call. EOF disposes and flushes snapshot runs; editors normally own process
* lifetime. Stdout is reserved for JSON-RPC—write diagnostics only to stderr.
* @module @deepseek-ai/dsh-acp-agent/bin
*/

View File

@@ -2,6 +2,10 @@
* The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the
* coupled front-door cluster an ACP server needs — JSONL session persistence and the {@link
* @deepseek-ai/dsh-acp} bridge, and deliberately NOTHING that writes to stdout.
* It pre-creates no agents and leaves adapters, executors, and optional tools to
* the leaf, which must likewise avoid stdout loggers. Named exports are
* required so Loader retains this plugin's `Config` schema (see
* docs/postmortem/0001).
* @module @deepseek-ai/dsh-acp-agent
*/

View File

@@ -139,7 +139,8 @@ describe('dsh-acp-agent composition', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
// Loader must retain the namespace so name, Config, and apply survive unwrapping.
// A default export would make `unwrapExports` collapse this inject-less namespace and silently
// drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly.
expect('default' in acpAgent).toBe(false)
expect(typeof acpAgent.apply).toBe('function')

View File

@@ -18,9 +18,10 @@ import { Readable, Writable } from 'node:stream'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Built-ARTIFACT smoke for the published `dsh-acp-agent` bin. `load-path.e2e.ts` boots
* `src/bin.ts` under tsx — but the package's `bin` field points at `lib/bin.js`, run under
* plain `node` by a real consumer.
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
* require a valid initialize response. This catches built-only settle races and stdout protocol
* leaks that the tsx source-path smoke cannot. It skips before build; initialize is keyless, with a
* dummy key used only to boot the adapter. `--expose-internals` enables Cordis bare-plugin loading.
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
@@ -37,7 +38,8 @@ const vendorPackages = [
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
'schemastery', 'cosmokit',
]
// Third-party deps the ACP bridge needs at runtime.
// Resolve ACP's declared third-party dependencies from that package, not this test: pnpm's strict
// layout need not hoist them. Symlink those exact paths into the plain-Node consumer.
const npmDeps = ['@agentclientprotocol/sdk', 'zod']
const acpPkgDir = join(repoRoot, 'packages/ui/acp')
@@ -145,7 +147,8 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js,
}, 30_000)
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
// A typo'd config path must fail clearly, not exit 0.
// A nonexistent directory prevents even the include plugin import. Loader logs the failure and
// leaves no fiber; boot's settled-entry guard must convert that state into non-zero exit.
const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml')
expect(code).not.toBe(0)
expect(stderr).toContain('failed to load')

View File

@@ -17,9 +17,11 @@ import {
} from '@agentclientprotocol/sdk'
/**
* real-load-path smoke for @deepseek-ai/dsh-acp-agent: boot the app through its own `bin` (the
* demo:acp entry) as a subprocess, driving the cordis Loader and `unwrapExports` over a
* minimal `cordis.yml` that loads THIS package.
* Source-path Loader smoke through the package's own bin, covering initialize, session/new, and
* session/load across the `unwrapExports` path implicated by postmortem 0001. Session creation and
* unknown-id loading reach factories but not the model, so a dummy key is sufficient. The temp cwd
* is also the session workspace, and an explicit root tsconfig keeps unbuilt path aliases resolvable
* when the child starts outside the repository.
*/
const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
@@ -119,7 +121,9 @@ describe('dsh-acp-agent real-load-path smoke (bin + Loader, keyless)', () => {
expect(sessionId).toBeTruthy()
// session/load reaches the resume FACTORY + persistence without the model: load an UNKNOWN
// id (loading the live `sessionId` would correctly reject as "already loaded").
// id (loading the live `sessionId` would correctly reject as "already loaded"). Persistence
// and resume run from the JSON-RPC loop outside bridge injection; a healthy tree reaches
// not-found, while a collapsed export would fail earlier with missing injection.
const unknownId = '00000000-0000-4000-8000-000000000000'
await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then(
() => { throw new Error('expected session/load of an unknown id to reject') },

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

View File

@@ -11,8 +11,8 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
/**
* Resolve the config to boot, honoring snapshot replay.
*
* Resolve the config to boot. Replay swaps a `cordis.yml` basename for
* `cordis.snapshot.yml` in the same directory; every other mode keeps the path.
* @param configPath - the requested config path (absolute, or relative to `cwd`).
* @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the
* basename.
@@ -62,8 +62,9 @@ export interface FailLoudProcess {
}
/**
* Make a load failure fail loud with a clear message on stderr.
*
* Install before boot to turn a late unhandled plugin-init rejection into one
* labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP;
* the returned function removes the handler.
* @param binName - the diagnostic prefix on the fatal-failure line.
* @param proc - the process slice to register on; tests inject a fake.
* @returns the uninstaller that removes the rejection handler.
@@ -78,8 +79,9 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process
}
/**
* After the tree settles, assert every loader entry actually started.
*
* After the tree settles, reject entries with no fiber, which indicates a
* swallowed module-import failure. Disabled entries are the only valid
* fiber-less state.
* @param ctx - the settled context whose loader entries to audit.
* @param binName - the diagnostic prefix on the thrown error.
*/
@@ -92,9 +94,11 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
}
/**
* Boot the Loader against `absoluteConfigPath` and return the root context once the whole tree
* has settled.
*
* Boot the Loader against `absoluteConfigPath` and return only after the whole
* tree settles. The include uses an absolute file URL while `baseUrl` stays at
* the config directory for its relative imports. A missing fiber rejects here;
* a later init rejection is handled by {@link installFailLoud}. Built bins need
* `--expose-internals` for bare plugin specifiers; relative specifiers do not.
* @param binName - the diagnostic prefix for load-failure errors.
* @param absoluteConfigPath - the config to include; must already be absolute
* (see {@link resolveConfigPath}).

View File

@@ -1,7 +1,8 @@
#!/usr/bin/env node
/**
* The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that loads the {@link
* @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM adapter and a bash executor).
* Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-agent [config]`, defaulting to the
* cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in
* dsh-app-boot. The echo and REPL demos invoke this bin with their own leaf configs.
* @module @deepseek-ai/dsh-stdio-agent/bin
*/

View File

@@ -3,6 +3,9 @@
* coupled front-door cluster a terminal chat needs — a console logger, the readline UI (the
* in-package `stdio-chat` module), JSONL session persistence, and a pre-created `main` agent
* the UI drives.
* Swappable adapters, executors, optional tools, and HMR stay in the leaf. This
* Loader plugin intentionally exposes named exports only; a default export
* would hide its `Config` schema (see docs/postmortem/0001).
* @module @deepseek-ai/dsh-stdio-agent
*/

View File

@@ -1,6 +1,7 @@
/**
* The stdio app's readline UI: reads lines from stdin → `agent.send()`/ `steer()`, and renders
* the durable transcript to stdout.
* The stdio app's readline UI: reads lines from stdin into `agent.send()` or
* `steer()`, renders the durable event stream to stdout, and exits piped input
* only after submitted work reaches idle.
* @module @deepseek-ai/dsh-stdio-agent/stdio-chat
*/
@@ -83,16 +84,15 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
const agentId = AgentId(config.agent ?? 'main')
const { input, output, exit } = runtime
// Render label lookup: the `turn/start` session event carries only the turn number, so to
// print the short agent id (`[main turn 1]`) we map the session's id to its agent's id.
// Session ids need not equal agent ids. Seed existing agents before listening
// so a pre-created or HMR-surviving agent still gets its short render label.
const labelBySession = new Map<string, string>()
for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id)
ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
// Transcript rendering off the durable `session/event` feed — the assistant token stream,
// turn/step boundaries, tool activity, and todos all come from the one canonical stream (no
// agent/* mirrors).
// Render the canonical append order from session/event so reasoning state is
// deterministic across chunks and boundaries; there are no agent/* mirrors.
let inReasoning = false
ctx.on('session/event', (session, event) => {
if (event.type === 'assistant/chunk') {
@@ -135,9 +135,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
ctx.effect(() => {
const reader = createInterface({ input, output, terminal: isTTYPair(input, output) })
// Piped-input exit, once stdin reaches EOF: - If no line ever submitted work (empty stdin,
// blank-only lines), exit immediately — no turn will ever start, so there is nothing to
// wait for.
// On piped EOF, exit immediately if no work was submitted. Otherwise wait
// for a real running state followed by idle: sends do not synchronously mark
// running, and several queued lines may share one turn.
let stdinClosed = false
let disposed = false
let submittedWork = false
@@ -155,7 +155,8 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
const agent = ctx.agents.get(agentId)
if (agent && agent.status !== 'idle') return // a turn is still running
}
// Let any final output flush, then exit.
// Let final output flush; track the timer so re-entry coalesces and HMR
// disposal can cancel it before it exits the replacement process.
if (exitTimer !== undefined) {
return // exit already scheduled — coalesce re-entrant calls
}

View File

@@ -7,13 +7,17 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Built-ARTIFACT smoke for the published `dsh-stdio-agent` bin.
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
* require the banner plus echo round-trip. This catches built-only early-exit and config-resolution
* failures masked by tsx source smokes. It skips before build; `--expose-internals` enables Cordis
* bare-plugin loading, matching the demo command.
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
// Workspace packages the stdio app's tree needs, by repo-relative path.
// Symlink each required workspace package by package name so plain Node resolves its built `main`,
// matching an installed dependency rather than tsconfig paths.
const dshPackages = [
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
@@ -32,8 +36,9 @@ async function pkgName(absDir: string): Promise<string> {
}
/**
* Build a temporary symlinked consumer for the stdio app. The optional disabled
* broken entry verifies that load guards accept intentionally fiber-less entries.
* Build a temporary external consumer with built workspace/vendor links and a mock-backed config.
* The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less
* entries rather than treating them as import failures.
*/
async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-'))
@@ -130,7 +135,8 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.
it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => {
// A `disabled: true` entry settles without a fiber by design; the fail-loud entry-load
// guard must not mistake it for a failed import.
// guard must not mistake it for a failed import. The nonexistent path makes that distinction
// observable while the successful round-trip proves boot continued.
consumer = await makeConsumer('DISABLED-OK ready.', true)
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
expect(stderr).not.toContain('failed to load')
@@ -140,7 +146,8 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.
}, 30_000)
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
// A consumer who typos the config path must get a clear failure, not silent success.
// A nonexistent directory prevents even the include plugin import. Loader leaves no fiber, and
// boot's settled-entry guard must turn that state into a clear non-zero failure.
consumer = await makeConsumer('unused')
const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '')
expect(code).not.toBe(0)

View File

@@ -10,9 +10,10 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as stdioAgent from '../src/index.ts'
/**
* Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it composes the
* console logger, the agent-core spine (pre-creating the `main` agent from the app config),
* the JSONL backend, and the readline UI in one `ctx.plugin`.
* Unit coverage for app composition and config forwarding: console logger, pre-created main agent,
* agent-core spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the
* keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise
* survive namespace collapse while silently losing its schema.
*/
async function mount(config: stdioAgent.Config): Promise<Context> {
const ctx = new Context()
@@ -152,7 +153,8 @@ describe('dsh-stdio-agent app', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
// Loader must retain the namespace so name, Config, and apply survive unwrapping.
// A default export would make `unwrapExports` collapse this inject-less namespace and silently
// drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly.
expect('default' in stdioAgent).toBe(false)
expect(typeof stdioAgent.apply).toBe('function')

View File

@@ -181,7 +181,8 @@ describe('createStdioChat rendering', () => {
it('seeds labels for agents already registered before the UI installs', async () => {
// The pre-created `main` agent (and any agent surviving an HMR reload of just this fiber)
// fired its `agent/created` before the UI's listener existed, so the live listener alone
// would miss it.
// would miss it. Seeding from `ctx.agents.list()` preserves the `[main turn N]` label instead
// of falling back to the raw session id.
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)

View File

@@ -6,6 +6,6 @@ Each request must belong to an open agent turn. The service appends a paired `ap
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer.
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is exposed to the model through the prompt and a coalesced switch notice.
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice whose header marker distinguishes user changes from operator/config changes.
The tools pipeline consumes this seam for `ask` decisions and the sandboxed bash tool uses it for escalated retries. See the [approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md) and [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).

View File

@@ -171,7 +171,8 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean {
}
/**
* Append the sole durable representation of a session policy override.
* Append the sole durable representation of a session policy override. Invalid
* values throw before the log changes; consumers fold the new value on each read.
* @param session - the session the override belongs to.
* @param policy - the policy in effect until the next switch.
*/

View File

@@ -438,7 +438,7 @@ describe('approval policy (the approval/policy fold)', () => {
it('never is unbypassable even by an answerer PREPENDED after the service mounts', async () => {
// Cordis prepend unshifts ahead of every existing listener, including any gate LISTENER the
// service could register — which is exactly why the 'never' decision lives inside request()
// instead.
// instead. This eager grant would bypass a listener-based gate and therefore must never run.
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const consulted = vi.fn()

View File

@@ -21,4 +21,4 @@ When an answer includes `custom`, `selected` is empty; custom text is an overrid
## Role
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop.
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.