Jelajahi Sumber

refactor: share loader smoke harness

Tianyi Cui 2 bulan lalu
induk
melakukan
0815ff4db4

+ 1 - 0
docs/config-catalog.md

@@ -1205,6 +1205,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
 - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
 - `@deepseek-ai/dsh-jsonrpc-agent` ([`packages/ui/jsonrpc-agent/src/index.ts`](../packages/ui/jsonrpc-agent/src/index.ts))
+- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
 - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
 - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts))

+ 2 - 0
docs/module-graph.md

@@ -86,6 +86,7 @@ flowchart TD
     pkg_acp_snapshot["acp-snapshot"]
     pkg_invariants["invariants"]
     pkg_llm_replay["llm-replay"]
+    pkg_loader_smoke["loader-smoke"]
     pkg_subagent_mock["subagent-mock"]
   end
   subgraph group_ui["packages/ui"]
@@ -329,6 +330,7 @@ flowchart TD
 | [`skill`](../packages/skill/skill) | `skill` | — |
 | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — |
 | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — |
+| [`loader-smoke`](../packages/support/loader-smoke) | `support` | — |
 | [`app-boot`](../packages/ui/app-boot) | `ui` | — |
 | [`jsonrpc-agent`](../packages/ui/jsonrpc-agent) | `ui` | — |
 | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — |

+ 1 - 1
examples/AGENTS.md

@@ -13,7 +13,7 @@ Each example must have **both** kinds of end-to-end smoke, because they catch di
 
 **Exception — keyless-by-nature examples.** An example whose model is itself a mock/deterministic stand-in (no real provider) has no meaningful with-key smoke; the keyless smoke is the complete requirement. State the exception inline in the test.
 
-A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_PATH` to the repo-root tsconfig (the unbuilt `paths` map is found by searching UP from cwd), and pass `--expose-internals` when the `cordis.yml` loads the HMR plugin (mirror the `demo:*` script).
+A keyless stdio smoke uses `@deepseek-ai/dsh-loader-smoke`, which owns the isolated cwd and DSH homes, repo tsconfig pin, `--expose-internals`, subprocess deadline, EOF, captured diagnostics, forced kill, and cleanup. The example test supplies only its absolute bin/config/tsconfig paths, environment overrides, stdin lines, and output assertions.
 
 ## Current state
 

+ 15 - 87
examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts

@@ -1,99 +1,27 @@
-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'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
- * Keyless Loader-path smoke for the Code Mode overlay: boot the REAL
- * example through the `@deepseek-ai/dsh-stdio-agent` bin against
- * `code-mode.cordis.yml` (the cordis Loader, `unwrapExports`, the include
- * patches over ./cordis.yml, the worker-thread code runtime, and the
- * registry in `mode: code`), then close stdin with no prompt and assert
- * the Code Mode banner + a clean exit.
- *
- * No prompt is ever sent, so the model is NEVER called and no `run_code`
- * turn happens — a dummy key lets `llm-deepseek`'s key-PRESENT check boot
- * the tree. This is the export-shape guard (postmortem 0001) for the Code
- * Mode composition; the with-key proof lives in `code-mode.e2e.ts`.
+ * Keyless Loader-path smoke for the Code Mode overlay: boot the real include
+ * tree through stdio-agent and `code-mode.cordis.yml`, then close stdin without
+ * a prompt and assert the banner. No model or `run_code` turn runs.
  */
 
 const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../code-mode.cordis.yml', 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.
-const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
-// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader
-// startup can therefore outlive a tight smoke-test deadline before the child
-// emits any output; 30s still detects a wedged process without confusing
-// bounded CI contention with a lifecycle failure.
-const PROCESS_TIMEOUT_MS = 30_000
-// Leave enough room for the process-owned timeout to report captured output
-// before Vitest aborts the test itself.
-const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
-
-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(), 'code-mode-smoke-'))
-  const cwd = workdir
-  return new Promise((resolve, reject) => {
-    const proc = spawn(
-      process.execPath,
-      // --expose-internals: the included cordis.yml loads the HMR plugin (mirrors demo:code-mode).
-      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
-      {
-        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(`code-mode overlay did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    }, PROCESS_TIMEOUT_MS)
-
-    proc.on('exit', (code) => {
-      clearTimeout(timer)
-      if (code === 0) resolve({ stdout, code })
-      else reject(new Error(`code-mode overlay 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()
-  })
-}
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 
 describe('code-mode overlay keyless smoke (real code-mode.cordis.yml via the Loader)', () => {
   it('boots the Code Mode plugin tree, prints its banner, and exits cleanly on EOF', async () => {
-    const { stdout, code } = await bootAndEof()
-    expect(code).toBe(0)
+    const { stdout } = await runLoaderSmoke({
+      label: 'code-mode overlay',
+      tempDirPrefix: 'code-mode-smoke-',
+      binScript,
+      configPath,
+      tsconfigPath,
+      env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
+    })
     expect(stdout).toContain('code-mode agent ready.')
-  }, TEST_TIMEOUT_MS)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })

+ 16 - 100
examples/coding-agent/tests/keyless-smoke.e2e.ts

@@ -1,112 +1,28 @@
-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'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
- * Keyless Loader-path smoke for examples/coding-agent: boot the REAL example
- * through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` (the
- * cordis Loader, `unwrapExports`, the full plugin tree incl. the
- * `@deepseek-ai/dsh-agent-core` bundle and the app's in-package readline UI
- * module), 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 that the composed tree boots (see postmortem 0001;
- * the app carries no `inject`, so its export SHAPE is pinned by the stdio-agent
- * unit suite's unwrap assertion, not by a crash here),
- * complementing coding-agent's with-key e2e suites which prove the real
- * product.
+ * Keyless Loader-path smoke for examples/coding-agent: boot the real example
+ * through the stdio-agent bin and its `cordis.yml`, then close stdin without a
+ * prompt and assert the banner. The dummy key satisfies adapter construction;
+ * immediate EOF guarantees there is no model call.
  */
 
-// TODO(loader-smoke-harness): extract the shared spawn/tempdir/timeout/EOF
-// harness used here, code-mode-keyless-smoke, and cordis-agent's keyless smoke.
-// The dsh-stdio-agent bin (the demo:repl entry) and this example's cordis.yml.
-// The bin resolves its config-path arg from CWD; the test spawns from a temp
-// cwd, so we pass the example config's ABSOLUTE path.
 const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../cordis.yml', 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))
-// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader
-// startup can therefore outlive a tight smoke-test deadline before the child
-// emits any output; 30s still detects a wedged process without confusing
-// bounded CI contention with a lifecycle failure.
-const PROCESS_TIMEOUT_MS = 30_000
-// Leave enough room for the process-owned timeout to report captured output
-// before Vitest aborts the test itself.
-const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
-
-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:repl).
-      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
-      {
-        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',
-          DSH_HOME: join(cwd, '.dsh'),
-          DSH_AGENTS_HOME: join(cwd, '.agents'),
-        },
-        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 ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    }, PROCESS_TIMEOUT_MS)
-
-    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()
-  })
-}
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 
 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)
+    const { stdout } = await runLoaderSmoke({
+      label: 'coding-agent',
+      tempDirPrefix: 'coding-smoke-',
+      binScript,
+      configPath,
+      tsconfigPath,
+      env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
+    })
     expect(stdout).toContain('agent REPL ready.')
-  }, TEST_TIMEOUT_MS)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })

+ 15 - 90
examples/cordis-agent/tests/keyless-smoke.e2e.ts

@@ -1,102 +1,27 @@
-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'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
- * Keyless Loader-path smoke for examples/cordis-agent: boot the REAL example
- * through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` —
- * the cordis Loader, `unwrapExports`, the full plugin tree INCLUDING the
- * `@deepseek-ai/dsh-tool-cordis` package resolved by name (whose `inject`
- * would crash a collapsed export shape at load, see docs/postmortem/0001) —
- * 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 — that is why it runs
- * without a real key: `llm-deepseek`'s apply() only requires a key to be
- * PRESENT, and the absence of any prompt guarantees no network call. The
- * with-key product proof lives in cordis-tools.e2e.ts.
+ * Keyless Loader-path smoke for examples/cordis-agent: boot the real tree,
+ * including tool-cordis resolved by package name, then close stdin without a
+ * prompt and assert the banner. The dummy key never reaches a model call.
  */
 
-// The dsh-stdio-agent bin (the demo:cordis entry) and this example's cordis.yml.
-// The bin resolves its config-path arg from CWD; the test spawns from a temp
-// cwd, so we pass the example config's ABSOLUTE path.
 const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../cordis.yml', 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 three levels up).
-const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
-// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader
-// startup can therefore outlive a tight smoke-test deadline before the child
-// emits any output; 30s still detects a wedged process without confusing
-// bounded CI contention with a lifecycle failure.
-const PROCESS_TIMEOUT_MS = 30_000
-// Leave enough room for the process-owned timeout to report captured output
-// before Vitest aborts the test itself.
-const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
-
-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(), 'cordis-smoke-'))
-  const cwd = workdir
-  return new Promise((resolve, reject) => {
-    const proc = spawn(
-      process.execPath,
-      // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:cordis).
-      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
-      {
-        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(`cordis-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    }, PROCESS_TIMEOUT_MS)
-
-    proc.on('exit', (code) => {
-      clearTimeout(timer)
-      if (code === 0) resolve({ stdout, code })
-      else reject(new Error(`cordis-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()
-  })
-}
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 
 describe('cordis-agent keyless smoke (real cordis.yml via the Loader)', () => {
   it('boots the full plugin tree incl. tool-cordis, prints its banner, and exits cleanly on EOF', async () => {
-    const { stdout, code } = await bootAndEof()
-    expect(code).toBe(0)
+    const { stdout } = await runLoaderSmoke({
+      label: 'cordis-agent',
+      tempDirPrefix: 'cordis-smoke-',
+      binScript,
+      configPath,
+      tsconfigPath,
+      env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' },
+    })
     expect(stdout).toContain('cordis-agent ready.')
-  }, TEST_TIMEOUT_MS)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })

+ 22 - 110
examples/echo-agent/tests/echo.e2e.ts

@@ -1,131 +1,43 @@
-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'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
 
 /**
- * Keyless Loader-path smoke for examples/echo-agent: boot the REAL example
- * through the `@deepseek-ai/dsh-stdio-agent` bin against this example's
- * `cordis.yml` (the cordis Loader, `unwrapExports`, the whole plugin tree),
- * pipe a script of stdin lines, and assert the rendered stdout.
- *
- * This is the guard the per-file unit suite structurally cannot be: it drives
- * the `@deepseek-ai/dsh-stdio-agent` app plugin, the `@deepseek-ai/dsh-agent-core`
- * bundle it loads, the app's in-package readline UI module, AND the
- * example-local `mock-llm.ts` / `echo-tool.ts` through their REAL load path
- * (see docs/postmortem/0001). The app itself carries no `inject`, so a stray
- * `export default` would boot rather than crash here — the export SHAPE is
- * pinned by the explicit unwrap assertion in the stdio-agent unit suite; this
- * smoke proves the composed tree actually runs. It needs no API key — the
- * `mock-echo` adapter never touches the network — so it runs in the default e2e
- * gate.
- *
- * Both branches of mock-llm.ts are exercised: an `echo …` line (the tool
- * round-trip → `ECHO: …`) and a plain line (the direct canned reply).
+ * Keyless-by-nature Loader-path coverage for examples/echo-agent. The real
+ * tree uses its deterministic mock model, so this suite is both the boot smoke
+ * and the complete behavior proof for the example.
  */
 
-// The dsh-stdio-agent bin (the demo:echo entry) and this example's cordis.yml.
-// The bin resolves its config-path arg from CWD; the test spawns from a temp
-// cwd, so we pass the example config's ABSOLUTE path.
 const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
-const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
-// Dev/test run UNBUILT: `@deepseek-ai/dsh-*` imports resolve through the root
-// tsconfig `paths` map, which tsx finds by searching UP from cwd. We spawn from
-// a temp cwd OUTSIDE the repo, so point tsx at the repo tsconfig explicitly
-// (repo root is four levels up from examples/echo-agent/tests).
-const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
-// The real-API workflow runs up to 14 e2e files at once. Cold tsx/Loader
-// startup can therefore outlive a tight smoke-test deadline before the child
-// emits any output; 30s still detects a wedged process without confusing
-// bounded CI contention with a lifecycle failure.
-const PROCESS_TIMEOUT_MS = 30_000
-// Leave enough room for the process-owned timeout to report captured output
-// before Vitest aborts the test itself.
-const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
-
-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
-})
-
-/**
- * Boot echo-agent, write `lines` to its stdin, close stdin, and resolve with
- * the full stdout once the process exits (the stdio UI exits on EOF after the
- * agent settles). Rejects on a non-zero exit or the process deadline.
- */
-async function runEcho(lines: string[]): Promise<{ stdout: string; code: number }> {
-  workdir = await mkdtemp(join(tmpdir(), 'echo-smoke-'))
-  const cwd = workdir
-  return new Promise((resolve, reject) => {
-    const proc = spawn(
-      process.execPath,
-      // --expose-internals: the example's cordis.yml loads the HMR plugin, which
-      // requires it (mirrors the `demo:echo` script). The whole point is to boot
-      // the example EXACTLY as it really runs, through the bin + Loader.
-      ['--expose-internals', '--import', tsxLoader, binScript, configPath],
-      {
-        cwd,
-        env: {
-          ...process.env,
-          TSX_TSCONFIG_PATH: repoTsconfig,
-          DSH_HOME: join(cwd, '.dsh'),
-          DSH_AGENTS_HOME: join(cwd, '.agents'),
-        },
-        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(`echo-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
-    }, PROCESS_TIMEOUT_MS)
-
-    proc.on('exit', (code) => {
-      clearTimeout(timer)
-      if (code === 0) resolve({ stdout, code })
-      else reject(new Error(`echo-agent exited ${code}. stderr:\n${stderr}`))
-    })
-    proc.on('error', (err) => { clearTimeout(timer); reject(err) })
-
-    // Feed the script, then EOF so the stdio UI exits after the agent settles.
-    for (const line of lines) proc.stdin.write(`${line}\n`)
-    proc.stdin.end()
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
+
+async function runEcho(stdinLines: readonly string[]): Promise<string> {
+  const { stdout } = await runLoaderSmoke({
+    label: 'echo-agent',
+    tempDirPrefix: 'echo-smoke-',
+    binScript,
+    configPath,
+    tsconfigPath,
+    stdinLines,
   })
+  return stdout
 }
 
 describe('echo-agent keyless smoke (real cordis.yml via the Loader)', () => {
   it('boots, prints its welcome banner, and exits cleanly on stdin EOF', async () => {
-    const { stdout, code } = await runEcho([])
-    expect(code).toBe(0)
-    expect(stdout).toContain('echo-agent ready.')
-  }, TEST_TIMEOUT_MS)
+    expect(await runEcho([])).toContain('echo-agent ready.')
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
   it('runs the echo tool round-trip for an "echo …" line', async () => {
-    const { stdout } = await runEcho(['echo hello world'])
-    // mock-llm.ts emits a tool-call for the echo tool; echo-tool.ts uppercases.
+    const stdout = await runEcho(['echo hello world'])
     expect(stdout).toContain('[tool call] echo')
     expect(stdout).toContain('[tool result] ECHO: HELLO WORLD')
-  }, TEST_TIMEOUT_MS)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
   it('streams a direct canned reply for a non-echo line', async () => {
-    const { stdout } = await runEcho(['just chatting'])
-    // The direct-response branch of mock-llm.ts quotes the input back.
+    const stdout = await runEcho(['just chatting'])
     expect(stdout).toContain('just chatting')
     expect(stdout).not.toContain('[tool call]')
-  }, TEST_TIMEOUT_MS)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })

+ 5 - 0
knip.json

@@ -41,6 +41,11 @@
       "project": ["src/**/*.ts", "tests/**/*.ts"],
       "ignoreDependencies": ["cordis"]
     },
+    "packages/support/loader-smoke": {
+      "entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"],
+      "project": ["src/**/*.ts", "tests/**/*.ts"],
+      "ignoreDependencies": ["cordis"]
+    },
     "packages/core/agent-loop": {
       "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
       "project": ["src/**/*.ts", "tests/**/*.ts"]

+ 1 - 1
packages/README.md

@@ -26,7 +26,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
 | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
 | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
 | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface |
-| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
+| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay, Loader-smoke, and subagent test helpers) | Support — lower compatibility expectations |
 | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
 
 The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table).

+ 2 - 1
packages/support/README.md

@@ -6,7 +6,8 @@ Packages that exist to serve development, testing, and the examples rather than
 |---|---|---|
 | `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) |
 | `invariants/` | Dev-mode event-contract assertions | (listens on `session/*`, `agent/*`) |
+| `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) |
 | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
 | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) |
 
-`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery so every example's suite is a scenario table over one shared, gate-covered implementation. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
+`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.

+ 7 - 0
packages/support/loader-smoke/README.md

@@ -0,0 +1,7 @@
+# `@deepseek-ai/dsh-loader-smoke`
+
+Shared subprocess harness for keyless example smokes that boot the real stdio-agent bin and a real `cordis.yml` through the Cordis Loader. A test supplies absolute bin/config/tsconfig paths, optional environment overrides, and stdin lines; `runLoaderSmoke` owns the isolated cwd, DSH homes, tsx path resolution, 30-second process deadline, captured diagnostics, forced kill, EOF, and cleanup.
+
+Successful runs return stdout and stderr only after a zero exit. Non-zero exits and deadlines reject with both captured streams. `LOADER_SMOKE_TEST_TIMEOUT_MS` leaves Vitest enough room for the process-owned diagnostic timeout to fire first.
+
+This is support-tier test infrastructure, not product API. The consumers are the Loader-path smokes under `examples/{echo-agent,coding-agent,cordis-agent}`.

+ 33 - 0
packages/support/loader-smoke/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "@deepseek-ai/dsh-loader-smoke",
+  "description": "Shared subprocess harness for keyless real-Loader example smoke tests",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "tsx": "^4.22.4"
+  },
+  "peerDependencies": {
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 117 - 0
packages/support/loader-smoke/src/index.ts

@@ -0,0 +1,117 @@
+/**
+ * Shared subprocess harness for keyless example smokes that boot a real
+ * `cordis.yml` through the stdio-agent bin and Cordis Loader.
+ *
+ * @module @deepseek-ai/dsh-loader-smoke
+ */
+
+import { spawn } 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'
+
+const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
+const TSX_LOADER = fileURLToPath(import.meta.resolve('tsx'))
+
+/** Vitest deadline that leaves room for the subprocess-owned 30-second diagnostic timeout. */
+export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000
+
+/** Inputs that vary between real-Loader example smokes. */
+export interface LoaderSmokeOptions {
+  /** Human-readable example name used in failure diagnostics. */
+  readonly label: string
+  /** Prefix for the isolated temporary process cwd. */
+  readonly tempDirPrefix: string
+  /** Absolute stdio-agent bin path. */
+  readonly binScript: string
+  /** Absolute real Loader config path. */
+  readonly configPath: string
+  /** Absolute repo tsconfig path used for unbuilt workspace-package resolution. */
+  readonly tsconfigPath: string
+  /** Environment overrides layered over the parent and isolated DSH homes. */
+  readonly env?: Readonly<NodeJS.ProcessEnv>
+  /** Lines written to stdin before EOF; omitted means immediate EOF. */
+  readonly stdinLines?: readonly string[]
+  /** Process deadline override for harness tests. */
+  readonly processTimeoutMs?: number
+}
+
+/** Captured output from a Loader smoke that exited successfully. */
+export interface LoaderSmokeResult {
+  /** Complete stdout after clean exit. */
+  readonly stdout: string
+  /** Complete stderr after clean exit. */
+  readonly stderr: string
+}
+
+/**
+ * Boot one real Loader tree from an isolated cwd, write the requested stdin
+ * script, close stdin, and await a clean exit. The helper owns process kill and
+ * temp-directory cleanup on every outcome.
+ * @param options - example paths, environment, stdin, and diagnostic identity.
+ * @returns captured stdout and stderr after a zero exit.
+ */
+export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<LoaderSmokeResult> {
+  const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
+  const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
+  try {
+    return await new Promise((resolve, reject) => {
+      const child = spawn(
+        process.execPath,
+        ['--expose-internals', '--import', TSX_LOADER, options.binScript, options.configPath],
+        {
+          cwd,
+          env: {
+            ...process.env,
+            DSH_HOME: join(cwd, '.dsh'),
+            DSH_AGENTS_HOME: join(cwd, '.agents'),
+            ...options.env,
+            TSX_TSCONFIG_PATH: options.tsconfigPath,
+          },
+          stdio: ['pipe', 'pipe', 'pipe'],
+        },
+      )
+      let stdout = ''
+      let stderr = ''
+      let deferredFailure: Error | undefined
+      child.stdout.setEncoding('utf8')
+      child.stdout.on('data', (chunk: string) => { stdout += chunk })
+      child.stderr.setEncoding('utf8')
+      child.stderr.on('data', (chunk: string) => { stderr += chunk })
+
+      const timer = setTimeout(() => {
+        deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)
+        child.kill('SIGKILL')
+      }, processTimeoutMs)
+
+      child.once('exit', (code) => {
+        clearTimeout(timer)
+        if (deferredFailure !== undefined) {
+          reject(deferredFailure)
+        } else if (code === 0) {
+          resolve({ stdout, stderr })
+        } else {
+          reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
+        }
+      })
+
+      // process.execPath and a just-created pipe make these OS-error paths
+      // impractical to induce without replacing the boundary under test.
+      /* v8 ignore start */
+      child.once('error', (error) => {
+        clearTimeout(timer)
+        reject(new Error(`${options.label} failed to start: ${error.message}`))
+      })
+      child.stdin.once('error', (error) => {
+        deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`)
+        child.kill('SIGKILL')
+      })
+      /* v8 ignore stop */
+
+      child.stdin.end((options.stdinLines ?? []).map(line => `${line}\n`).join(''))
+    })
+  } finally {
+    await rm(cwd, { recursive: true, force: true })
+  }
+}

+ 4 - 0
packages/support/loader-smoke/tests/fixtures/fail.ts

@@ -0,0 +1,4 @@
+/** Non-zero subprocess fixture for the Loader-smoke harness. */
+
+console.error('fixture failed')
+process.exitCode = 7

+ 4 - 0
packages/support/loader-smoke/tests/fixtures/hang.ts

@@ -0,0 +1,4 @@
+/** Deadline subprocess fixture for the Loader-smoke harness. */
+
+console.log('fixture hanging')
+setInterval(() => {}, 1_000)

+ 16 - 0
packages/support/loader-smoke/tests/fixtures/success.ts

@@ -0,0 +1,16 @@
+/** Successful subprocess fixture for the Loader-smoke harness. */
+
+let input = ''
+process.stdin.setEncoding('utf8')
+process.stdin.on('data', (chunk: string) => { input += chunk })
+process.stdin.on('end', () => {
+  console.log(JSON.stringify({
+    configPath: process.argv[2],
+    cwd: process.cwd(),
+    dshHome: process.env.DSH_HOME,
+    agentsHome: process.env.DSH_AGENTS_HOME,
+    marker: process.env.LOADER_SMOKE_MARKER,
+    input,
+  }))
+  console.error('fixture stderr')
+})

+ 61 - 0
packages/support/loader-smoke/tests/loader-smoke.spec.ts

@@ -0,0 +1,61 @@
+import { existsSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
+
+const configPath = '/tmp/fixture.cordis.yml'
+const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
+const fixture = (name: string): string => fileURLToPath(new URL(`./fixtures/${name}.ts`, import.meta.url))
+const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/var\/)/, '')
+
+describe('runLoaderSmoke', () => {
+  it('isolates the process, writes stdin, captures output, and removes the cwd', async () => {
+    const result = await runLoaderSmoke({
+      label: 'success fixture',
+      tempDirPrefix: 'loader-smoke-success-',
+      binScript: fixture('success'),
+      configPath,
+      tsconfigPath,
+      env: { LOADER_SMOKE_MARKER: 'present' },
+      stdinLines: ['one', 'two'],
+    })
+    const output = JSON.parse(result.stdout) as {
+      configPath: string
+      cwd: string
+      dshHome: string
+      agentsHome: string
+      marker: string
+      input: string
+    }
+    expect(output).toMatchObject({
+      configPath,
+      marker: 'present',
+      input: 'one\ntwo\n',
+    })
+    expect(canonicalTempPath(output.dshHome)).toBe(`${canonicalTempPath(output.cwd)}/.dsh`)
+    expect(canonicalTempPath(output.agentsHome)).toBe(`${canonicalTempPath(output.cwd)}/.agents`)
+    expect(result.stderr).toContain('fixture stderr')
+    expect(existsSync(output.cwd)).toBe(false)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
+  it('rejects a non-zero exit with captured diagnostics', async () => {
+    await expect(runLoaderSmoke({
+      label: 'failure fixture',
+      tempDirPrefix: 'loader-smoke-fail-',
+      binScript: fixture('fail'),
+      configPath,
+      tsconfigPath,
+    })).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed')
+  })
+
+  it('kills a process at its deadline and reports captured output', async () => {
+    await expect(runLoaderSmoke({
+      label: 'hanging fixture',
+      tempDirPrefix: 'loader-smoke-hang-',
+      binScript: fixture('hang'),
+      configPath,
+      tsconfigPath,
+      processTimeoutMs: 100,
+    })).rejects.toThrow('hanging fixture did not exit within 0.1s.')
+  })
+})

+ 11 - 0
packages/support/loader-smoke/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": []
+}

+ 10 - 0
pnpm-lock.yaml

@@ -1070,6 +1070,16 @@ importers:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
 
+  packages/support/loader-smoke:
+    dependencies:
+      tsx:
+        specifier: ^4.22.4
+        version: 4.22.4
+    devDependencies:
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
   packages/support/subagent-mock:
     dependencies:
       schemastery:

+ 1 - 0
tsconfig.build.json

@@ -61,6 +61,7 @@
     { "path": "./packages/ui/stdio-agent" },
     { "path": "./packages/support/llm-replay" },
     { "path": "./packages/support/acp-snapshot" },
+    { "path": "./packages/support/loader-smoke" },
     { "path": "./packages/subagent/subagent" },
     { "path": "./packages/support/subagent-mock" },
     { "path": "./packages/subagent/tool-subagent" },

+ 1 - 0
tsconfig.json

@@ -72,6 +72,7 @@
     { "path": "./packages/ui/stdio-agent" },
     { "path": "./packages/support/llm-replay" },
     { "path": "./packages/support/acp-snapshot" },
+    { "path": "./packages/support/loader-smoke" },
     { "path": "./packages/subagent/subagent" },
     { "path": "./packages/support/subagent-mock" },
     { "path": "./packages/subagent/tool-subagent" },