From 5ed531acbaf39d9dae9991ed48d3a775fa11c841 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:42:35 +0800 Subject: [PATCH 1/5] ci(examples): run examples / testing from built lib/js --- .github/workflows/e2e.yml | 10 +- AGENTS.md | 2 +- docs/rfc/INDEX.md | 1 + ...7-run-ci-examples-from-built-lib.i18n.yaml | 6 + ...26-07-17-run-ci-examples-from-built-lib.md | 42 ++++++ ...07-17-run-ci-examples-from-built-lib.zh.md | 42 ++++++ docs/testing.md | 7 +- examples/AGENTS.md | 4 +- examples/acp-agent/tests/acp.e2e.ts | 44 +++--- examples/acp-agent/tests/escalation.e2e.ts | 26 ++-- examples/acp-agent/tests/hooks.e2e.ts | 14 +- .../fixtures/context/time-context}/cordis.yml | 2 +- examples/package.json | 45 ++++++ knip.json | 18 +-- packages/context/time-context/package.json | 1 + .../time-context/tests/time-context.e2e.ts | 40 ++--- packages/context/time-context/tsconfig.json | 5 +- .../mcp/mcp-client/tests/fixture-server.ts | 2 +- .../mcp/mcp-client/tests/mcp-client.e2e.ts | 14 +- packages/subagent/subagent-acp/package.json | 1 + .../subagent-acp/tests/mock-acp-server.ts | 4 +- .../subagent-acp/tests/subagent-acp.e2e.ts | 40 ++--- .../subagent-acp/tests/subagent-acp.spec.ts | 32 ++-- packages/subagent/subagent-acp/tsconfig.json | 3 + packages/support/acp-snapshot/package.json | 2 +- packages/support/acp-snapshot/src/harness.ts | 56 ++++--- packages/support/acp-snapshot/tsconfig.json | 4 +- packages/support/loader-smoke/README.md | 8 +- packages/support/loader-smoke/src/index.ts | 139 +++++++++++++++--- .../loader-smoke/tests/example-launch.spec.ts | 96 ++++++++++++ .../loader-smoke/tests/loader-smoke.spec.ts | 1 + pnpm-lock.yaml | 123 +++++++++++++++- pnpm-workspace.yaml | 7 + scripts/doc-budgets.manifest.json | 4 +- scripts/run-gates.ts | 17 ++- 35 files changed, 679 insertions(+), 183 deletions(-) create mode 100644 docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md create mode 100644 docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md rename {packages/context/time-context/tests/fixtures => examples/echo-agent/tests/fixtures/context/time-context}/cordis.yml (88%) create mode 100644 examples/package.json create mode 100644 packages/support/loader-smoke/tests/example-launch.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a2015696f4..dd3965f5cd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -110,9 +110,14 @@ jobs: fi echo "DEEPSEEK_API_KEY present." + # The e2e suites boot the example bins in `lib` mode (DSH_EXAMPLE_MODE=lib): + # the built artifact under plain Node, resolving plugins through real package + # exports — the shape a real consumer runs. That requires a prior build. + - name: Build (lib for the e2e example bins) + run: pnpm run build + # Real-API end-to-end tests only. The keyless gates (lint/typecheck/ - # coverage/snapshot/build/etc.) already run in ci.yml on every push/PR; - # no need to repeat them or build first (tests run unbuilt via tsx). + # coverage/snapshot/etc.) already run in ci.yml on every push/PR. # DEEPSEEK_BASE_URL is pinned to the external API; the secret is scoped to # this step (and preflight) only — never exposed to checkout/setup/install. - name: E2E tests (real DeepSeek API) @@ -120,4 +125,5 @@ jobs: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} DEEPSEEK_BASE_URL: https://api.deepseek.com DSH_E2E_MAX_WORKERS: 14 + DSH_EXAMPLE_MODE: lib run: pnpm run test:e2e diff --git a/AGENTS.md b/AGENTS.md index 21e9877e05..e551a7de10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only. +- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. CI subprocesses that boot examples or Cordis configs run built `lib/` under plain Node; only explicit source-path regressions use tsx ([testing policy](docs/testing.md#test-subprocess-launch-modes)). - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 91e67e8730..ab39d4f314 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -191,6 +191,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [A gated Known-Limitations section in every package README](implemented/process/2026-07-10-readme-known-limitations-gate.md) | 2026-07-10 | | [Package Model Experience contract](implemented/process/2026-07-12-package-model-experience-contract.md) | 2026-07-12 | | [TypeScript Program-backed semantic gates](implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) | 2026-07-14 | +| [Run CI examples from built lib](implemented/process/2026-07-17-run-ci-examples-from-built-lib.md) | 2026-07-17 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml new file mode 100644 index 0000000000..9424f2aa24 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-17-run-ci-examples-from-built-lib.md: aae88ee965b4e2211f3a53aeb9c0ee4944d95c2a +2026-07-17-run-ci-examples-from-built-lib.zh.md: 0cd3822a71b8f7399be93beaac74b2177fcbe7ca diff --git a/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md new file mode 100644 index 0000000000..aae88ee965 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md @@ -0,0 +1,42 @@ +# RFC: Run CI examples from built lib + +Status: implemented + +English | [中文](2026-07-17-run-ci-examples-from-built-lib.zh.md) + +## Problem + +CI boots examples and Cordis-backed test projects through `node --import tsx` and the root tsconfig `paths` map. This adds TypeScript transformation cost and changes package resolution: imports resolve to workspace source instead of following package `exports` into built `lib/`. + +These runs therefore do not test the same code or resolution behavior as an installed consumer. A package can pass CI while its built export graph is incomplete or resolves differently. + +## Decision + +Execution has two modes. `src` is the default local-development mode and uses tsx; `lib` is the strict CI mode and starts built bins with plain Node, without tsx or tsconfig path mapping. + +- CI subprocesses that boot an example or a checked-in `cordis.yml` use `lib` mode. +- TypeScript fixtures that only implement an ACP or MCP peer and do not load Cordis run directly with Node. An explicit source-path regression may remain in `src` mode. + +### Resolution topology + +Every test Cordis config must resolve its bare modules by walking upward from the config directory. + +- `examples/` is one pnpm workspace member and provides the shared `examples/node_modules` resolution root. +- Every checked-in test Cordis config, including snapshot configs and package-owned fixtures, lives under its corresponding `examples//` tree. A config owned by `packages///` maps to `examples//tests/fixtures///cordis.yml`; the test driver and assertions remain package-local. +- Every package named by an example Cordis config is declared in both `examples/package.json` for `lib` resolution and the root `tsconfig.json` references for `src` mode. + +### Launch policy + +The shared Loader test harness selects `src` or `lib` from `DSH_EXAMPLE_MODE`. CI builds first and selects `lib`; an unset mode keeps the fast local source loop. + +## Alternatives considered + +- **Keep CI on tsx** — rejected because it preserves transformation overhead and source-only resolution behavior. +- **Use lib everywhere** — rejected because local development would require a build before every run. Dual mode keeps that cost out of the development loop. +- **Build a private `node_modules` tree per test** — rejected because it duplicates consumer scaffolding. The `examples/` workspace root gives every Cordis config one real and declared resolution path. + +## Consequences + +- CI validates built package exports without tsx changing module resolution; local development retains the no-build source loop. +- CI must build before these tests, and manual `lib` runs can observe stale local artifacts. +- Cordis config dependencies are not visible to normal TypeScript import analysis, so `examples/package.json` and the root tsconfig references must stay synchronized with the configs. diff --git a/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md new file mode 100644 index 0000000000..0cd3822a71 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md @@ -0,0 +1,42 @@ +# RFC: 在 CI 中从构建后的 lib 运行示例 + +Status: implemented + +[English](2026-07-17-run-ci-examples-from-built-lib.md) | 中文 + +## 问题 + +CI 通过 `node --import tsx` 和根 tsconfig 的 `paths` 映射启动示例与加载 Cordis 配置的测试项目。这种方式既增加了 TypeScript 转换开销,也改变了包解析行为:import 会解析到 workspace 源码,而不是经包的 `exports` 进入构建后的 `lib/`。 + +因此,这些测试没有覆盖已安装消费方实际运行的代码和解析路径。即使包的构建导出图不完整或解析结果不同,CI 仍可能通过。 + +## 决策 + +执行机制包含两种模式。`src` 是本地开发的默认模式并使用 tsx;`lib` 是严格的 CI 模式,通过 plain Node 启动构建后的 bin,不加载 tsx,也不使用 tsconfig 路径映射。 + +- CI 中启动示例或签入仓库的 `cordis.yml` 的子进程使用 `lib` 模式。 +- 仅实现 ACP 或 MCP 对端、且不加载 Cordis 的 TypeScript fixture(测试前置数据)直接由 Node 运行。只有显式验证源码路径的回归测试可以保留 `src` 模式。 + +### 解析拓扑 + +每个测试 Cordis 配置都必须能从配置文件所在目录向上解析裸模块。 + +- `examples/` 作为一个 pnpm workspace 成员,提供统一的 `examples/node_modules` 解析根目录。 +- 所有签入仓库的测试 Cordis 配置,包括快照配置和包内测试 fixture,都放在对应的 `examples//` 目录树下。归属 `packages///` 的配置映射到 `examples//tests/fixtures///cordis.yml`;测试驱动和断言仍留在包内。 +- 示例 Cordis 配置中引用的每个包都同时登记在 `examples/package.json` 和根 `tsconfig.json` 的 references 中,分别支持 `lib` 与 `src` 解析。 + +### 启动策略 + +共享 Loader 测试 harness 通过 `DSH_EXAMPLE_MODE` 选择 `src` 或 `lib`。CI 先构建再选择 `lib`;未设置模式时保留快速的本地源码开发回路。 + +## 曾考虑的替代方案 + +- **CI 继续使用 tsx**:不予采纳,因为它会保留转换开销和仅适用于源码的解析行为。 +- **所有环境只使用 lib**:不予采纳,因为本地开发每次运行前都必须构建。双模式避免把这项成本带入开发回路。 +- **每个测试单独构造 `node_modules`**:不予采纳,因为它会重复消费方脚手架。以 `examples/` 作为 workspace 根,可让每个 Cordis 配置通过同一条真实且显式声明的路径解析模块。 + +## 后果 + +- CI 可以验证构建后的包导出,不再受 tsx 模块解析影响;本地开发仍保留免构建的源码回路。 +- CI 必须先构建再运行这些测试;手动执行 `lib` 模式时可能读取陈旧的本地产物。 +- 常规 TypeScript import 分析无法识别 Cordis 配置依赖,因此 `examples/package.json`、根 tsconfig references 与配置文件必须保持同步。 diff --git a/docs/testing.md b/docs/testing.md index d4acdc33c4..8d4f0f554f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -26,7 +26,12 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external/nondeterministic boundaries, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. - "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. -- An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). + +## Test subprocess launch modes + +- CI and build-having test lanes run every example or Cordis-config subprocess from built `lib/` through the shared dual-mode launcher. Do not hand-write `--import tsx` for these subprocesses. +- Protocol and operating-system fixtures that do not load Cordis run erasable `.ts` directly with Node, without tsx or the root paths map. +- Only a test whose subject is source-path resolution may select `src`; state that contract in the test. ## When a snapshot test is required diff --git a/examples/AGENTS.md b/examples/AGENTS.md index df330de838..6b820368e4 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Examples -Runnable harness compositions. **Examples are NOT workspaces**: their private `package.json` files are dependency-free stubs, and the cordis Loader boots each `cordis.yml` unbuilt through `tsx` plus the root tsconfig paths. +Runnable harness compositions. `examples/` is one workspace member and the module-resolution root for runnable and test Cordis configs; it is not a build target. [package.json](package.json) declares the packages loaded by those configs, while each leaf's private `package.json` remains metadata only. Extract reusable logic into `packages/`, where per-file coverage and README gates apply. Examples keep only `cordis.yml` wiring, demo artifacts, and e2e/snapshot scenarios; app package bins own boot glue. @@ -13,7 +13,7 @@ Each example has both: Mock-only examples require only the keyless tier; state that exception in the test. -Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke` for isolation, root-tsconfig loading, subprocess lifecycle, diagnostics, EOF, and cleanup; tests supply paths, environment, input, and assertions. +Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke`; tests supply paths, environment, input, and assertions. Every checked-in test Cordis config lives under its corresponding `examples//` leaf. Map a package-owned config to `examples//tests/fixtures///cordis.yml`, keep its driver and assertions package-local, and declare every package it names in both root `tsconfig.json` references and `examples/package.json`. Do not inventory example tests here; the `tests/` trees and root scripts are authoritative. diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 23c6f58301..b19bc9bfb1 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -15,6 +15,7 @@ import { type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' /** * Boots examples/acp-agent as an ACP subprocess. The key-gated prompt leg @@ -25,8 +26,6 @@ import { // The child runs from a temp cwd, so its bin and config path are absolute. const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -// Resolve tsx absolutely because the subprocess runs outside the repo. -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // The root tsconfig supplies unbuilt workspace `paths`; making it explicit // avoids accidental resolution through stale built output. const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) @@ -38,24 +37,21 @@ interface Spawned { stderr: string[] } -// TODO(acp-test-harness): this subprocess/client boot glue is duplicated with -// hooks.e2e.ts and partly with dsh-acp-snapshot's harness. Migrate both e2e -// files onto that launcher before the TSX/env/permission-stub details drift. function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned { - const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, '--config', configPath], - { - cwd, - env: { - ...env, - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_PERMISSION_MODE: 'danger-full-access', - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: ['--config', configPath], + tsconfigPath: repoTsconfig, + env: { + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), }, + }) + const child = spawn( + launch.command, + launch.args, + { cwd, env: { ...env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, ) const stderr: string[] = [] child.stderr.setEncoding('utf8') @@ -138,16 +134,20 @@ describe('acp-agent over real stdio (no key required)', () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) // Collect raw stdout bytes directly (bypass the SDK framing) to inspect. // A dummy key boots the adapter; this purity test sends no prompt and makes no model call. - const child = spawn(process.execPath, ['--import', tsxLoader, binScript, '--config', configPath], { - cwd: workdir, + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: ['--config', configPath], + tsconfigPath: repoTsconfig, env: { - ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', - TSX_TSCONFIG_PATH: repoTsconfig, DSH_PERMISSION_MODE: 'danger-full-access', DSH_HOME: join(workdir, '.dsh'), DSH_AGENTS_HOME: join(workdir, '.agents'), }, + }) + const child = spawn(launch.command, launch.args, { + cwd: workdir, + env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'], }) const out: string[] = [] diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index e18ecd2f4c..7e1024c23f 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -15,6 +15,7 @@ import { type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' /** * Exercises the default ACP composition through the real bin and Loader. The @@ -28,9 +29,8 @@ import { const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // The subprocess runs from a temp cwd outside the repo; point tsx at the repo -// tsconfig so the unbuilt `paths` map resolves (see examples/AGENTS.md). +// tsconfig so the unbuilt `paths` map resolves in src mode (see examples/AGENTS.md). const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) // Without a usable bwrap/Seatbelt runner, the strict attempt fails closed with @@ -55,17 +55,19 @@ interface Spawned { /** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */ function spawnAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: ['--config', configPath], + tsconfigPath: repoTsconfig, + // A dummy key lets the deepseek adapter boot keyless (presence-checked at + // apply, used only on a real model call); the with-key tests carry the + // real key, so the fallback is inert there. + env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || 'sk-dummy-for-boot' }, + }) const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, '--config', configPath], - { - cwd, - // A dummy key lets the deepseek adapter boot keyless (presence-checked at - // apply, used only on a real model call); the with-key tests carry the - // real key, so the fallback is inert there. - env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || 'sk-dummy-for-boot', TSX_TSCONFIG_PATH: repoTsconfig }, - stdio: ['pipe', 'pipe', 'pipe'], - }, + launch.command, + launch.args, + { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, ) const stderr: string[] = [] child.stderr.setEncoding('utf8') diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index 99cebb907e..68dc2648bf 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -15,6 +15,7 @@ import { type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' /** * With-key e2e for the Claude hook bridge. The process-level `./hooks.json` is @@ -25,7 +26,6 @@ import { const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) interface Spawned { @@ -36,10 +36,16 @@ interface Spawned { } function spawnAcpAgent(cwd: string): Spawned { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: ['--config', configPath], + tsconfigPath: repoTsconfig, + env: { DSH_PERMISSION_MODE: 'danger-full-access' }, + }) const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, '--config', configPath], - { cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig, DSH_PERMISSION_MODE: 'danger-full-access' }, stdio: ['pipe', 'pipe', 'pipe'] }, + launch.command, + launch.args, + { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, ) const stderr: string[] = [] child.stderr.setEncoding('utf8') diff --git a/packages/context/time-context/tests/fixtures/cordis.yml b/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml similarity index 88% rename from packages/context/time-context/tests/fixtures/cordis.yml rename to examples/echo-agent/tests/fixtures/context/time-context/cordis.yml index 480a93c39b..9b59e2ded9 100644 --- a/packages/context/time-context/tests/fixtures/cordis.yml +++ b/examples/echo-agent/tests/fixtures/context/time-context/cordis.yml @@ -1,6 +1,6 @@ # Test-only composition: keep time-context opt-in while exercising its real Loader/app path. - id: mock-llm - name: '../../../../../examples/echo-agent/src/mock-llm.ts' + name: '../../../../src/mock-llm.ts' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000000..a6b6e97bc0 --- /dev/null +++ b/examples/package.json @@ -0,0 +1,45 @@ +{ + "name": "dsh-examples", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", + "dependencies": { + "@cordisjs/plugin-hmr": "workspace:*", + "@cordisjs/plugin-include": "workspace:*", + "@deepseek-ai/dsh-acp-demo": "workspace:*", + "@deepseek-ai/dsh-bash-local": "workspace:*", + "@deepseek-ai/dsh-bash-sandbox": "workspace:*", + "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", + "@deepseek-ai/dsh-compact-basic": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-fs-policy": "workspace:*", + "@deepseek-ai/dsh-hooks-claude": "workspace:*", + "@deepseek-ai/dsh-hooks-codex": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-replay": "workspace:*", + "@deepseek-ai/dsh-permission": "workspace:*", + "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", + "@deepseek-ai/dsh-sandbox-local": "workspace:*", + "@deepseek-ai/dsh-spill-local": "workspace:*", + "@deepseek-ai/dsh-spill-policy": "workspace:*", + "@deepseek-ai/dsh-stdio-demo": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-subagent-fork": "workspace:*", + "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-time-context": "workspace:*", + "@deepseek-ai/dsh-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-tool-cordis": "workspace:*", + "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tool-fs-search": "workspace:*", + "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-todo": "workspace:*", + "@deepseek-ai/dsh-tool-workflow": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-web": "workspace:*", + "@deepseek-ai/dsh-web-fetch-local": "workspace:*", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:*" + } +} diff --git a/knip.json b/knip.json index 9c644cebb7..ad83bbbd2e 100644 --- a/knip.json +++ b/knip.json @@ -5,15 +5,16 @@ "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime", "website"], "workspaces": { ".": { + "project": ["scripts/**/*.ts"] + }, + "examples": { "entry": [ - "examples/echo-agent/src/*.ts", - "examples/echo-agent/tests/**/*.e2e.ts", - "examples/coding-agent/tests/**/*.e2e.ts", - "examples/cordis-agent/tests/**/*.e2e.ts", - "examples/acp-agent/tests/**/*.e2e.ts", - "examples/*/tests/**/*.snapshot.ts" + "echo-agent/src/*.ts", + "*/tests/**/*.e2e.ts", + "*/tests/**/*.snapshot.ts" ], - "project": ["scripts/**/*.ts", "examples/**/*.ts"] + "project": ["**/*.ts"], + "ignoreDependencies": ["@deepseek-ai/.+", "@cordisjs/.+"] }, "packages/*/*": { "entry": ["tests/**/*.spec.ts"], @@ -52,8 +53,7 @@ }, "packages/support/acp-snapshot": { "entry": ["tests/**/*.spec.ts", "tests/fixtures/fake-acp-agent.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignoreDependencies": ["cordis"] + "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/support/loader-smoke": { "entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"], diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 8900eaf900..c0d69a75cb 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index 5c9b4e8e62..f14981ea36 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -4,12 +4,17 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +// Keep the Loader config under examples so both modes exercise the same deployable +// topology: local fixture source plus bare plugins owned by the examples workspace. const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url)) +const configPath = fileURLToPath(new URL( + '../../../../examples/echo-agent/tests/fixtures/context/time-context/cordis.yml', + import.meta.url, +)) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const PROCESS_TIMEOUT_MS = 30_000 const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:' @@ -39,21 +44,22 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> { workdir = await mkdtemp(join(tmpdir(), 'time-context-e2e-')) const cwd = workdir return new Promise((resolve, reject) => { - const proc = spawn( - process.execPath, - ['--expose-internals', '--import', tsxLoader, binScript, configPath], - { - cwd, - env: { - ...process.env, - TZ: 'Asia/Shanghai', - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [configPath], + tsconfigPath: repoTsconfig, + exposeInternals: true, + env: { + TZ: 'Asia/Shanghai', + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), }, - ) + }) + const proc = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) child = proc let stdout = '' let stderr = '' diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index f8e14d8aa2..7815242b55 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, { "path": "../../llm/llm" }, - { "path": "../../core/agent" } + { "path": "../../core/agent" }, + { "path": "../../core/system-prompt" }, + { "path": "../../core/agent" }, + { "path": "../../support/loader-smoke" } ] } diff --git a/packages/mcp/mcp-client/tests/fixture-server.ts b/packages/mcp/mcp-client/tests/fixture-server.ts index d127412736..8491b96634 100644 --- a/packages/mcp/mcp-client/tests/fixture-server.ts +++ b/packages/mcp/mcp-client/tests/fixture-server.ts @@ -2,7 +2,7 @@ * Minimal MCP server over stdio for e2e testing of the dsh-mcp-client plugin. * Registers controlled tools with predictable behavior for asserting edge cases. * - * Run: node --import tsx fixture-server.ts + * Run: node fixture-server.ts */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index 686d51acea..dc783b3edf 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -26,9 +26,7 @@ import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts' import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) // Resolve package-local .bin for pnpm-hoisted MCP server binaries. const packageDir = fileURLToPath(new URL('..', import.meta.url)) @@ -86,8 +84,8 @@ describe('fixture server — controlled scenarios', () => { transport: 'stdio', serverName: 'fixture', command: process.execPath, - args: ['--import', tsxLoader, fixtureServerPath], - env: { TSX_TSCONFIG_PATH: repoTsconfig }, + args: [fixtureServerPath], + env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, } @@ -170,8 +168,8 @@ describe('fixture server — duplicate serverName', () => { transport: 'stdio', serverName: 'dup', command: process.execPath, - args: ['--import', tsxLoader, fixtureServerPath], - env: { TSX_TSCONFIG_PATH: repoTsconfig }, + args: [fixtureServerPath], + env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, } @@ -191,8 +189,8 @@ describe('fixture server — disposal', () => { transport: 'stdio', serverName: 'fixture', command: process.execPath, - args: ['--import', tsxLoader, fixtureServerPath], - env: { TSX_TSCONFIG_PATH: repoTsconfig }, + args: [fixtureServerPath], + env: {}, cwd: packageDir, toolCallTimeoutMs: 15_000, }) diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 5093e3df40..ec55845f91 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -35,6 +35,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", "@cordisjs/plugin-loader": "^1.0.0-rc.5", diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 000f6d49f0..1496f882aa 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -2,8 +2,8 @@ * Minimal no-network ACP child process for keyless backend tests. Environment variables script its * text and stop reason, a cancel-cooperative or cancel-ignoring hang, permission requests, and a * readiness marker. Disposal fixtures can delay an EOF flush, ignore EOF but exit and mark - * SIGTERM, or trap SIGTERM to require SIGKILL. The specs spawn this non-test module under tsx with - * an explicit tsconfig, mirroring real example boot. + * SIGTERM, or trap SIGTERM to require SIGKILL. The specs run this protocol-only fixture directly + * with Node's type stripping; it imports no harness code or workspace paths. * @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server */ diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 0c20b27fa1..688b1c44aa 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import * as acp from '../src/index.ts' /** @@ -17,9 +18,22 @@ import * as acp from '../src/index.ts' // The real acp-agent example: its bin + cordis.yml (the live DeepSeek config). const binScript = fileURLToPath(new URL('../../../examples/acp-demo/src/bin.ts', import.meta.url)) const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +// How to launch the child acp-agent (src via tsx / lib via plain node, per DSH_EXAMPLE_MODE). +// buildChildEnv scrubs ambient creds but keeps these extras, so the model key is +// forwarded explicitly; TSX_TSCONFIG_PATH is added by the resolver in src mode only. +const childLaunch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: ['--config', exampleConfig], + tsconfigPath: repoTsconfig, + env: { + ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, + ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, + DSH_PERMISSION_MODE: 'danger-full-access', + }, +}) + /** The ACP backend ignores the parent, but the seam requires one. */ const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent @@ -40,18 +54,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive await ctx.plugin(SubagentService) await ctx.plugin(acp, { providerName: 'acp', - command: process.execPath, - args: ['--import', tsxLoader, binScript, '--config', exampleConfig], + command: childLaunch.command, + args: childLaunch.args, cwd: workdir, permission: 'reject', - // The child harness needs the key to reach the model; forward it - // explicitly (buildChildEnv scrubs ambient creds but keeps these extras). - env: { - ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, - ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_PERMISSION_MODE: 'danger-full-access', - }, + env: childLaunch.env as Record, }) const run = await ctx.subagents.start('acp', { @@ -76,17 +83,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive await ctx.plugin(SubagentService) await ctx.plugin(acp, { providerName: 'acp', - command: process.execPath, - args: ['--import', tsxLoader, binScript, '--config', exampleConfig], + command: childLaunch.command, + args: childLaunch.args, cwd: workdir, // The child needs to act (run bash), so approve its permission prompts. permission: 'allow', - env: { - ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, - ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_PERMISSION_MODE: 'danger-full-access', - }, + env: childLaunch.env as Record, }) const run = await ctx.subagents.start('acp', { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index eed3cfe1ed..bfc8476bae 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -21,8 +21,6 @@ import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DI */ const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) /** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */ const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent @@ -46,11 +44,9 @@ async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'r await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], permission, - // The mock-server scripting vars must reach the child; TSX_TSCONFIG_PATH lets - // tsx resolve @deepseek-ai/* from a child cwd outside the repo. - env: { ...mockEnv, TSX_TSCONFIG_PATH: repoTsconfig }, + env: mockEnv, }) return ctx } @@ -197,10 +193,10 @@ describe('dsh-subagent-acp', () => { try { const spec: AcpRunSpec = { command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], cwd: process.cwd(), permission: 'reject', - env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig }, + env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready }, // Short on BOTH tiers: the trap ignores EOF and SIGTERM, so dispose must // burn the EOF window, then the SIGTERM window, then SIGKILL — keep each // small so the whole ladder finishes well within the 4000ms bound. @@ -240,7 +236,7 @@ describe('dsh-subagent-acp', () => { try { const spec: AcpRunSpec = { command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], cwd: process.cwd(), permission: 'reject', // MOCK_HANG so the prompt never resolves on its own — we tear down a live @@ -249,7 +245,7 @@ describe('dsh-subagent-acp', () => { // wider grace. env: { MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, - MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig, + MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', }, disposeEofGraceMs: 2000, disposeGraceMs: 50, @@ -280,12 +276,12 @@ describe('dsh-subagent-acp', () => { try { const spec: AcpRunSpec = { command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], cwd: process.cwd(), permission: 'reject', env: { MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x', - MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm, TSX_TSCONFIG_PATH: repoTsconfig, + MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm, }, // Tiny EOF grace so the ignored-EOF window elapses fast, then SIGTERM. disposeEofGraceMs: 150, @@ -404,9 +400,9 @@ describe('dsh-subagent-acp', () => { await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], permission: 'reject', - env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig }, + env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready }, disposeEofGraceMs: 150, disposeGraceMs: 150, }) @@ -455,10 +451,10 @@ describe('dsh-subagent-acp', () => { request(), { command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], cwd: process.cwd(), permission: 'reject', - env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig }, + env: { MOCK_CRASH_ON_PROMPT: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) }, @@ -493,10 +489,10 @@ describe('dsh-subagent-acp', () => { request(), { command: process.execPath, - args: ['--import', tsxLoader, mockServer], + args: [mockServer], cwd: process.cwd(), permission: 'reject', - env: { MOCK_CRASH_ON_PROMPT: '1', TSX_TSCONFIG_PATH: repoTsconfig }, + env: { MOCK_CRASH_ON_PROMPT: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, onError: () => { throw new Error('sink boom') }, diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index e415ace1de..5aa28528ac 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../subagent-subprocess" + }, + { + "path": "../../support/loader-smoke" } ] } diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index d206b08161..eb6b46deaf 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -23,7 +23,7 @@ "license": "BSD-3-Clause", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", - "tsx": "^4.22.4", + "@deepseek-ai/dsh-loader-smoke": "workspace:*", "vitest": "^4.1.8" }, "peerDependencies": { diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index c26cb84299..05d79d3330 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -11,7 +11,6 @@ import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, delimiter } from 'node:path' -import { fileURLToPath } from 'node:url' import { Readable, Writable } from 'node:stream' import { ClientSideConnection, @@ -23,12 +22,7 @@ import { type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' - -// Resolve tsx's ESM loader to an ABSOLUTE path once: the child runs with its -// cwd in a temp dir OUTSIDE the repo, where a bare `--import tsx` would not -// resolve from node_modules. import.meta.resolve gives this package's tsx -// regardless of the child cwd. -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' /** * The agent composition a scenario runs against: which bin to boot and which @@ -37,7 +31,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) * them from its own `import.meta.url`. */ export interface AgentUnderTest { - /** The agent bin entry (e.g. `packages/examples/acp-demo/src/bin.ts`), run unbuilt via tsx. */ + /** The agent bin's SOURCE entry (e.g. `packages/examples/acp-demo/src/bin.ts`); the `lib` bin is derived from it. */ binScript: string /** * The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps @@ -47,10 +41,8 @@ export interface AgentUnderTest { configPath: string /** * The repo-root tsconfig whose `paths` map resolves the unbuilt workspace - * imports. Passed to the child as `TSX_TSCONFIG_PATH`: tsx finds a tsconfig - * by searching UP from the child's cwd — a temp dir outside the repo — so - * without the explicit pin the dsh-* imports fail before the bin writes a - * byte. + * imports in `src` mode (passed to the child as `TSX_TSCONFIG_PATH`). Ignored + * in `lib` mode, where the example resolves plugins through real `exports`. */ tsconfigPath: string } @@ -184,25 +176,31 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) } - const env: NodeJS.ProcessEnv = { - ...process.env, - TSX_TSCONFIG_PATH: opts.agent.tsconfigPath, - DSH_SNAPSHOT: opts.mode, - DSH_SNAPSHOT_FILE: opts.fixtureFile, - DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, - DSH_SNAPSHOT_SPILL_ROOT: spillRoot, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, - ...opts.childFiles !== undefined && opts.childFiles.length > 0 - ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } - : {}, - } + // Boot the agent in the environment's mode (DSH_EXAMPLE_MODE): `src` runs the + // source bin under tsx with the paths map; `lib` runs the built bin under plain + // Node, resolving plugins through the example's workspace node_modules → lib. + const launch = resolveExampleLaunch({ + srcBin: opts.agent.binScript, + configArgs: ['--config', opts.configPath ?? opts.agent.configPath], + tsconfigPath: opts.agent.tsconfigPath, + env: { + DSH_SNAPSHOT: opts.mode, + DSH_SNAPSHOT_FILE: opts.fixtureFile, + DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, + DSH_SNAPSHOT_SPILL_ROOT: spillRoot, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, + ...opts.childFiles !== undefined && opts.childFiles.length > 0 + ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } + : {}, + }, + }) child = spawn( - process.execPath, - ['--import', tsxLoader, opts.agent.binScript, '--config', opts.configPath ?? opts.agent.configPath], - { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }, + launch.command, + launch.args, + { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, ) child.stderr.setEncoding('utf8') diff --git a/packages/support/acp-snapshot/tsconfig.json b/packages/support/acp-snapshot/tsconfig.json index 749cb0208e..9120df0ad1 100644 --- a/packages/support/acp-snapshot/tsconfig.json +++ b/packages/support/acp-snapshot/tsconfig.json @@ -7,5 +7,7 @@ "include": [ "src" ], - "references": [] + "references": [ + { "path": "../loader-smoke" } + ] } diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index ea197b25d0..04f519b4c1 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -1,10 +1,10 @@ # `@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. +Shared subprocess harness for tests that boot an app and `cordis.yml` through the Cordis Loader. `resolveExampleLaunch` selects local `src` mode (tsx and root tsconfig paths) or CI `lib` mode (plain Node and package exports) from an explicit mode or `DSH_EXAMPLE_MODE`. -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. +`runLoaderSmoke` owns the isolated cwd, DSH homes, stdin, diagnostics, deadline, termination, and cleanup. It returns both streams after a zero exit and rejects with both streams on failure. -This is support-tier test infrastructure, not product API. The consumers are the Loader-path smokes under `examples/{echo-agent,coding-agent,cordis-agent}`. +This is support-tier test infrastructure, not product API. ## Model Experience @@ -12,6 +12,6 @@ None, as this test-only harness boots example processes and inspects their strea ## Known Limitations and Deferred Work -- **Only the unbuilt tsx/Loader path is exercised** — built-bin artifacts remain the responsibility of their separate e2e smokes. +- **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`. - **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it. - **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup. diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index 72839c6a05..f39c6d678c 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -2,6 +2,14 @@ * Shared subprocess harness for keyless example smokes that boot a real * `cordis.yml` through the stdio-agent bin and Cordis Loader. * + * It also owns the mode-aware launch resolver every example subprocess harness shares + * ({@link resolveExampleLaunch}): booting an example bin from TypeScript source under `tsx` (the + * zero-build dev path, resolving `@deepseek-ai/dsh-*` / `@cordisjs/*` through the tsconfig `paths` + * map) or from built `lib/` under plain Node (resolving bare packages through real `exports`, as an + * installed consumer does, while Node type-strips relative example-local TypeScript plugins). + * Consolidating that spawn glue here retires the copies in the ACP snapshot harness and the example + * e2e drivers (the `TODO(acp-test-harness)`). + * * @module @deepseek-ai/dsh-loader-smoke */ @@ -12,23 +20,116 @@ 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 +/** Which artifact an example bin is booted from: unbuilt `src` via tsx, or built `lib` via plain Node. */ +export type ExampleMode = 'src' | 'lib' + +/** Environment variable selecting the mode; CI and pre-push set it to `lib`, dev leaves it unset (`src`). */ +export const EXAMPLE_MODE_ENV = 'DSH_EXAMPLE_MODE' + +/** + * Parse an {@link ExampleMode} from a raw string, defaulting to `src` when absent so an unset + * environment reproduces the dev/tsx behavior. Throws on any other value rather than silently + * falling back, so a typo in a gate's env fails loud. + * @param raw - the raw value; defaults to `process.env.DSH_EXAMPLE_MODE`. + * @returns the validated mode. + */ +export function resolveExampleMode(raw: string | undefined = process.env[EXAMPLE_MODE_ENV]): ExampleMode { + switch (raw) { + case undefined: + case '': + case 'src': + return 'src' + case 'lib': + return 'lib' + default: + throw new Error(`${EXAMPLE_MODE_ENV} must be 'src' or 'lib', got ${JSON.stringify(raw)}.`) + } +} + +/** Inputs to {@link resolveExampleLaunch}. */ +export interface ExampleLaunchOptions { + /** Absolute path to the example bin's TypeScript source entry (`/src/bin.ts`); the `lib` bin is derived from it. */ + readonly srcBin: string + /** Arguments passed after the bin — the config, positional (`[configPath]`) or flagged (`['--config', configPath]`). */ + readonly configArgs?: readonly string[] + /** The mode to launch in; defaults to {@link resolveExampleMode} of the environment. */ + readonly mode?: ExampleMode + /** Absolute repo tsconfig whose `paths` map resolves unbuilt workspace imports. Required in `src` mode, ignored in `lib`. */ + readonly tsconfigPath?: string + /** Prepend `--expose-internals` (the Cordis Loader's bare-plugin resolver needs it for some bins); defaults to `false`. */ + readonly exposeInternals?: boolean + /** Extra environment entries the mode-specific ones layer over; the caller then merges the result over `process.env`. */ + readonly env?: NodeJS.ProcessEnv +} + +/** The resolved spawn: `spawn(command, args, { env: { ...process.env, ...env } })`. */ +export interface ExampleLaunch { + /** The executable to spawn — always the current Node binary. */ + readonly command: string + /** Node flags, the resolved bin, then the caller's `configArgs`. */ + readonly args: string[] + /** Mode-specific environment (`TSX_TSCONFIG_PATH` in `src`, nothing added in `lib`) layered over the caller's `env`. */ + readonly env: NodeJS.ProcessEnv +} + +/** Derive the built-lib bin (`/lib/.js`) from a source bin (`/src/.ts`). */ +function toLibBin(srcBin: string): string { + const marker = '/src/' + const cut = srcBin.lastIndexOf(marker) + if (cut === -1) throw new Error(`resolveExampleLaunch: expected a "/src/" segment in bin path ${JSON.stringify(srcBin)}.`) + const tail = srcBin.slice(cut + marker.length).replace(/\.ts$/, '.js') + return `${srcBin.slice(0, cut)}/lib/${tail}` +} + +/** + * Resolve how to spawn an example bin in the selected mode. + * + * `src` yields `node [--expose-internals] --import ` with `TSX_TSCONFIG_PATH` + * set so the tsconfig `paths` map resolves workspace imports to source. `lib` yields + * `node [--expose-internals] ` under plain Node with no tsx and no paths map, so + * bare package plugins resolve through real package `exports` into built `lib/`; relative example-local + * TypeScript plugins remain source files loaded through Node's built-in type stripping. Bare resolution + * requires the config to live below a workspace that declares its `cordis.yml` package dependencies. + * + * @param options - the source bin, config arguments, mode, and environment. + * @returns the command, argument vector, and mode-specific environment to spawn with. + */ +export function resolveExampleLaunch(options: ExampleLaunchOptions): ExampleLaunch { + const mode = options.mode ?? resolveExampleMode() + const configArgs = options.configArgs ?? [] + const flags = options.exposeInternals === true ? ['--expose-internals'] : [] + const env: NodeJS.ProcessEnv = { ...options.env } + + if (mode === 'src') { + if (options.tsconfigPath === undefined) { + throw new Error("resolveExampleLaunch: 'src' mode needs tsconfigPath for the workspace paths map.") + } + const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) + env.TSX_TSCONFIG_PATH = options.tsconfigPath + return { command: process.execPath, args: [...flags, '--import', tsxLoader, options.srcBin, ...configArgs], env } + } + + return { command: process.execPath, args: [...flags, toLibBin(options.srcBin), ...configArgs], env } +} + /** 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. */ + /** Absolute stdio-agent bin SOURCE path (`/src/bin.ts`); the `lib` bin is derived from it. */ readonly binScript: string /** Absolute real Loader config path. */ readonly configPath: string - /** Absolute repo tsconfig path used for unbuilt workspace-package resolution. */ + /** Absolute repo tsconfig path used for unbuilt workspace-package resolution (required in `src` mode). */ readonly tsconfigPath: string + /** Boot from source via tsx (`src`) or built lib via plain Node (`lib`); defaults to the environment's mode. */ + readonly mode?: ExampleMode /** Environment overrides layered over the parent and isolated DSH homes. */ readonly env?: Readonly /** Lines written to stdin before EOF; omitted means immediate EOF. */ @@ -48,30 +149,28 @@ export interface LoaderSmokeResult { /** * 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. + * temp-directory cleanup on every outcome, and picks src/lib via {@link resolveExampleLaunch}. + * @param options - example paths, mode, environment, stdin, and diagnostic identity. * @returns captured stdout and stderr after a zero exit. */ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix)) const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS + const launch = resolveExampleLaunch({ + srcBin: options.binScript, + configArgs: [options.configPath], + ...options.mode !== undefined ? { mode: options.mode } : {}, + tsconfigPath: options.tsconfigPath, + exposeInternals: true, + env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env }, + }) 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'], - }, - ) + const child = spawn(launch.command, launch.args, { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }) let stdout = '' let stderr = '' let deferredFailure: Error | undefined diff --git a/packages/support/loader-smoke/tests/example-launch.spec.ts b/packages/support/loader-smoke/tests/example-launch.spec.ts new file mode 100644 index 0000000000..d1172db711 --- /dev/null +++ b/packages/support/loader-smoke/tests/example-launch.spec.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + EXAMPLE_MODE_ENV, + resolveExampleLaunch, + resolveExampleMode, +} from '@deepseek-ai/dsh-loader-smoke' + +const SRC_BIN = '/repo/packages/examples/stdio-demo/src/bin.ts' +const TSCONFIG = '/repo/tsconfig.json' + +const originalMode = process.env[EXAMPLE_MODE_ENV] +afterEach(() => { + if (originalMode === undefined) Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV) + else process.env[EXAMPLE_MODE_ENV] = originalMode +}) + +describe('resolveExampleMode', () => { + it('defaults absent/empty/src to src', () => { + expect(resolveExampleMode(undefined)).toBe('src') + expect(resolveExampleMode('')).toBe('src') + expect(resolveExampleMode('src')).toBe('src') + }) + + it('accepts lib', () => { + expect(resolveExampleMode('lib')).toBe('lib') + }) + + it('throws on any other value', () => { + expect(() => resolveExampleMode('prod')).toThrow(/must be 'src' or 'lib'/) + }) + + it('reads the environment when no argument is given', () => { + process.env[EXAMPLE_MODE_ENV] = 'lib' + expect(resolveExampleMode()).toBe('lib') + Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV) + expect(resolveExampleMode()).toBe('src') + }) +}) + +describe('resolveExampleLaunch', () => { + it('src mode: --import tsx on the source bin with the tsconfig paths env', () => { + const { command, args, env } = resolveExampleLaunch({ + srcBin: SRC_BIN, + configArgs: ['./cordis.yml'], + mode: 'src', + tsconfigPath: TSCONFIG, + }) + expect(command).toBe(process.execPath) + expect(args).toContain('--import') + expect(args).toContain(SRC_BIN) + expect(args[args.length - 1]).toBe('./cordis.yml') + expect(args).not.toContain('--expose-internals') + expect(env.TSX_TSCONFIG_PATH).toBe(TSCONFIG) + }) + + it('src mode: throws without a tsconfig path', () => { + expect(() => resolveExampleLaunch({ srcBin: SRC_BIN, mode: 'src' })).toThrow(/needs tsconfigPath/) + }) + + it('lib mode: plain node on the derived lib bin, no tsx and no paths env', () => { + const { args, env } = resolveExampleLaunch({ + srcBin: SRC_BIN, + configArgs: ['--config', './cordis.yml'], + mode: 'lib', + env: { DSH_HOME: '/tmp/home' }, + }) + expect(args).not.toContain('--import') + expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js') + expect(args.slice(-2)).toEqual(['--config', './cordis.yml']) + expect(env.TSX_TSCONFIG_PATH).toBeUndefined() + expect(env.DSH_HOME).toBe('/tmp/home') + }) + + it('prepends --expose-internals when requested', () => { + const { args } = resolveExampleLaunch({ srcBin: SRC_BIN, mode: 'lib', exposeInternals: true }) + expect(args[0]).toBe('--expose-internals') + }) + + it('lib mode: rewrites only the last /src/ segment', () => { + const { args } = resolveExampleLaunch({ + srcBin: '/repo/src/packages/examples/acp-demo/src/bin.ts', + mode: 'lib', + }) + expect(args).toContain('/repo/src/packages/examples/acp-demo/lib/bin.js') + }) + + it('lib mode: throws when the bin has no /src/ segment', () => { + expect(() => resolveExampleLaunch({ srcBin: '/repo/lib/bin.js', mode: 'lib' })).toThrow(/"\/src\/" segment/) + }) + + it('defaults the mode from the environment', () => { + process.env[EXAMPLE_MODE_ENV] = 'lib' + const { args } = resolveExampleLaunch({ srcBin: SRC_BIN }) + expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js') + }) +}) diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index 4cc9f878f9..991a1a64af 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -16,6 +16,7 @@ describe('runLoaderSmoke', () => { binScript: fixture('success'), configPath, tsconfigPath, + mode: 'src', env: { LOADER_SMOKE_MARKER: 'present' }, stdinLines: ['one', 'two'], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d6081cd07..39b7638b69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,117 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + examples: + dependencies: + '@cordisjs/plugin-hmr': + specifier: workspace:* + version: link:../vendor/hmr + '@cordisjs/plugin-include': + specifier: workspace:* + version: link:../vendor/include + '@deepseek-ai/dsh-acp-demo': + specifier: workspace:* + version: link:../packages/examples/acp-demo + '@deepseek-ai/dsh-bash-local': + specifier: workspace:* + version: link:../packages/bash/bash-local + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:* + version: link:../packages/bash/bash-sandbox + '@deepseek-ai/dsh-code-runtime-worker': + specifier: workspace:* + version: link:../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:* + version: link:../packages/compact/compact-basic + '@deepseek-ai/dsh-fs-local': + specifier: workspace:* + version: link:../packages/fs/fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:* + version: link:../packages/fs/fs-policy + '@deepseek-ai/dsh-hooks-claude': + specifier: workspace:* + version: link:../packages/hooks/hooks-claude + '@deepseek-ai/dsh-hooks-codex': + specifier: workspace:* + version: link:../packages/hooks/hooks-codex + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../packages/llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:* + version: link:../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-replay': + specifier: workspace:* + version: link:../packages/support/llm-replay + '@deepseek-ai/dsh-permission': + specifier: workspace:* + version: link:../packages/ui/permission + '@deepseek-ai/dsh-repeat-tool-guard': + specifier: workspace:* + version: link:../packages/guard/repeat-tool-guard + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:* + version: link:../packages/sandbox/sandbox-local + '@deepseek-ai/dsh-spill-local': + specifier: workspace:* + version: link:../packages/spill/spill-local + '@deepseek-ai/dsh-spill-policy': + specifier: workspace:* + version: link:../packages/spill/spill-policy + '@deepseek-ai/dsh-stdio-demo': + specifier: workspace:* + version: link:../packages/examples/stdio-demo + '@deepseek-ai/dsh-subagent': + specifier: workspace:* + version: link:../packages/subagent/subagent + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:* + version: link:../packages/subagent/subagent-fork + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:* + version: link:../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-time-context': + specifier: workspace:* + version: link:../packages/context/time-context + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:* + version: link:../packages/timeout/timeout-policy + '@deepseek-ai/dsh-tool-cordis': + specifier: workspace:* + version: link:../packages/cordis/tool-cordis + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:* + version: link:../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:* + version: link:../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:* + version: link:../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:* + version: link:../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:* + version: link:../packages/workflow/tool-workflow + '@deepseek-ai/dsh-tools': + specifier: workspace:* + version: link:../packages/core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:* + version: link:../packages/ui/user-approval + '@deepseek-ai/dsh-web': + specifier: workspace:* + version: link:../packages/web/web + '@deepseek-ai/dsh-web-fetch-local': + specifier: workspace:* + version: link:../packages/web/web-fetch-local + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:* + version: link:../packages/workflow/workflow-workerthread + packages/bash/bash: devDependencies: '@deepseek-ai/dsh-sandbox': @@ -293,6 +404,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1352,6 +1466,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -1532,9 +1649,9 @@ importers: '@agentclientprotocol/sdk': specifier: 0.25.1 version: 0.25.1(zod@4.4.3) - tsx: - specifier: ^4.22.4 - version: 4.22.4 + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:* + version: link:../loader-smoke vitest: specifier: ^4.1.8 version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 07182c9959..8f93814899 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,13 @@ packages: - vendor/* - packages/*/* - website + # The runnable demo leaves join as ONE workspace member: examples/package.json + # declares the union of every leaf's cordis.yml plugins as workspace:*, so a + # plain-node (`:lib`) boot of any leaf (examples//cordis.yml) resolves its + # plugins through real package `exports`→lib by walking up to examples/node_modules. + # Members for DEPENDENCY RESOLUTION only — NOT build targets: tsdown's explicit + # globs (vendor/*, packages/*/*) exclude them. See the example-execute-over-tsx RFC. + - examples # Deploy root of the single-exe build: a pure dependency manifest whose # closure is what the exe bundles and what the Python runtime distributes. - python/sdk-runtime diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index f8a39137aa..3b99ad454a 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -4,8 +4,8 @@ "docs/architecture.md": 1790, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, - "docs/testing.md": 800, - "examples/AGENTS.md": 200, + "docs/testing.md": 960, + "examples/AGENTS.md": 310, "packages/AGENTS.md": 290, "packages/README.md": 760 } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index fde83ff5ea..378cafed90 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -167,7 +167,8 @@ function gatesForMode(selected: Mode): Gate[] { ] case 'ci-snapshot': return [ - pnpmScript('snapshot', 'test:snapshot'), + pnpmScript('build', 'build'), + snapshotGate(), ] case 'ci-artifacts': return ciArtifactGates() @@ -186,7 +187,7 @@ function gatesForMode(selected: Mode): Gate[] { pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('test', 'test'), pnpmScript('duplication', 'duplication'), - pnpmScript('snapshot', 'test:snapshot'), + snapshotGate(), pnpmScript('build', 'build'), ...hygieneLeafGates({ artifactNeeds: ['build'] }), ...docSyncLeafGates({ @@ -207,7 +208,7 @@ function ciPrimaryGates(): Gate[] { lintGate(), pnpmScript('duplication', 'duplication'), coverageGate(), - pnpmScript('snapshot', 'test:snapshot'), + snapshotGate(), demoSmokeGate({ needs: ['lint'] }), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), @@ -284,6 +285,16 @@ function coverageGate(): Gate { }) } +// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node, +// plugins via real exports) — CI and pre-push already build, so they exercise what ships rather +// than the tsx/source path dev uses. It therefore waits on `build`. +function snapshotGate(): Gate { + return pnpmScript('snapshot', 'test:snapshot', { + env: { DSH_EXAMPLE_MODE: 'lib' }, + needs: ['build'], + }) +} + function positiveIntArg(envName: string, flag: string): string[] { const raw = process.env[envName] if (raw === undefined || raw === '') return [] From df21d22e28406b46a7f13941d771f9d93cf55208 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:38:05 +0800 Subject: [PATCH 2/5] fix(examples): enforce config dependency resolution --- examples/package.json | 1 + pnpm-lock.yaml | 3 + scripts/verify-cordis-config.ts | 102 +++++++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/examples/package.json b/examples/package.json index a6b6e97bc0..8d77731a05 100644 --- a/examples/package.json +++ b/examples/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-subagent-spawn": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39b7638b69..9810a9a61e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,9 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:* version: link:../packages/timeout/timeout-policy + '@deepseek-ai/dsh-token-meter': + specifier: workspace:* + version: link:../packages/llm/token-meter '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../packages/cordis/tool-cordis diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 131bcf0e58..c4075cb256 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -1,18 +1,32 @@ /** - * Reject JavaScript expressions in Cordis Loader entry metadata. + * Validate Cordis Loader entry metadata and example package resolution. * * The Loader interpolates only a plugin entry's `config`; expression objects in * fields such as `disabled` remain truthy data and silently change composition. + * Example configs run from built packages, so every named package must resolve + * from the examples workspace and every local package must be in the root + * TypeScript project graph. */ import { globSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { dirname, relative, resolve } from 'node:path' import * as yaml from 'js-yaml' +import ts from 'typescript' interface JsExpr { __jsExpr: string } +interface PackageManifest { + name?: string + dependencies?: Record +} + +interface PluginReference { + file: string + name: string +} + const root = resolve(import.meta.dirname, '..') const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { @@ -30,6 +44,7 @@ const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], { exclude: ['.claude/**', 'node_modules/**', 'vendor/**'], }).sort() const errors: string[] = [] +const examplePluginReferences: PluginReference[] = [] for (const file of files) { const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) @@ -42,8 +57,10 @@ for (const file of files) { } } +errors.push(...validateExampleResolution()) + if (errors.length > 0) { - console.error('verify-cordis-config: Loader entry metadata is static; move !!js under plugin config or select an explicit overlay.') + console.error('verify-cordis-config: invalid Loader metadata or example package resolution:') for (const error of errors) console.error(`- ${error}`) process.exitCode = 1 } else { @@ -55,6 +72,7 @@ function validateEntry(value: unknown, file: string, path: string): void { errors.push(`${file}${path}: entry must be an object`) return } + recordExamplePlugin(value, file) validateMetadata(value, file, path) if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) { for (let index = 0; index < value.config.length; index++) { @@ -68,6 +86,7 @@ function validateEntry(value: unknown, file: string, path: string): void { const patch = config.patches[index] const patchPath = `${path}.config.patches[${index}]` if (!isRecord(patch)) continue + recordExamplePlugin(patch, file) validateMetadata(patch, file, patchPath) if (!isUnknownArray(patch.insert)) continue for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) { @@ -76,6 +95,83 @@ function validateEntry(value: unknown, file: string, path: string): void { } } +function recordExamplePlugin(entry: Record, file: string): void { + if (file.startsWith('examples/') && typeof entry.name === 'string') { + examplePluginReferences.push({ file, name: entry.name }) + } +} + +function validateExampleResolution(): string[] { + const violations: string[] = [] + const exampleManifest = readManifest('examples/package.json') + const dependencies = exampleManifest.dependencies ?? {} + const localPackages = localPackageDirectories() + const rootReferences = rootProjectReferences() + const requiredPackages = new Map>() + + for (const reference of examplePluginReferences) { + const packageName = packageNameFromSpecifier(reference.name) + if (packageName === undefined) continue + const locations = requiredPackages.get(packageName) ?? new Set() + locations.add(reference.file) + requiredPackages.set(packageName, locations) + } + + for (const [packageName, locations] of requiredPackages) { + if (!(packageName in dependencies)) { + violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`) + } + } + + const localExamplePackages = new Set([ + ...Object.keys(dependencies), + ...requiredPackages.keys(), + ]) + for (const packageName of localExamplePackages) { + const packageDirectory = localPackages.get(packageName) + if (packageDirectory === undefined || rootReferences.has(packageDirectory)) continue + const repoPath = relative(root, packageDirectory).replaceAll('\\', '/') + violations.push(`tsconfig.json: missing project reference for ${packageName} (${repoPath})`) + } + + return violations +} + +function readManifest(path: string): PackageManifest { + return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest +} + +function localPackageDirectories(): Map { + const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root }) + const packages = new Map() + for (const manifestPath of manifests) { + const manifest = readManifest(manifestPath) + if (manifest.name !== undefined) packages.set(manifest.name, resolve(root, dirname(manifestPath))) + } + return packages +} + +function rootProjectReferences(): Set { + const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), ts.sys.readFile) + if (config.error !== undefined) { + throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) + } + const references = (config.config as { references?: Array<{ path?: unknown }> }).references ?? [] + return new Set(references.flatMap((reference) => { + if (typeof reference.path !== 'string') return [] + return [resolve(root, reference.path)] + })) +} + +function packageNameFromSpecifier(specifier: string): string | undefined { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined + const segments = specifier.split('/') + if (specifier.startsWith('@')) { + return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined + } + return segments[0] || undefined +} + function validateMetadata(entry: Record, file: string, path: string): void { for (const field of metadataFields) { if (!(field in entry)) continue From b4ab7debb7caf111981c7d37863191c02d8912f3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:43:20 +0800 Subject: [PATCH 3/5] fix(ci): avoid unbound TypeScript reader --- scripts/verify-cordis-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index c4075cb256..88615ee57a 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -152,7 +152,7 @@ function localPackageDirectories(): Map { } function rootProjectReferences(): Set { - const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), ts.sys.readFile) + const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), path => ts.sys.readFile(path)) if (config.error !== undefined) { throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) } From de19c6ca0820cb7bafc48bc8918b9214e7fa9638 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:10:04 +0800 Subject: [PATCH 4/5] fix(examples): run coverage subprocesses from built libs --- packages/support/acp-snapshot/src/harness.ts | 3 +++ .../acp-snapshot/tests/harness.spec.ts | 6 +++-- .../support/acp-snapshot/tests/suite.spec.ts | 6 +++-- packages/support/loader-smoke/src/index.ts | 23 ++++++++++++------- .../loader-smoke/tests/example-launch.spec.ts | 17 +++++++++++++- .../loader-smoke/tests/loader-smoke.spec.ts | 2 ++ scripts/run-gates.ts | 3 +++ 7 files changed, 47 insertions(+), 13 deletions(-) diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 05d79d3330..e2072b6b17 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -33,6 +33,8 @@ import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' export interface AgentUnderTest { /** The agent bin's SOURCE entry (e.g. `packages/examples/acp-demo/src/bin.ts`); the `lib` bin is derived from it. */ binScript: string + /** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */ + libBinScript?: string | undefined /** * The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps * it for the sibling `cordis.snapshot.yml` (the keyless replay overlay), so @@ -181,6 +183,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // Node, resolving plugins through the example's workspace node_modules → lib. const launch = resolveExampleLaunch({ srcBin: opts.agent.binScript, + libBin: opts.agent.libBinScript, configArgs: ['--config', opts.configPath ?? opts.agent.configPath], tsconfigPath: opts.agent.tsconfigPath, env: { diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 42ccbd7de4..1d953f5e95 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -14,10 +14,12 @@ import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness * assertions read plain `rawStdout`. */ +const fakeAgent = fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)) const AGENT: AgentUnderTest = { - binScript: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)), + binScript: fakeAgent, + libBinScript: fakeAgent, // The fake bin ignores its config argv; any real path documents the shape. - configPath: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)), + configPath: fakeAgent, tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)), } diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 89af6c6614..4cedc1cdfb 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -32,9 +32,11 @@ import { * spec once with `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1`, then review and commit the resulting tree. */ +const fakeAgent = fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)) const AGENT = { - binScript: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)), - configPath: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)), + binScript: fakeAgent, + libBinScript: fakeAgent, + configPath: fakeAgent, tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)), } diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index f39c6d678c..edbe39d8ca 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -17,7 +17,6 @@ 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 @@ -54,6 +53,8 @@ export function resolveExampleMode(raw: string | undefined = process.env[EXAMPLE export interface ExampleLaunchOptions { /** Absolute path to the example bin's TypeScript source entry (`/src/bin.ts`); the `lib` bin is derived from it. */ readonly srcBin: string + /** Explicit plain-Node entry for `lib` mode; test fixtures may point this at Node-type-strippable TypeScript. */ + readonly libBin?: string | undefined /** Arguments passed after the bin — the config, positional (`[configPath]`) or flagged (`['--config', configPath]`). */ readonly configArgs?: readonly string[] /** The mode to launch in; defaults to {@link resolveExampleMode} of the environment. */ @@ -78,11 +79,14 @@ export interface ExampleLaunch { /** Derive the built-lib bin (`/lib/.js`) from a source bin (`/src/.ts`). */ function toLibBin(srcBin: string): string { - const marker = '/src/' - const cut = srcBin.lastIndexOf(marker) - if (cut === -1) throw new Error(`resolveExampleLaunch: expected a "/src/" segment in bin path ${JSON.stringify(srcBin)}.`) - const tail = srcBin.slice(cut + marker.length).replace(/\.ts$/, '.js') - return `${srcBin.slice(0, cut)}/lib/${tail}` + const markerLength = '/src/'.length + const cut = Math.max(srcBin.lastIndexOf('/src/'), srcBin.lastIndexOf('\\src\\')) + if (cut === -1) { + throw new Error(`resolveExampleLaunch: expected a "/src/" segment or Windows equivalent in bin path ${JSON.stringify(srcBin)}.`) + } + const separator = srcBin.slice(cut, cut + 1) + const tail = srcBin.slice(cut + markerLength).replace(/\.ts$/, '.js') + return `${srcBin.slice(0, cut)}${separator}lib${separator}${tail}` } /** @@ -108,12 +112,12 @@ export function resolveExampleLaunch(options: ExampleLaunchOptions): ExampleLaun if (options.tsconfigPath === undefined) { throw new Error("resolveExampleLaunch: 'src' mode needs tsconfigPath for the workspace paths map.") } - const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) + const tsxLoader = import.meta.resolve('tsx') env.TSX_TSCONFIG_PATH = options.tsconfigPath return { command: process.execPath, args: [...flags, '--import', tsxLoader, options.srcBin, ...configArgs], env } } - return { command: process.execPath, args: [...flags, toLibBin(options.srcBin), ...configArgs], env } + return { command: process.execPath, args: [...flags, options.libBin ?? toLibBin(options.srcBin), ...configArgs], env } } /** Inputs that vary between real-Loader example smokes. */ @@ -124,6 +128,8 @@ export interface LoaderSmokeOptions { readonly tempDirPrefix: string /** Absolute stdio-agent bin SOURCE path (`/src/bin.ts`); the `lib` bin is derived from it. */ readonly binScript: string + /** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */ + readonly libBinScript?: string | undefined /** Absolute real Loader config path. */ readonly configPath: string /** Absolute repo tsconfig path used for unbuilt workspace-package resolution (required in `src` mode). */ @@ -158,6 +164,7 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { describe('resolveExampleMode', () => { it('defaults absent/empty/src to src', () => { - expect(resolveExampleMode(undefined)).toBe('src') + Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV) + expect(resolveExampleMode()).toBe('src') expect(resolveExampleMode('')).toBe('src') expect(resolveExampleMode('src')).toBe('src') }) @@ -71,6 +72,12 @@ describe('resolveExampleLaunch', () => { expect(env.DSH_HOME).toBe('/tmp/home') }) + it('lib mode: uses an explicit plain-Node bin when provided', () => { + const fixture = '/repo/packages/support/loader-smoke/tests/fixture.ts' + const { args } = resolveExampleLaunch({ srcBin: fixture, libBin: fixture, mode: 'lib' }) + expect(args).toContain(fixture) + }) + it('prepends --expose-internals when requested', () => { const { args } = resolveExampleLaunch({ srcBin: SRC_BIN, mode: 'lib', exposeInternals: true }) expect(args[0]).toBe('--expose-internals') @@ -84,6 +91,14 @@ describe('resolveExampleLaunch', () => { expect(args).toContain('/repo/src/packages/examples/acp-demo/lib/bin.js') }) + it('lib mode: derives the built bin from a Windows source path', () => { + const { args } = resolveExampleLaunch({ + srcBin: String.raw`D:\repo\src\packages\examples\acp-demo\src\bin.ts`, + mode: 'lib', + }) + expect(args).toContain(String.raw`D:\repo\src\packages\examples\acp-demo\lib\bin.js`) + }) + it('lib mode: throws when the bin has no /src/ segment', () => { expect(() => resolveExampleLaunch({ srcBin: '/repo/lib/bin.js', mode: 'lib' })).toThrow(/"\/src\/" segment/) }) diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index 991a1a64af..b99d810188 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -44,6 +44,7 @@ describe('runLoaderSmoke', () => { label: 'failure fixture', tempDirPrefix: 'loader-smoke-fail-', binScript: fixture('fail'), + libBinScript: fixture('fail'), configPath, tsconfigPath, })).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed') @@ -54,6 +55,7 @@ describe('runLoaderSmoke', () => { label: 'hanging fixture', tempDirPrefix: 'loader-smoke-hang-', binScript: fixture('hang'), + libBinScript: fixture('hang'), configPath, tsconfigPath, processTimeoutMs: 100, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 378cafed90..ed575858a6 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -163,6 +163,7 @@ function gatesForMode(selected: Mode): Gate[] { ] case 'ci-coverage': return [ + pnpmScript('build', 'build'), coverageGate(), ] case 'ci-snapshot': @@ -282,6 +283,8 @@ function coverageGate(): Gate { ...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'), ], { label: 'test:coverage', + env: { DSH_EXAMPLE_MODE: 'lib' }, + needs: ['build'], }) } From 6bc8ab5c573741d2a06040c644f9bd2a62b31c66 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:39:20 +0800 Subject: [PATCH 5/5] fix: verify-package-paths --- packages/support/loader-smoke/tests/example-launch.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/support/loader-smoke/tests/example-launch.spec.ts b/packages/support/loader-smoke/tests/example-launch.spec.ts index bbecc1f545..20520e6fb6 100644 --- a/packages/support/loader-smoke/tests/example-launch.spec.ts +++ b/packages/support/loader-smoke/tests/example-launch.spec.ts @@ -73,7 +73,7 @@ describe('resolveExampleLaunch', () => { }) it('lib mode: uses an explicit plain-Node bin when provided', () => { - const fixture = '/repo/packages/support/loader-smoke/tests/fixture.ts' + const fixture = '/repo/fixture.ts' const { args } = resolveExampleLaunch({ srcBin: fixture, libBin: fixture, mode: 'lib' }) expect(args).toContain(fixture) })