Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop

This commit is contained in:
Tianyi Cui
2026-07-18 14:27:53 +08:00
60 changed files with 1041 additions and 242 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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`

View File

@@ -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/*`

View File

@@ -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`

View File

@@ -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

View File

@@ -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`) | - |

View File

@@ -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) |

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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 与配置文件必须保持同步。

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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:*"
}
}

View File

@@ -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"],

View File

@@ -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:^",

View File

@@ -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 = ''

View File

@@ -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" }
]
}

View File

@@ -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',

View File

@@ -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()

View File

@@ -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

View File

@@ -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'

View File

@@ -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,
})

View File

@@ -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:^",

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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', {

View File

@@ -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') },

View File

@@ -28,6 +28,9 @@
},
{
"path": "../subagent-subprocess"
},
{
"path": "../../support/loader-smoke"
}
]
}

View File

@@ -175,6 +175,7 @@ export async function startInProcessRun(
return {
id: childId,
localAgent: child,
result,
dispose(): Promise<void> {
request.signal.removeEventListener('abort', onAbort)

View File

@@ -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.

View File

@@ -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:^",

View File

@@ -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
}

View File

@@ -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

View File

@@ -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())

View File

@@ -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(),
}

View File

@@ -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": {

View File

@@ -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'],
},
)

View File

@@ -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,

View File

@@ -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)),
}

View File

@@ -7,5 +7,7 @@
"include": [
"src"
],
"references": []
"references": [
{ "path": "../loader-smoke" }
]
}

View File

@@ -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.

View File

@@ -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

View 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')
})
})

View File

@@ -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,

View File

@@ -69,6 +69,7 @@ class MockSubagentProvider implements SubagentProvider {
})
return {
id,
localAgent: undefined,
result,
dispose(): Promise<void> {
flags.cancelled = true

View File

@@ -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

View File

@@ -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 {

View File

@@ -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(); },
});

View File

@@ -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)
})
})

View File

@@ -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
View File

@@ -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))

View File

@@ -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

View File

@@ -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
}

View File

@@ -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'],
})
}

View File

@@ -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

View File

@@ -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/*

View File

@@ -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)