refactor(examples): extract reusable logic into tested packages

Logic that lived under examples/ was outside the per-file 100% coverage
gate (examples/ are not workspaces) and, in the stdio-UI case, duplicated
across two examples. Move it into packages/ so it is gated and de-duped.

- packages/ui-stdio (new): unify the two diverged stdio-chat.ts copies into
  one @deepseek-ai/dsh-ui-stdio plugin (welcome/agent Config). A test-only
  I/O seam (createStdioChat(ctx, config, runtime)) keeps process streams out
  of the serializable config and makes every render/EOF/disposal branch
  unit-testable. Per-file 100%. echo/coding cordis.yml now load the package;
  both src/stdio-chat.ts deleted.
- packages/llm-replay (new): move examples/acp-agent/src/llm-replay.ts (+ its
  spec) here so its derive/parse/replay branches fall under the coverage gate.
  cordis.snapshot.yml + README rewired to the package name; added apply/env
  /assertNever/abort tests to reach per-file 100%.
- examples/{echo,coding}-agent: keyless Loader-path e2e smokes that boot the
  real cordis.yml (no key) — the guard a hand-mounted unit test cannot be for
  the unwrapExports/export-shape class (postmortem 0001). examples/AGENTS.md
  codifies the keyless+with-key smoke convention (keyless-by-nature exception
  for echo-agent).
- AGENTS.md: a scoped, removal-triggered pre-release stance (foundation over
  blast radius). packages/README.md: new rows + a FIXME to later regroup ALL
  packages into a hierarchy. Wiring: tsconfig paths/refs, publint, knip,
  module-graph.

Verified: typecheck, lint, test:coverage (887 tests, 100%), build, hygiene,
doc-sync, test:snapshot (10), test:e2e (6 keyless pass, with-key self-skip).
This commit is contained in:
Tianyi Cui
2026-06-19 12:42:28 +08:00
parent 1e09ab7204
commit 072f97c184
29 files changed
+1121 -195

No files matched your search

+3 -1
View File
@@ -54,4 +54,6 @@
root: './.sessions'
- id: stdio-chat
name: './src/stdio-chat.ts'
name: '@deepseek-ai/dsh-ui-stdio'
config:
welcome: 'coding-agent ready. Give it a coding task (bash is its only tool).'
-119
View File
@@ -1,119 +0,0 @@
import { createInterface } from 'node:readline'
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-agent'
export const name = 'stdio-chat'
export const inject = ['agents']
// Copied from examples/echo-agent (welcome text + reasoning rendering
// adjusted). Deliberately example-local rather than a shared package — two
// examples don't justify the abstraction yet; revisit at the third.
/**
* Minimal UI plugin: reads lines from stdin → agent.send(); renders the
* agent's stream chunks and tool activity to stdout. Demonstrates that a UI
* is "just a plugin" — it only consumes the agent/* event taxonomy.
*/
export function apply(ctx: Context) {
let inReasoning = false
ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => {
if (chunk.type === 'reasoning-delta') {
// Dim the chain-of-thought so the answer stands out.
if (!inReasoning) process.stdout.write('\x1B[2m')
inReasoning = true
process.stdout.write(chunk.text)
} else if (chunk.type === 'text-delta') {
if (inReasoning) process.stdout.write('\x1B[0m\n')
inReasoning = false
process.stdout.write(chunk.text)
}
})
ctx.on('agent/turn-start', (agent, turn) => {
process.stdout.write(`\n[${agent.id} turn ${turn}] `)
})
ctx.on('agent/turn-end', () => {
if (inReasoning) process.stdout.write('\x1B[0m')
inReasoning = false
process.stdout.write('\n> ')
})
ctx.on('session/event', (_session, event) => {
if (event.type === 'tool/call') {
const { name: toolName, arguments: args } = event.data
if (inReasoning) process.stdout.write('\x1B[0m')
inReasoning = false
process.stdout.write(`\n [tool call] ${toolName}(${args})`)
} else if (event.type === 'tool/result') {
const { content } = event.data
const text = content.filter(b => b.type === 'text').map(b => b.text).join('')
process.stdout.write(`\n [tool result] ${text}\n `)
}
})
ctx.effect(() => {
const reader = createInterface({ input: process.stdin })
// 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. (Gating on an observed 'running' here would hang forever.)
// - If work WAS submitted, exit the next time the agent settles to idle
// AFTER having run. Two subtleties this handles: the loop batches
// several queued messages into ONE turn (one idle), so we don't count
// sends; and agent.send() does NOT synchronously flip status to
// 'running', so requiring an observed 'running' first (`sawRunning`)
// avoids exiting in the gap before the turn starts and dropping work.
let stdinClosed = false
let disposed = false
let submittedWork = false
let sawRunning = false
const maybeExit = (): void => {
if (disposed || !stdinClosed) return
// No work submitted: nothing will ever run, exit straight away.
// Work submitted: wait until a turn has run and the agent is idle.
if (submittedWork) {
if (!sawRunning) return
const agent = ctx.agents.get('main')
if (agent && agent.status !== 'idle') return // a turn is still running
}
// Let any final output flush, then exit.
setTimeout(() => process.exit(0), 200)
}
const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
if (subject.id !== 'main') return
if (status === 'running') sawRunning = true
if (status === 'idle') maybeExit()
})
reader.on('line', (line) => {
const text = line.trim()
if (!text) return
const agent = ctx.agents.get('main')
if (!agent) {
console.error('agent "main" is not running')
return
}
submittedWork = true
if (agent.status === 'running') {
agent.steer([{ type: 'text', text }])
} else {
agent.send([{ type: 'text', text }])
}
})
reader.on('close', () => {
// Fires for BOTH stdin EOF and plugin disposal (reader.close() below);
// `disposed` guards teardown so HMR/dispose never exits the process.
stdinClosed = true
maybeExit()
})
process.stdout.write('coding-agent ready. Give it a coding task (bash is its only tool).\n> ')
return () => {
disposed = true
disposeStatusListener()
reader.close()
}
}, 'stdio-chat')
}
@@ -0,0 +1,92 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Keyless Loader-path smoke for examples/coding-agent: boot the REAL example
* through its `cordis.yml` (the cordis Loader, `unwrapExports`, the full plugin
* tree incl. the extracted `@deepseek-ai/dsh-ui-stdio`), then close stdin with
* no prompt and assert the ready banner + a clean exit.
*
* No prompt is ever sent, so the model is NEVER called — this is why it runs
* without a real key. coding-agent's `cordis.yml` loads `llm-deepseek`, whose
* `apply()` only requires a key to be PRESENT (it does not validate it and only
* uses it when a stream actually starts), so a dummy key lets the tree boot
* while the absence of any prompt guarantees no network call. The value is the
* real-Loader-path guard for the shared UI plugin's export shape (a broken
* `export default` that drops `inject` would crash here — see postmortem 0001),
* complementing coding-agent's with-key e2e suites which prove the real product.
*/
const startScript = fileURLToPath(new URL('../start.ts', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
// Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig
// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside
// the repo, so point it at the repo tsconfig (root is four levels up).
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
let child: ChildProcessWithoutNullStreams | undefined
let workdir: string | undefined
afterEach(async () => {
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
child = undefined
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
workdir = undefined
})
async function bootAndEof(): Promise<{ stdout: string; code: number }> {
workdir = await mkdtemp(join(tmpdir(), 'coding-smoke-'))
const cwd = workdir
return new Promise((resolve, reject) => {
const proc = spawn(
process.execPath,
// --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:coding).
['--expose-internals', '--import', tsxLoader, startScript],
{
cwd,
env: {
...process.env,
TSX_TSCONFIG_PATH: repoTsconfig,
// A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
// No prompt is sent, so the adapter never streams — no network call.
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
},
stdio: ['pipe', 'pipe', 'pipe'],
},
)
child = proc
let stdout = ''
let stderr = ''
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', (chunk: string) => { stdout += chunk })
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
proc.kill('SIGKILL')
reject(new Error(`coding-agent did not exit within 10s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 10_000)
proc.on('exit', (code) => {
clearTimeout(timer)
if (code === 0) resolve({ stdout, code })
else reject(new Error(`coding-agent exited ${code}. stderr:\n${stderr}`))
})
proc.on('error', (err) => { clearTimeout(timer); reject(err) })
// No prompt — just EOF, so the stdio UI exits without ever running a turn.
proc.stdin.end()
})
}
describe('coding-agent keyless smoke (real cordis.yml via the Loader)', () => {
it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => {
const { stdout, code } = await bootAndEof()
expect(code).toBe(0)
expect(stdout).toContain('coding-agent ready.')
}, 15_000)
})