mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop
This commit is contained in:
10
.github/workflows/e2e.yml
vendored
10
.github/workflows/e2e.yml
vendored
@@ -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
|
||||
|
||||
@@ -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-<name>`; 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.
|
||||
|
||||
@@ -918,7 +918,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
Source: [`packages/support/subagent-mock/src/index.ts:86`](../packages/support/subagent-mock/src/index.ts)
|
||||
Source: [`packages/support/subagent-mock/src/index.ts:87`](../packages/support/subagent-mock/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-subagent-spawn`
|
||||
|
||||
|
||||
@@ -301,7 +301,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
|
||||
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:109`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:112`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-added` — emit
|
||||
|
||||
@@ -311,7 +311,7 @@ A provider became resolvable in the registry.
|
||||
'subagent/provider-added'(provider: SubagentProvider): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:83`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-removed` — emit
|
||||
|
||||
@@ -321,7 +321,7 @@ A provider left the registry. Accepted runs remain holder-owned.
|
||||
'subagent/provider-removed'(name: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:89`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/start` — emit
|
||||
|
||||
@@ -331,7 +331,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get(
|
||||
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:100`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `system-prompt/*`
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ list(): string[]
|
||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `ctx.systemPrompt` — `SystemPrompt`
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ interface SubagentStopReasonMap {
|
||||
```ts type-equiv
|
||||
interface SubagentRun {
|
||||
readonly id: SessionId
|
||||
readonly localAgent: Agent | undefined
|
||||
readonly result: Promise<SubagentResult>
|
||||
dispose(): Promise<void>
|
||||
sendMessage?(content: ContentBlock[]): void
|
||||
@@ -76,7 +77,7 @@ interface SubagentRun {
|
||||
}
|
||||
```
|
||||
|
||||
A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope; `parentSession` is the durable transport-neutral lineage. A remote provider instead returns a parent-scoped lifecycle id and does not publish a local child.
|
||||
A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`.
|
||||
|
||||
## The provider seam: `SubagentProvider`
|
||||
|
||||
@@ -91,7 +92,7 @@ interface SubagentProvider {
|
||||
}
|
||||
```
|
||||
|
||||
`start()` fulfills only with a ready run. The service observes its result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. In-process children are discoverable through `ctx.agents`, while remote children need not be. `subagent/end` reports final output or infrastructure failure. Both events are observe-only and contain listener exceptions.
|
||||
`start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions.
|
||||
|
||||
## In-process backends: depth and seed
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:109`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:89`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:100`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:103`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
|
||||
@@ -279,6 +279,7 @@ flowchart TD
|
||||
pkg_tool_skill --> pkg_skill
|
||||
pkg_tool_skill --> pkg_tools
|
||||
pkg_subagent --> pkg_agent
|
||||
pkg_subagent --> pkg_brand
|
||||
pkg_subagent --> pkg_llm
|
||||
pkg_subagent --> pkg_scope
|
||||
pkg_subagent --> pkg_session
|
||||
@@ -493,7 +494,7 @@ flowchart TD
|
||||
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
|
||||
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
|
||||
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
|
||||
| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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/<agent>/` tree. A config owned by `packages/<group>/<package>/` maps to `examples/<agent>/tests/fixtures/<group>/<package>/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.
|
||||
@@ -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/<agent>/` 目录树下。归属 `packages/<group>/<package>/` 的配置映射到 `examples/<agent>/tests/fixtures/<group>/<package>/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 与配置文件必须保持同步。
|
||||
@@ -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: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). 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
|
||||
|
||||
|
||||
@@ -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/<agent>/` leaf. Map a package-owned config to `examples/<agent>/tests/fixtures/<group>/<package>/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.
|
||||
|
||||
|
||||
@@ -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'
|
||||
46
examples/package.json
Normal file
46
examples/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"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-token-meter": "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:*"
|
||||
}
|
||||
}
|
||||
18
knip.json
18
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"],
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1028,7 +1028,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SubagentRun',
|
||||
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
|
||||
declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise<SubagentResult>;\n dispose(): Promise<void>;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise<SubagentRun>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SubagentStartRequest',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -289,8 +290,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
// Drive the observe-only lifecycle events directly (no real child needed — the
|
||||
// bridge just listens). No child agent is registered, so SubagentStart's
|
||||
// child lookup yields undefined and it simply runs the hook.
|
||||
ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') })
|
||||
ctx.emit('subagent/end', { provider: 'inproc', id: SessionId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
|
||||
ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false })
|
||||
ctx.emit('subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
|
||||
|
||||
// Both hooks run async (detached .then); poll for their marker files rather
|
||||
// than a fixed sleep that flakes under load.
|
||||
@@ -325,7 +326,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([]))
|
||||
const warn = vi.fn()
|
||||
ctx.logger.warn = warn as never
|
||||
ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') })
|
||||
ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false })
|
||||
await waitFor(() => existsSync(marker))
|
||||
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
||||
await hooks.dispose()
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
@@ -232,7 +233,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const injected: string[] = []
|
||||
const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-x') })
|
||||
ctx.emit('subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true })
|
||||
await waitFor(() => injected.includes('child guidance'))
|
||||
expect(injected).toContain('child guidance')
|
||||
})
|
||||
@@ -248,7 +249,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-y') })
|
||||
ctx.emit('subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true })
|
||||
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
|
||||
})
|
||||
@@ -292,7 +293,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
ctx.emit('subagent/end', { provider: 'p', id: SessionId('child-z'), stopReason: 'completed' })
|
||||
ctx.emit('subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' })
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
@@ -692,7 +693,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
// Register a live child on its own session cwd; emit subagent/end with its id.
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' })
|
||||
ctx.emit('subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' })
|
||||
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
|
||||
|
||||
@@ -294,6 +294,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe
|
||||
let disposal: Promise<void> | undefined
|
||||
return {
|
||||
id,
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
if (disposal !== undefined) return disposal
|
||||
|
||||
@@ -36,10 +36,9 @@
|
||||
* grace, before the SIGKILL escalation). Touches
|
||||
* MOCK_READY_FILE once armed.
|
||||
*
|
||||
* It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the
|
||||
* child process the ACP backend drives. Kept as a `.ts` run under tsx by the
|
||||
* spec (which passes its own tsconfig), mirroring how the snapshot harness boots
|
||||
* the real example.
|
||||
* It is not a test spec: the specs launch this protocol-only fixture through
|
||||
* the mode-aware example resolver (tsx in source mode, Node type stripping in
|
||||
* built mode). It imports no harness code or workspace paths.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server
|
||||
*/
|
||||
|
||||
@@ -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<string, string>,
|
||||
})
|
||||
|
||||
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<string, string>,
|
||||
})
|
||||
|
||||
const run = await ctx.subagents.start('acp', {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -62,7 +58,7 @@ function text(blocks: { type: string; text?: string }[]): string {
|
||||
/**
|
||||
* Poll until `file` exists (the mock touches it once its prompt is in flight),
|
||||
* so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
|
||||
* subprocess cold-start under tsx is variable, and a fixed sleep both flakes and
|
||||
* subprocess cold-start is variable, and a fixed sleep both flakes and
|
||||
* slows the suite. Fails loud if the child never signals readiness.
|
||||
*/
|
||||
async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
|
||||
@@ -201,14 +197,13 @@ describe('dsh-subagent-acp', () => {
|
||||
try {
|
||||
await expect(startAcpRun(request(), {
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, mockServer],
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: {
|
||||
MOCK_MISSING_SESSION_ID: '1',
|
||||
MOCK_FLUSH_ON_EOF: flushed,
|
||||
MOCK_FLUSH_DELAY_MS: '20',
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
},
|
||||
disposeEofGraceMs: 1000,
|
||||
disposeGraceMs: 100,
|
||||
@@ -230,10 +225,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.
|
||||
@@ -273,7 +268,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
|
||||
@@ -282,7 +277,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,
|
||||
@@ -313,12 +308,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,
|
||||
@@ -437,9 +432,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,
|
||||
})
|
||||
@@ -488,10 +483,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 }) },
|
||||
@@ -526,10 +521,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') },
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../subagent-subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/loader-smoke"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ export async function startInProcessRun(
|
||||
|
||||
return {
|
||||
id: childId,
|
||||
localAgent: child,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
request.signal.removeEventListener('abort', onAbort)
|
||||
|
||||
@@ -50,9 +50,9 @@ Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a
|
||||
|
||||
`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce.
|
||||
|
||||
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, and records `request.parent.session.id` in the child's `parentSession` header. The child may be owned by the parent scope or by a provider/root scope; durable lineage is the transport-neutral local-child relation. Remote providers instead mint a parent-scoped lifecycle id without publishing a local child.
|
||||
A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`.
|
||||
|
||||
The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent.
|
||||
The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names.
|
||||
|
||||
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -31,6 +32,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
* @module @deepseek-ai/dsh-subagent
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
@@ -43,7 +44,9 @@ import type {
|
||||
SubagentRun,
|
||||
SubagentStartRequest,
|
||||
} from './types.ts'
|
||||
import { SubagentRunId } from './types.ts'
|
||||
|
||||
export { SubagentRunId } from './types.ts'
|
||||
export type {
|
||||
SubagentCapabilities,
|
||||
SubagentProvider,
|
||||
@@ -112,18 +115,26 @@ declare module 'cordis' {
|
||||
|
||||
/** Observe-only identifying detail for a ready subagent run. */
|
||||
export interface SubagentRunInfo {
|
||||
/** Unique identity shared with the paired terminal event. */
|
||||
readonly runId: SubagentRunId
|
||||
/** The provider that established the run. */
|
||||
readonly provider: string
|
||||
/** The child agent's id. */
|
||||
readonly id: SessionId
|
||||
/** Whether the provider exposed an exact published in-process child. */
|
||||
readonly local: boolean
|
||||
}
|
||||
|
||||
/** Observe-only outcome detail for a settled subagent run. */
|
||||
export interface SubagentRunEndInfo {
|
||||
/** Unique identity shared with the paired start event. */
|
||||
readonly runId: SubagentRunId
|
||||
/** The provider that ran it. */
|
||||
readonly provider: string
|
||||
/** The child agent's id. */
|
||||
readonly id: SessionId
|
||||
/** Whether the provider exposed an exact published in-process child. */
|
||||
readonly local: boolean
|
||||
/** The terminal stop reason. */
|
||||
readonly stopReason: SubagentResult['stopReason']
|
||||
/** The child's final assistant output, absent on infrastructure rejection. */
|
||||
@@ -208,22 +219,28 @@ export class SubagentService extends Service {
|
||||
|
||||
const parent = request.parent
|
||||
const run = await provider.start(request)
|
||||
const runId = SubagentRunId(randomUUID())
|
||||
const lifecycleIdentity = {
|
||||
runId,
|
||||
provider: name,
|
||||
id: run.id,
|
||||
local: run.localAgent !== undefined,
|
||||
}
|
||||
// Attach the terminal observer before dispatching start. Promise reactions
|
||||
// still run after this synchronous start emission, preserving start → end.
|
||||
void run.result.then(
|
||||
(result) => {
|
||||
this.emitLifecycle('subagent/end', {
|
||||
provider: name,
|
||||
id: run.id,
|
||||
...lifecycleIdentity,
|
||||
stopReason: result.stopReason,
|
||||
lastAssistantMessage: result.output,
|
||||
}, parent)
|
||||
},
|
||||
() => {
|
||||
this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent)
|
||||
this.emitLifecycle('subagent/end', { ...lifecycleIdentity, stopReason: 'error' }, parent)
|
||||
},
|
||||
)
|
||||
this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent)
|
||||
this.emitLifecycle('subagent/start', lifecycleIdentity, parent)
|
||||
return run
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,23 @@
|
||||
*/
|
||||
|
||||
import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** Identifies one accepted subagent run across its lifecycle event pair. */
|
||||
export type SubagentRunId = Branded<'SubagentRunId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link SubagentRunId}.
|
||||
* @param id - the raw id string (the service mints UUIDs; tests may pass fixtures).
|
||||
* @returns the same string, branded.
|
||||
*/
|
||||
export function SubagentRunId(id: string): SubagentRunId {
|
||||
return id as SubagentRunId
|
||||
}
|
||||
|
||||
/**
|
||||
* Which START-TIME features a provider supports. Checked by the service before delegating to
|
||||
* {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks
|
||||
@@ -139,6 +152,12 @@ export interface SubagentRun {
|
||||
* remote provider mints an id unique in the parent namespace.
|
||||
*/
|
||||
readonly id: SessionId
|
||||
/**
|
||||
* The exact published in-process child, or `undefined` for a remote run.
|
||||
* When present, its id is {@link id}; the provider retains no ownership
|
||||
* implication beyond the run's ordinary {@link dispose} contract.
|
||||
*/
|
||||
readonly localAgent: Agent | undefined
|
||||
/**
|
||||
* Resolves with the child's terminal {@link SubagentResult} when the run
|
||||
* settles. Does NOT reject on a child-level failure — a model/transport
|
||||
|
||||
@@ -48,6 +48,7 @@ class StubProvider implements SubagentProvider {
|
||||
this.startCount += 1
|
||||
return {
|
||||
id: SessionId(`child:${this.name}:${request.parent.id}`),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve(this.outcome),
|
||||
async dispose() {},
|
||||
}
|
||||
@@ -137,13 +138,14 @@ describe('SubagentService', () => {
|
||||
const parent = fakeParent('delegator')
|
||||
const events: string[] = []
|
||||
const keys: unknown[] = []
|
||||
ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) })
|
||||
ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) })
|
||||
const runIds: string[] = []
|
||||
ctx.on('subagent/start', function (info) { events.push('start'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) })
|
||||
ctx.on('subagent/end', function (info) { events.push('end'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) })
|
||||
|
||||
const starting = subagents.start('deferred', baseRequest({ parent }))
|
||||
await Promise.resolve()
|
||||
expect(events).toEqual([])
|
||||
ready.resolve({ id: SessionId('child'), result: result.promise, async dispose() {} })
|
||||
ready.resolve({ id: SessionId('child'), localAgent: undefined, result: result.promise, async dispose() {} })
|
||||
const run = await starting
|
||||
expect(events).toEqual(['start'])
|
||||
result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' })
|
||||
@@ -151,6 +153,21 @@ describe('SubagentService', () => {
|
||||
await Promise.resolve()
|
||||
expect(events).toEqual(['start', 'end'])
|
||||
expect(keys).toEqual([parent, parent])
|
||||
expect(runIds[0]).toBe(runIds[1])
|
||||
})
|
||||
|
||||
it('mints distinct lifecycle identities when provider and child ids repeat', async () => {
|
||||
const { ctx, subagents } = await service()
|
||||
subagents.registerProvider(new StubProvider('reused'))
|
||||
const runIds: string[] = []
|
||||
ctx.on('subagent/start', info => void runIds.push(info.runId))
|
||||
|
||||
const first = await subagents.start('reused', baseRequest())
|
||||
const second = await subagents.start('reused', baseRequest())
|
||||
await Promise.all([first.result, second.result])
|
||||
|
||||
expect(runIds).toHaveLength(2)
|
||||
expect(new Set(runIds).size).toBe(2)
|
||||
})
|
||||
|
||||
it('emits no run lifecycle when provider startup rejects', async () => {
|
||||
@@ -192,7 +209,7 @@ describe('SubagentService', () => {
|
||||
capabilities: NO_CAPS,
|
||||
inheritsParentContext: false,
|
||||
async start() {
|
||||
return { id: SessionId('infra-child'), result: failure.promise, async dispose() {} }
|
||||
return { id: SessionId('infra-child'), localAgent: undefined, result: failure.promise, async dispose() {} }
|
||||
},
|
||||
})
|
||||
const failedRun = await subagents.start('infra', baseRequest())
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('dsh-tool-subagent', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('weird-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }),
|
||||
dispose: async () => {},
|
||||
}),
|
||||
@@ -171,6 +172,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -200,6 +202,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('bare-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -327,6 +330,7 @@ describe('dsh-tool-subagent', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('spy-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
@@ -349,6 +353,7 @@ describe('dsh-tool-subagent', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('spy-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [], stopReason: 'error' as const }),
|
||||
dispose: async () => void disposed(),
|
||||
}),
|
||||
@@ -380,6 +385,7 @@ describe('dsh-tool-subagent', () => {
|
||||
}, { once: true })
|
||||
return {
|
||||
id: SessionId('spy-child'),
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -472,6 +478,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture2-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -529,6 +536,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture3-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -558,6 +566,7 @@ describe('dsh-tool-subagent', () => {
|
||||
seen = request
|
||||
return {
|
||||
id: SessionId('capture4-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose: async () => {},
|
||||
}
|
||||
@@ -732,6 +741,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
}, { once: true })
|
||||
return {
|
||||
id,
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
@@ -771,6 +781,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
const order: string[] = []
|
||||
const completed = await settleRun({
|
||||
id: SessionId('child-1'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }),
|
||||
dispose() { order.push('dispose'); return Promise.resolve() },
|
||||
})
|
||||
@@ -782,6 +793,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
let disposed = false
|
||||
const failed = await settleRun({
|
||||
id: SessionId('child-2'),
|
||||
localAgent: undefined,
|
||||
result: Promise.reject(new Error('transport gone')),
|
||||
dispose() { disposed = true; return Promise.resolve() },
|
||||
})
|
||||
@@ -790,6 +802,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
|
||||
const disposeFailed = await settleRun({
|
||||
id: SessionId('child-3'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' }),
|
||||
dispose: () => Promise.reject(new Error('reap failed')),
|
||||
})
|
||||
@@ -797,6 +810,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
|
||||
const bothFailed = await settleRun({
|
||||
id: SessionId('child-4'),
|
||||
localAgent: undefined,
|
||||
result: Promise.reject(new Error('result failed')),
|
||||
dispose: () => Promise.reject(new Error('reap failed')),
|
||||
})
|
||||
@@ -832,6 +846,7 @@ describe('background preflight failure (no orphaned child, by construction)', ()
|
||||
starts += 1
|
||||
return {
|
||||
id: SessionId('probe-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [], stopReason: 'completed' as const }),
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Shared launcher for ACP tests that drive an unbuilt agent subprocess over
|
||||
* JSON-RPC stdio. It owns the tsx loader, workspace-resolution environment,
|
||||
* Shared launcher for ACP tests that drive an agent subprocess over JSON-RPC
|
||||
* stdio. It owns source-or-built launch resolution, workspace environment,
|
||||
* stdout tee, SDK client, update collection, permission fallback, and process
|
||||
* shutdown so e2e and snapshot suites do not each reconstruct that boundary.
|
||||
*
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
@@ -20,15 +19,14 @@ import {
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
// The child runs from a temp directory outside the repo, where a bare
|
||||
// `--import tsx` cannot resolve. Resolve this package's loader once instead.
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
|
||||
/** The unbuilt agent entry, leaf config, and workspace tsconfig an ACP test boots. */
|
||||
/** The source/built agent entry, leaf config, and workspace tsconfig an ACP test boots. */
|
||||
export interface AgentUnderTest {
|
||||
/** The agent bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */
|
||||
/** The agent source bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */
|
||||
binScript: string
|
||||
/** Explicit built-mode entry for fixtures whose source path is not under `src/`. */
|
||||
libBinScript?: string | undefined
|
||||
/** The leaf `cordis.yml` loaded by the bin. */
|
||||
configPath: string
|
||||
/** The repo tsconfig whose paths resolve unbuilt workspace imports. */
|
||||
@@ -77,18 +75,23 @@ export interface LaunchedAcpTestAgent {
|
||||
*/
|
||||
export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTestAgent {
|
||||
const { agent, cwd } = options
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: agent.binScript,
|
||||
libBin: agent.libBinScript,
|
||||
configArgs: ['--config', options.configPath ?? agent.configPath],
|
||||
tsconfigPath: agent.tsconfigPath,
|
||||
env: {
|
||||
...options.env,
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
})
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, agent.binScript, '--config', options.configPath ?? agent.configPath],
|
||||
launch.command,
|
||||
launch.args,
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...options.env,
|
||||
TSX_TSCONFIG_PATH: agent.tsconfigPath,
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
env: { ...process.env, ...launch.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -28,17 +28,19 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
|
||||
/**
|
||||
* Unit tests for the subprocess harness, driven through the REAL spawn path
|
||||
* (tsx loader, temp cwd, env plumbing) against the scripted fake ACP bin in
|
||||
* (mode-aware launcher, temp cwd, env plumbing) against the scripted fake ACP bin in
|
||||
* ./fixtures/fake-acp-agent.ts. Each case writes a `behavior.json` next to a
|
||||
* throwaway fixture path; the fake bin echoes observable facts (env, seeded
|
||||
* workspace, permission outcomes) into `agent_message_chunk` text, so the
|
||||
* 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)),
|
||||
}
|
||||
|
||||
@@ -242,6 +244,23 @@ describe('runScenario', () => {
|
||||
)).rejects.toThrow(/agent stderr:\nfake agent requested startup failure/)
|
||||
})
|
||||
|
||||
it('preserves launch-resolution errors when no child process exists', async () => {
|
||||
const { dir, fixtureFile } = await scenario({})
|
||||
vi.stubEnv('DSH_EXAMPLE_MODE', 'lib')
|
||||
try {
|
||||
await expect(runScenario(
|
||||
{ steps: [] },
|
||||
{
|
||||
agent: { ...AGENT, binScript: join(dir, 'outside-src.ts'), libBinScript: undefined },
|
||||
mode: 'replay',
|
||||
fixtureFile,
|
||||
},
|
||||
)).rejects.toThrow(/expected a "\/src\/" segment/)
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({
|
||||
permissionProbe: true,
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": []
|
||||
"references": [
|
||||
{ "path": "../loader-smoke" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -9,26 +17,125 @@ 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
|
||||
|
||||
/** 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 (`<pkg>/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. */
|
||||
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 (`<pkg>/lib/<name>.js`) from a source bin (`<pkg>/src/<name>.ts`). */
|
||||
function toLibBin(srcBin: string): string {
|
||||
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}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve how to spawn an example bin in the selected mode.
|
||||
*
|
||||
* `src` yields `node [--expose-internals] --import <tsx> <srcBin> <configArgs>` with `TSX_TSCONFIG_PATH`
|
||||
* set so the tsconfig `paths` map resolves workspace imports to source. `lib` yields
|
||||
* `node [--expose-internals] <libBin> <configArgs>` 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 = 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, options.libBin ?? 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 (`<pkg>/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. */
|
||||
/** 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<NodeJS.ProcessEnv>
|
||||
/** Lines written to stdin before EOF; omitted means immediate EOF. */
|
||||
@@ -48,30 +155,29 @@ 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<LoaderSmokeResult> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
|
||||
const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: options.binScript,
|
||||
libBin: options.libBinScript,
|
||||
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
|
||||
|
||||
111
packages/support/loader-smoke/tests/example-launch.spec.ts
Normal file
111
packages/support/loader-smoke/tests/example-launch.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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', () => {
|
||||
Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV)
|
||||
expect(resolveExampleMode()).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('lib mode: uses an explicit plain-Node bin when provided', () => {
|
||||
const fixture = '/repo/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')
|
||||
})
|
||||
|
||||
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: 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/)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ describe('runLoaderSmoke', () => {
|
||||
binScript: fixture('success'),
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
mode: 'src',
|
||||
env: { LOADER_SMOKE_MARKER: 'present' },
|
||||
stdinLines: ['one', 'two'],
|
||||
})
|
||||
@@ -43,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')
|
||||
@@ -53,6 +55,7 @@ describe('runLoaderSmoke', () => {
|
||||
label: 'hanging fixture',
|
||||
tempDirPrefix: 'loader-smoke-hang-',
|
||||
binScript: fixture('hang'),
|
||||
libBinScript: fixture('hang'),
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
processTimeoutMs: 100,
|
||||
|
||||
@@ -69,6 +69,7 @@ class MockSubagentProvider implements SubagentProvider {
|
||||
})
|
||||
return {
|
||||
id,
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose(): Promise<void> {
|
||||
flags.cancelled = true
|
||||
|
||||
@@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve
|
||||
|
||||
## Wiring
|
||||
|
||||
`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair. It classifies subagent completions through live parent ownership or durable parent lineage and retains parent-scoped provider/id counts after child disposal. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`.
|
||||
`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair. It forwards subagent completions only when the lifecycle payload's `local` flag was snapshotted from the provider's exact in-process child; reusable provider names, child ids, and durable lineage never establish locality. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`.
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentRunEndInfo, SubagentRunInfo } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import type { JsonRpcTransportPeer } from './transport.ts'
|
||||
|
||||
@@ -68,15 +68,6 @@ function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
|
||||
return carrierKeyOf(carrier) as Agent
|
||||
}
|
||||
|
||||
/** Whether the live id names a local child related to this exact delegating parent. */
|
||||
function isLocalChild(ctx: Context, id: SessionId, parent: Agent): boolean {
|
||||
const child = ctx.agents.get(id)
|
||||
return child !== undefined && (
|
||||
ctx.agents.isOwnedBy(id, parent)
|
||||
|| child.session.header.parentSession === parent.session.id
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK server over a booted harness context. Constructing it subscribes to
|
||||
* session and subagent lifecycle events, forwarding durable session
|
||||
@@ -92,7 +83,6 @@ export class HarnessSdkServer {
|
||||
private llmFiber: { dispose(): Promise<void> } | undefined
|
||||
private readonly sessions = new Map<string, SessionRecord>()
|
||||
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
|
||||
private readonly localRuns = new Map<string, Map<SessionId, Map<Agent, number>>>()
|
||||
private readonly disposers: (() => void)[] = []
|
||||
private shutdownTask: Promise<Record<string, never>> | undefined
|
||||
private shuttingDown = false
|
||||
@@ -116,36 +106,12 @@ export class HarnessSdkServer {
|
||||
childSessionId: String(session.id),
|
||||
})
|
||||
}))
|
||||
// In-process providers publish the child before start. Count starts related
|
||||
// by exact runtime ownership or durable parent lineage so provider-owned
|
||||
// roots remain local, completions survive child disposal, and reused ids
|
||||
// need no settlement-order assumption.
|
||||
const localRuns = this.localRuns
|
||||
this.disposers.push(ctx.on('subagent/start', function (this: Scoped<SubagentService>, info: SubagentRunInfo) {
|
||||
const parent = subagentParentOf(this)
|
||||
if (!isLocalChild(ctx, info.id, parent)) return
|
||||
const providerRuns = localRuns.get(info.provider) ?? new Map<SessionId, Map<Agent, number>>()
|
||||
const parentRuns = providerRuns.get(info.id) ?? new Map<Agent, number>()
|
||||
parentRuns.set(parent, (parentRuns.get(parent) ?? 0) + 1)
|
||||
providerRuns.set(info.id, parentRuns)
|
||||
localRuns.set(info.provider, providerRuns)
|
||||
}))
|
||||
this.disposers.push(ctx.on('subagent/end', function (this: Scoped<SubagentService>, info: SubagentRunEndInfo) {
|
||||
const parent = subagentParentOf(this)
|
||||
const providerRuns = localRuns.get(info.provider)
|
||||
const parentRuns = providerRuns?.get(info.id)
|
||||
const pendingCount = parentRuns?.get(parent)
|
||||
if (pendingCount !== undefined) {
|
||||
if (pendingCount === 1) parentRuns?.delete(parent)
|
||||
else parentRuns?.set(parent, pendingCount - 1)
|
||||
if (parentRuns?.size === 0) providerRuns?.delete(info.id)
|
||||
if (providerRuns?.size === 0) localRuns.delete(info.provider)
|
||||
}
|
||||
// This protocol reports LOCAL child sessions. A lineage-bearing child
|
||||
// has the session/created-driven start notification above. A remote run
|
||||
// has neither a cached local start nor a live child related to this
|
||||
// parent; an unrelated local agent with the same id never makes it local.
|
||||
if (pendingCount === undefined && !isLocalChild(ctx, info.id, parent)) return
|
||||
// This protocol reports only in-process child sessions. The service
|
||||
// snapshots the provider's exact run provenance through child disposal;
|
||||
// matching ids or parent lineage alone never establishes locality.
|
||||
if (!info.local) return
|
||||
transport.notify('subagent.finished', {
|
||||
provider: info.provider,
|
||||
agentId: String(info.id),
|
||||
@@ -223,7 +189,6 @@ export class HarnessSdkServer {
|
||||
this.sessionCreations.clear()
|
||||
const records = [...this.sessions.values()]
|
||||
this.sessions.clear()
|
||||
this.localRuns.clear()
|
||||
const failures: unknown[] = []
|
||||
while (this.disposers.length > 0) {
|
||||
try {
|
||||
|
||||
@@ -43,7 +43,7 @@ const [
|
||||
const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-"));
|
||||
const ctx = new Context();
|
||||
try {
|
||||
await ctx.plugin(agentCore);
|
||||
await ctx.plugin(agentCore, { workspaceContext: false });
|
||||
await ctx.plugin(SubagentService);
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: storageRoot });
|
||||
await new Promise((ready) => setTimeout(ready, 50));
|
||||
@@ -71,6 +71,7 @@ try {
|
||||
start() {
|
||||
return Promise.resolve({
|
||||
id: child.agent.id,
|
||||
localAgent: child.agent,
|
||||
result: result.promise,
|
||||
dispose() { return Promise.resolve(); },
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ async function makeHarness(storageDir: string) {
|
||||
async function settleSubagent(
|
||||
ctx: Context,
|
||||
parent: Agent,
|
||||
info: SubagentRunEndInfo,
|
||||
info: Omit<SubagentRunEndInfo, 'runId' | 'local'> & { localAgent: Agent | undefined },
|
||||
beforeSettle?: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const result = Promise.withResolvers<SubagentResult>()
|
||||
@@ -81,6 +81,7 @@ async function settleSubagent(
|
||||
async start() {
|
||||
return {
|
||||
id: info.id,
|
||||
localAgent: info.localAgent,
|
||||
result: result.promise,
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
@@ -294,12 +295,14 @@ describe('HarnessSdkServer', () => {
|
||||
await settleSubagent(ctx, parentHandle.agent, {
|
||||
provider: 'spawn',
|
||||
id: SessionId('child-session'),
|
||||
localAgent: handle.agent,
|
||||
stopReason: 'completed',
|
||||
lastAssistantMessage: [{ type: 'text', text: 'child done' }],
|
||||
}, () => handle.dispose())
|
||||
await settleSubagent(ctx, parentHandle.agent, {
|
||||
provider: 'spawn',
|
||||
id: SessionId('parentless-child-session'),
|
||||
localAgent: parentlessHandle.agent,
|
||||
stopReason: 'error',
|
||||
}, () => parentlessHandle.dispose())
|
||||
|
||||
@@ -335,7 +338,7 @@ describe('HarnessSdkServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores a remote run id that collides with an unrelated local agent', async () => {
|
||||
it('ignores a remote run id that collides with a local child of the same parent', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-remote-collision-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
try {
|
||||
@@ -346,24 +349,26 @@ describe('HarnessSdkServer', () => {
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
})
|
||||
const unrelatedHandle = await ctx.agents.create({
|
||||
const collidingChild = await parentHandle.agent.ctx.agents.create({
|
||||
sessionId: SessionId('remote-run-id'),
|
||||
meta: { cwd: storageDir },
|
||||
meta: { cwd: storageDir, parentSession: SessionId('collision-parent') },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
})
|
||||
|
||||
await settleSubagent(ctx, parentHandle.agent, {
|
||||
provider: 'remote',
|
||||
id: SessionId('remote-run-id'),
|
||||
localAgent: undefined,
|
||||
stopReason: 'completed',
|
||||
lastAssistantMessage: [],
|
||||
}, () => unrelatedHandle.dispose())
|
||||
})
|
||||
|
||||
expect(transport.notifications.some(notification =>
|
||||
notification.method === 'subagent.finished'
|
||||
&& notification.params?.agentId === 'remote-run-id',
|
||||
)).toBe(false)
|
||||
|
||||
await collidingChild.dispose()
|
||||
await parentHandle.dispose()
|
||||
await server.shutdown()
|
||||
} finally {
|
||||
@@ -392,12 +397,14 @@ describe('HarnessSdkServer', () => {
|
||||
await settleSubagent(ctx, parentHandle.agent, {
|
||||
provider: 'continuation',
|
||||
id: SessionId('continuation-child'),
|
||||
localAgent: childHandle.agent,
|
||||
stopReason: 'completed',
|
||||
lastAssistantMessage: [{ type: 'text', text: 'first' }],
|
||||
})
|
||||
await settleSubagent(ctx, parentHandle.agent, {
|
||||
provider: 'continuation',
|
||||
id: SessionId('continuation-child'),
|
||||
localAgent: childHandle.agent,
|
||||
stopReason: 'completed',
|
||||
lastAssistantMessage: [{ type: 'text', text: 'second' }],
|
||||
}, () => childHandle.dispose())
|
||||
@@ -436,6 +443,7 @@ describe('HarnessSdkServer', () => {
|
||||
const replacement = Promise.withResolvers<SubagentResult>()
|
||||
const results = [first.promise, sameLifetime.promise, replacement.promise]
|
||||
let starts = 0
|
||||
let currentLocalAgent = oldChild.agent
|
||||
const disposeProvider = ctx.subagents.registerProvider({
|
||||
name: 'reused',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
@@ -444,7 +452,7 @@ describe('HarnessSdkServer', () => {
|
||||
const result = results[starts]
|
||||
starts += 1
|
||||
if (result === undefined) throw new Error('unexpected fourth reused-id run')
|
||||
return Promise.resolve({ id: SessionId('reused-child'), result, dispose: () => Promise.resolve() })
|
||||
return Promise.resolve({ id: SessionId('reused-child'), localAgent: currentLocalAgent, result, dispose: () => Promise.resolve() })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -471,6 +479,7 @@ describe('HarnessSdkServer', () => {
|
||||
meta: { cwd: storageDir, parentSession: SessionId('new-parent') },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
})
|
||||
currentLocalAgent = newChild.agent
|
||||
const secondRun = await ctx.subagents.start('reused', {
|
||||
parent: newParent.agent,
|
||||
prompt: [],
|
||||
@@ -512,7 +521,99 @@ describe('HarnessSdkServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to live lineage and ignores runs without a local child session', async () => {
|
||||
it('keeps locality bound to the accepted run across provider re-registration', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-provider-reuse-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
try {
|
||||
const transport = new FakeTransport()
|
||||
const server = new HarnessSdkServer(ctx, transport)
|
||||
const parent = await ctx.agents.create({
|
||||
sessionId: SessionId('provider-reuse-parent'),
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
})
|
||||
const child = await parent.agent.ctx.agents.create({
|
||||
sessionId: SessionId('provider-reuse-child'),
|
||||
meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
})
|
||||
const localResult = Promise.withResolvers<SubagentResult>()
|
||||
const remoteResult = Promise.withResolvers<SubagentResult>()
|
||||
const unregisterLocal = ctx.subagents.registerProvider({
|
||||
name: 'reused-provider',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => Promise.resolve({
|
||||
id: SessionId('provider-reuse-child'),
|
||||
localAgent: child.agent,
|
||||
result: localResult.promise,
|
||||
dispose: () => Promise.resolve(),
|
||||
}),
|
||||
})
|
||||
const localRun = await ctx.subagents.start('reused-provider', {
|
||||
parent: parent.agent,
|
||||
prompt: [],
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
unregisterLocal()
|
||||
|
||||
const unregisterRemote = ctx.subagents.registerProvider({
|
||||
name: 'reused-provider',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: () => Promise.resolve({
|
||||
id: SessionId('provider-reuse-child'),
|
||||
localAgent: undefined,
|
||||
result: remoteResult.promise,
|
||||
dispose: () => Promise.resolve(),
|
||||
}),
|
||||
})
|
||||
const remoteRun = await ctx.subagents.start('reused-provider', {
|
||||
parent: parent.agent,
|
||||
prompt: [],
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
|
||||
remoteResult.resolve({ output: [{ type: 'text', text: 'remote' }], stopReason: 'completed' })
|
||||
await remoteRun.result
|
||||
await Promise.resolve()
|
||||
expect(transport.notifications.some(notification =>
|
||||
notification.method === 'subagent.finished'
|
||||
&& notification.params?.lastAssistantMessage !== undefined,
|
||||
)).toBe(false)
|
||||
|
||||
await child.dispose()
|
||||
localResult.resolve({ output: [{ type: 'text', text: 'local' }], stopReason: 'completed' })
|
||||
await localRun.result
|
||||
await Promise.resolve()
|
||||
expect(transport.notifications.filter(notification =>
|
||||
notification.method === 'subagent.finished'
|
||||
&& notification.params?.childSessionId === 'provider-reuse-child',
|
||||
)).toEqual([{
|
||||
method: 'subagent.finished',
|
||||
params: {
|
||||
provider: 'reused-provider',
|
||||
agentId: 'provider-reuse-child',
|
||||
parentSessionId: 'provider-reuse-parent',
|
||||
childSessionId: 'provider-reuse-child',
|
||||
status: 'ok',
|
||||
stopReason: 'completed',
|
||||
lastAssistantMessage: [{ type: 'text', text: 'local' }],
|
||||
},
|
||||
}])
|
||||
|
||||
await localRun.dispose()
|
||||
await remoteRun.dispose()
|
||||
unregisterRemote()
|
||||
await parent.dispose()
|
||||
await server.shutdown()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses explicit local provenance when start was missed and ignores remote runs', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
let parentHandle: AgentHandle | undefined
|
||||
@@ -529,6 +630,7 @@ describe('HarnessSdkServer', () => {
|
||||
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||
})
|
||||
const fallbackChild = handle.agent
|
||||
failedHandle = await parentHandle.agent.ctx.agents.create({
|
||||
sessionId: SessionId('failed-child-session'),
|
||||
meta: { cwd: storageDir },
|
||||
@@ -541,12 +643,13 @@ describe('HarnessSdkServer', () => {
|
||||
inheritsParentContext: true,
|
||||
start: () => Promise.resolve({
|
||||
id: SessionId('fallback-child-session'),
|
||||
localAgent: fallbackChild,
|
||||
result: missedStartResult.promise,
|
||||
dispose: () => Promise.resolve(),
|
||||
}),
|
||||
})
|
||||
// Start before the server subscribes, so the terminal fallback must use
|
||||
// the still-live registry entry rather than a cached start record.
|
||||
// Start before the server subscribes. The terminal payload still carries
|
||||
// this run's exact local child without reconstructing it from ids.
|
||||
const missedStartRun = await ctx.subagents.start('fork', {
|
||||
parent: parentHandle.agent,
|
||||
prompt: [],
|
||||
@@ -560,22 +663,25 @@ describe('HarnessSdkServer', () => {
|
||||
await Promise.resolve()
|
||||
await missedStartRun.dispose()
|
||||
disposeMissedStartProvider()
|
||||
// The server also missed this agent's creation, but observes the start;
|
||||
// recover its lineage from the still-live registry entry.
|
||||
// The server also missed this agent's creation but sees the exact child
|
||||
// on the run lifecycle payload.
|
||||
await settleSubagent(ctx, parentHandle.agent, {
|
||||
provider: 'fork-live-fallback',
|
||||
id: SessionId('fallback-child-session'),
|
||||
localAgent: fallbackChild,
|
||||
stopReason: 'completed',
|
||||
lastAssistantMessage: [],
|
||||
})
|
||||
await settleSubagent(ctx, parentHandle.agent, {
|
||||
provider: 'fork',
|
||||
id: SessionId('failed-child-session'),
|
||||
localAgent: failedHandle.agent,
|
||||
stopReason: 'error',
|
||||
})
|
||||
await settleSubagent(ctx, parentHandle.agent, {
|
||||
provider: 'fork',
|
||||
id: SessionId('missing-child-agent'),
|
||||
localAgent: undefined,
|
||||
stopReason: 'error',
|
||||
})
|
||||
|
||||
@@ -800,6 +906,6 @@ describe('HarnessSdkServer', () => {
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
|
||||
await expect(server.shutdown()).rejects.toBe(listenerFailure)
|
||||
expect(on).toHaveBeenCalledTimes(4)
|
||||
expect(on).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +105,7 @@ class StubProvider implements SubagentProvider {
|
||||
if (request.signal.aborted) throw new Error('child start aborted before publication')
|
||||
return {
|
||||
id: SessionId(`stub-child-${index}`),
|
||||
localAgent: undefined,
|
||||
result: terminal.promise,
|
||||
dispose: () => {
|
||||
controlled.disposeCalls += 1
|
||||
@@ -362,6 +363,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('reject-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.reject(new Error('backend exploded')),
|
||||
dispose: () => Promise.resolve(),
|
||||
}),
|
||||
@@ -396,6 +398,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
} as unknown as SubagentResult
|
||||
const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({
|
||||
id: SessionId('raw-invalid-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve(invalid),
|
||||
dispose: () => Promise.resolve(),
|
||||
})
|
||||
@@ -419,6 +422,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('bad-dispose-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
|
||||
cancel: () => { /* settled already */ },
|
||||
dispose: () => { throw new Error('dispose exploded') },
|
||||
@@ -440,6 +444,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
inheritsParentContext: false,
|
||||
start: async () => ({
|
||||
id: SessionId('trap-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }),
|
||||
cancel: () => { /* settled already */ },
|
||||
// The rejection VALUE's own coercion throws: a warn built with bare
|
||||
@@ -767,6 +772,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
}, { once: true })
|
||||
return {
|
||||
id: SessionId('signal-only-child'),
|
||||
localAgent: undefined,
|
||||
result,
|
||||
dispose: () => Promise.resolve(),
|
||||
}
|
||||
@@ -1088,6 +1094,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
|
||||
ready.resolve({
|
||||
id: SessionId('late-ready-child'),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({ output: [], stopReason: 'aborted' }),
|
||||
dispose: () => {
|
||||
disposeCalls += 1
|
||||
@@ -1124,6 +1131,7 @@ describe('dsh-workflow-workerthread', () => {
|
||||
}, { once: true })
|
||||
return {
|
||||
id: SessionId('doomed-child'),
|
||||
localAgent: undefined,
|
||||
result: new Promise(() => { /* never settles; the reap is the teardown */ }),
|
||||
dispose: () => Promise.reject(new Error('dispose exploded during reap')),
|
||||
}
|
||||
|
||||
129
pnpm-lock.yaml
generated
129
pnpm-lock.yaml
generated
@@ -90,6 +90,120 @@ 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-token-meter':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/llm/token-meter
|
||||
'@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 +407,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
|
||||
@@ -1324,6 +1441,9 @@ importers:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
@@ -1358,6 +1478,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
|
||||
@@ -1541,9 +1664,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))
|
||||
|
||||
@@ -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/<leaf>/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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -163,11 +163,13 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
]
|
||||
case 'ci-coverage':
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
coverageGate(),
|
||||
]
|
||||
case 'ci-snapshot':
|
||||
return [
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
pnpmScript('build', 'build'),
|
||||
snapshotGate(),
|
||||
]
|
||||
case 'ci-artifacts':
|
||||
return ciArtifactGates()
|
||||
@@ -186,7 +188,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 +209,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' }),
|
||||
@@ -281,6 +283,18 @@ function coverageGate(): Gate {
|
||||
...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'),
|
||||
], {
|
||||
label: 'test:coverage',
|
||||
env: { DSH_EXAMPLE_MODE: 'lib' },
|
||||
needs: ['build'],
|
||||
})
|
||||
}
|
||||
|
||||
// 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'],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
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<string, unknown>, 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<string, Set<string>>()
|
||||
|
||||
for (const reference of examplePluginReferences) {
|
||||
const packageName = packageNameFromSpecifier(reference.name)
|
||||
if (packageName === undefined) continue
|
||||
const locations = requiredPackages.get(packageName) ?? new Set<string>()
|
||||
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<string, string> {
|
||||
const manifests = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
|
||||
const packages = new Map<string, string>()
|
||||
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<string> {
|
||||
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'))
|
||||
}
|
||||
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<string, unknown>, file: string, path: string): void {
|
||||
for (const field of metadataFields) {
|
||||
if (!(field in entry)) continue
|
||||
|
||||
@@ -383,7 +383,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
|
||||
|
||||
- `info` — the run identity and terminal outcome.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L109)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L112)
|
||||
|
||||
### subagent/provider-added
|
||||
|
||||
@@ -397,7 +397,7 @@ A provider became resolvable in the registry.
|
||||
|
||||
- `provider` — the registered provider.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L83)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L86)
|
||||
|
||||
### subagent/provider-removed
|
||||
|
||||
@@ -411,7 +411,7 @@ A provider left the registry. Accepted runs remain holder-owned.
|
||||
|
||||
- `name` — the provider name that no longer resolves.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L89)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L92)
|
||||
|
||||
### subagent/start
|
||||
|
||||
@@ -425,7 +425,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get(
|
||||
|
||||
- `info` — the provider and ready child identity.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L100)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L103)
|
||||
|
||||
## system-prompt/*
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
Named provider registry and capability-checked start surface.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L142)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L153)
|
||||
|
||||
### ctx.subagents.registerProvider(provider)
|
||||
|
||||
@@ -20,7 +20,7 @@ Register a provider under its name. Registration is effect-scoped and HMR safe;
|
||||
|
||||
**Returns** the exact Cordis effect disposer.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L156)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L167)
|
||||
|
||||
### ctx.subagents.getProvider(name)
|
||||
|
||||
@@ -34,7 +34,7 @@ Look up a provider by name.
|
||||
|
||||
**Returns** the provider, or undefined when absent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L179)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L190)
|
||||
|
||||
### ctx.subagents.list()
|
||||
|
||||
@@ -46,7 +46,7 @@ List registered provider names in insertion order.
|
||||
|
||||
**Returns** the registered names.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L187)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L198)
|
||||
|
||||
### ctx.subagents.start(name, request)
|
||||
|
||||
@@ -61,4 +61,4 @@ Establish a ready child on the named provider. Capability and semantic checks ru
|
||||
|
||||
**Returns** the ready holder-owned run.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L200)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L211)
|
||||
|
||||
Reference in New Issue
Block a user