Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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 packages/sdk/README.md
|
||||
README.md: 0dcf3655fc4d20452981f83000f9b9b586ede17b
|
||||
README.zh.md: 215e2ddc47151d8828cbfaee7eae48d517bdad47
|
||||
README.md: 3f99d6d45ddc64ac068457b0d03e533d21451a5f
|
||||
README.zh.md: 003383c5f6238b37893927813959d52d96350c3e
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Developer tooling for creating, editing, building, and running DeepSeek Harness projects, plus the client SDK stack for driving a harness runtime from another process.
|
||||
|
||||
The [feature Agent Note](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md) owns the developer workflow; the [architecture Agent Note](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the package and project-editing boundaries; the [TypeScript SDK Agent Note](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) owns the client SDK stack.
|
||||
This group contains developer tooling for Harness projects and the client stack for driving a Harness runtime from another process.
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| [`helper`](helper/README.md) | Project aggregate, edit session, builtin features, project documents, templates, package managers, and prompt abstraction |
|
||||
| [`scripts`](scripts/README.md) | The `dsh-sdk` launcher: `start`, `dev`, `build`, and interactive `config` |
|
||||
| [`create-sdk`](create-sdk/README.md) | The `npm create @deepseek-ai/sdk` initializer |
|
||||
| [`sdk-protocol`](sdk-protocol/README.md) | Shared SDK runtime wire protocol: the newline-delimited JSON-RPC transport + named request/notification types |
|
||||
| [`sdk-client`](sdk-client/README.md) | TypeScript client SDK: drive a harness runtime subprocess over stdio JSON-RPC (the Python SDK's design twin) |
|
||||
| [`helper/`](helper/README.md) | Provides the shared project-editing domain |
|
||||
| [`scripts/`](scripts/README.md) | Provides the `dsh-sdk` project commands |
|
||||
| [`create-sdk/`](create-sdk/README.md) | Creates new SDK projects |
|
||||
| [`sdk-protocol/`](sdk-protocol/README.md) | Defines the SDK runtime wire protocol |
|
||||
| [`sdk-client/`](sdk-client/README.md) | Drives a Harness runtime through the TypeScript client API |
|
||||
| [`telemetry/`](telemetry/README.md) | Provides launcher telemetry, consent, and redaction primitives |
|
||||
|
||||
`@deepseek-ai/create-sdk` is the one package-name exception to the repository's `@deepseek-ai/dsh-*` rule: npm's scoped initializer convention requires that name for `npm create @deepseek-ai/sdk`.
|
||||
|
||||
Generated projects keep `cordis.yml` as the only runtime plugin tree. `dsh-sdk dev` adds TypeScript and local-workspace resolution around that same file; it does not create a development-only config.
|
||||
`@deepseek-ai/create-sdk` follows npm's scoped initializer naming convention; the other packages follow the repository's `@deepseek-ai/dsh-*` convention. See the [developer-project workflow](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md), [project-editing architecture](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md), and [TypeScript SDK design](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md).
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
# SDK 包(package)
|
||||
# SDK 包
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
用于创建、编辑、构建和运行 DeepSeek Harness 项目的开发者工具,外加从另一进程驱动 harness 运行时的客户端 SDK 栈。
|
||||
|
||||
[功能 Agent Note(agent 决策记录)](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md)负责开发者工作流;[架构 Agent Note](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)负责包与项目编辑边界;[TypeScript SDK Agent Note](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)负责客户端 SDK 栈。
|
||||
本分组包含 Harness 项目的开发者工具,以及从另一个进程驱动 Harness 运行时的客户端栈。
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| [`helper`](helper/README.md) | 项目聚合、编辑会话、内置功能、项目文档、模板、包管理器与提示词抽象 |
|
||||
| [`scripts`](scripts/README.md) | `dsh-sdk` 启动器:`start`、`dev`、`build` 和交互式 `config` |
|
||||
| [`create-sdk`](create-sdk/README.md) | `npm create @deepseek-ai/sdk` 初始化器 |
|
||||
| [`sdk-protocol`](sdk-protocol/README.md) | 共享的 SDK 运行时通信协议:以换行符分隔的 JSON-RPC 传输 + 具名请求/通知类型 |
|
||||
| [`sdk-client`](sdk-client/README.md) | TypeScript 客户端 SDK:通过 stdio JSON-RPC 驱动 harness 运行时子进程(Python SDK 的设计孪生) |
|
||||
| [`helper/`](helper/README.md) | 提供共享的项目编辑领域 |
|
||||
| [`scripts/`](scripts/README.md) | 提供 `dsh-sdk` 项目命令 |
|
||||
| [`create-sdk/`](create-sdk/README.md) | 创建新的 SDK 项目 |
|
||||
| [`sdk-protocol/`](sdk-protocol/README.md) | 定义 SDK 运行时协议格式 |
|
||||
| [`sdk-client/`](sdk-client/README.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 |
|
||||
| [`telemetry/`](telemetry/README.md) | 提供启动器遥测、同意和脱敏原语 |
|
||||
|
||||
`@deepseek-ai/create-sdk` 是仓库 `@deepseek-ai/dsh-*` 命名规则的唯一例外:npm 的 scoped initializer 约定要求使用该名称,才能支持 `npm create @deepseek-ai/sdk`。
|
||||
|
||||
生成的项目始终以 `cordis.yml` 作为唯一运行时插件树。`dsh-sdk dev` 只是围绕同一文件增加 TypeScript 与本地工作区解析,不会创建仅供开发环境使用的配置。
|
||||
`@deepseek-ai/create-sdk` 遵循 npm 的 scoped initializer 命名约定;其他包遵循仓库的 `@deepseek-ai/dsh-*` 约定。参见[开发者项目工作流](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md)、[项目编辑架构](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)和 [TypeScript SDK 设计](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)。
|
||||
|
||||
@@ -24,9 +24,7 @@
|
||||
"lib/invariant.js",
|
||||
"lib/bin.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper'
|
||||
export interface CreateArgs {
|
||||
directory?: string
|
||||
description?: string
|
||||
provider?: 'deepseek' | 'custom'
|
||||
provider?: 'deepseek-official' | 'custom'
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
@@ -27,7 +27,7 @@ export interface CreateArgs {
|
||||
|
||||
interface CommanderCreateOptions {
|
||||
description?: string
|
||||
provider?: 'deepseek' | 'custom'
|
||||
provider?: 'deepseek-official' | 'custom'
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
@@ -57,11 +57,11 @@ function createProgram(): Command {
|
||||
.argument('[directory]')
|
||||
.option('-h, --help')
|
||||
.option('--description <text>')
|
||||
.addOption(new Option('--provider <name>').choices(['deepseek', 'custom']))
|
||||
.addOption(new Option('--provider <name>').choices(['deepseek-official', 'custom']))
|
||||
.option('--base-url <url>')
|
||||
.option('--api-key <key>')
|
||||
.option('--model <name>')
|
||||
.addOption(new Option('--interface <name>').choices(['acp', 'tui', 'embed']))
|
||||
.addOption(new Option('--interface <name>').choices(['acp', 'embed']))
|
||||
.addOption(new Option('--pm <name>').choices(['npm', 'pnpm', 'yarn']))
|
||||
.addOption(new Option('--install').default(undefined))
|
||||
.addOption(new Option('--no-install').default(undefined))
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface ProjectAnswers {
|
||||
directory: string
|
||||
name: string
|
||||
description: string
|
||||
provider: 'deepseek' | 'custom'
|
||||
provider: 'deepseek-official' | 'custom'
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
@@ -142,14 +142,14 @@ const PROJECT_QUESTION_STEPS: readonly WizardStep<ProjectAnswerState>[] = [
|
||||
apply: (state, value) => { state.description = value },
|
||||
}),
|
||||
questionStep({
|
||||
question: () => new SelectQuestion<'deepseek' | 'custom'>({
|
||||
question: () => new SelectQuestion<'deepseek-official' | 'custom'>({
|
||||
id: 'provider',
|
||||
message: 'Model provider',
|
||||
options: [
|
||||
{ value: 'deepseek', label: 'DeepSeek' },
|
||||
{ value: 'deepseek-official', label: 'DeepSeek' },
|
||||
{ value: 'custom', label: 'Custom endpoint (pi-ai)' },
|
||||
],
|
||||
initialValue: 'deepseek',
|
||||
initialValue: 'deepseek-official',
|
||||
}),
|
||||
prefilled: state => state.args.provider,
|
||||
apply: (state, value) => { state.provider = value },
|
||||
@@ -169,10 +169,9 @@ const PROJECT_QUESTION_STEPS: readonly WizardStep<ProjectAnswerState>[] = [
|
||||
message: 'Run interface',
|
||||
options: [
|
||||
{ value: 'acp', label: 'ACP automation server' },
|
||||
{ value: 'tui', label: 'Terminal TUI' },
|
||||
{ value: 'embed', label: 'Embedded context' },
|
||||
],
|
||||
initialValue: 'tui',
|
||||
initialValue: 'acp',
|
||||
}),
|
||||
prefilled: state => state.args.runInterface,
|
||||
apply: (state, value) => { state.runInterface = value },
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { CreateArgs } from './args.ts'
|
||||
interface HeadlessCreateSpec {
|
||||
directory?: string
|
||||
description?: string
|
||||
provider?: 'deepseek' | 'custom'
|
||||
provider?: 'deepseek-official' | 'custom'
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
|
||||
@@ -2,11 +2,11 @@ Usage: create-sdk [directory] [options]
|
||||
|
||||
Options:
|
||||
--description <text>
|
||||
--provider <deepseek|custom>
|
||||
--provider <deepseek-official|custom>
|
||||
--base-url <url>
|
||||
--api-key <key>
|
||||
--model <name>
|
||||
--interface <acp|tui|embed>
|
||||
--interface <acp|embed>
|
||||
--pm <npm|pnpm|yarn>
|
||||
--install / --no-install
|
||||
--config <path>
|
||||
|
||||
@@ -87,7 +87,7 @@ describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', ()
|
||||
'my-agent',
|
||||
'my-agent',
|
||||
'Snapshot agent',
|
||||
'deepseek',
|
||||
'deepseek-official',
|
||||
'secret-key',
|
||||
'acp',
|
||||
[
|
||||
@@ -166,7 +166,7 @@ describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', ()
|
||||
"message": "Project description",
|
||||
},
|
||||
{
|
||||
"initialValue": "deepseek",
|
||||
"initialValue": "deepseek-official",
|
||||
"kind": "select",
|
||||
"message": "Model provider",
|
||||
"options": [
|
||||
@@ -179,12 +179,11 @@ describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', ()
|
||||
"message": "DeepSeek API key",
|
||||
},
|
||||
{
|
||||
"initialValue": "tui",
|
||||
"initialValue": "acp",
|
||||
"kind": "select",
|
||||
"message": "Run interface",
|
||||
"options": [
|
||||
"ACP automation server",
|
||||
"Terminal TUI",
|
||||
"Embedded context",
|
||||
],
|
||||
},
|
||||
@@ -330,7 +329,7 @@ describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', ()
|
||||
{
|
||||
"id": "provider",
|
||||
"options": [
|
||||
"deepseek",
|
||||
"deepseek-official",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -131,13 +131,13 @@ afterEach(async () => {
|
||||
describe('create arguments', () => {
|
||||
it('parses public options and the private repository link mode', () => {
|
||||
expect(parseCreateArgs([
|
||||
'agent', '--description=demo', '--provider', 'deepseek', '--base-url=https://api.example',
|
||||
'agent', '--description=demo', '--provider', 'deepseek-official', '--base-url=https://api.example',
|
||||
'--api-key', 'key', '--model=m', '--interface', 'acp', '--pm=pnpm', '--no-install',
|
||||
'--link-workspace',
|
||||
])).toEqual({
|
||||
directory: 'agent',
|
||||
description: 'demo',
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
baseURL: 'https://api.example',
|
||||
apiKey: 'key',
|
||||
model: 'm',
|
||||
@@ -151,7 +151,7 @@ describe('create arguments', () => {
|
||||
expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'")
|
||||
expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom')
|
||||
expect(parseCreateArgs(['--help']).help).toBe(true)
|
||||
expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, tui, embed')
|
||||
expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, embed')
|
||||
expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'")
|
||||
expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments')
|
||||
})
|
||||
@@ -205,10 +205,10 @@ describe('CreateWizard and scaffolder', () => {
|
||||
const args = parseCreateArgs([
|
||||
'my-agent',
|
||||
'--description=demo',
|
||||
'--provider=deepseek',
|
||||
'--provider=deepseek-official',
|
||||
'--api-key=deepseek-key',
|
||||
'--model=deepseek-v4-flash',
|
||||
'--interface=tui',
|
||||
'--interface=acp',
|
||||
'--pm=npm',
|
||||
'--no-install',
|
||||
'--link-workspace',
|
||||
@@ -246,8 +246,8 @@ describe('CreateWizard and scaffolder', () => {
|
||||
]
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'my-agent', '--description=demo', '--provider=deepseek', '--api-key=deepseek-key',
|
||||
'--model=deepseek-v4-flash', '--interface=tui', '--pm=npm', '--no-install',
|
||||
'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=deepseek-key',
|
||||
'--model=deepseek-v4-flash', '--interface=acp', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port: new HeadlessPromptPort(),
|
||||
cwd,
|
||||
@@ -274,8 +274,8 @@ describe('CreateWizard and scaffolder', () => {
|
||||
] as unknown as FeatureSelection[]
|
||||
await expect(new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'my-agent', '--description=demo', '--provider=deepseek', '--api-key=k',
|
||||
'--model=m', '--interface=tui', '--pm=npm', '--no-install',
|
||||
'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=k',
|
||||
'--model=m', '--interface=acp', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port: new HeadlessPromptPort(),
|
||||
cwd,
|
||||
@@ -295,7 +295,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
features: [
|
||||
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } },
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'key' } },
|
||||
{ id: featureId('bash'), options: ['local'] },
|
||||
{ id: featureId('app'), options: ['embed'] },
|
||||
{ id: featureId('persistence'), options: ['jsonl'] },
|
||||
@@ -346,7 +346,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'workflow-agent', '--description=test', '--provider=deepseek', '--api-key=key',
|
||||
'workflow-agent', '--description=test', '--provider=deepseek-official', '--api-key=key',
|
||||
'--interface=embed', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port,
|
||||
@@ -371,7 +371,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'empty-key-agent', '--description=test', '--provider=deepseek',
|
||||
'empty-key-agent', '--description=test', '--provider=deepseek-official',
|
||||
'--interface=embed', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port,
|
||||
@@ -398,7 +398,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
'embed',
|
||||
[
|
||||
{ value: featureId('persistence'), choices: ['jsonl'] },
|
||||
{ value: featureId('web'), choices: ['deepseek'] },
|
||||
{ value: featureId('web'), choices: ['deepseek-official'] },
|
||||
],
|
||||
true,
|
||||
'none',
|
||||
@@ -426,14 +426,14 @@ describe('CreateWizard and scaffolder', () => {
|
||||
'agent',
|
||||
[
|
||||
{ value: featureId('persistence'), choices: ['jsonl'] },
|
||||
{ value: featureId('web'), choices: ['deepseek'] },
|
||||
{ value: featureId('web'), choices: ['deepseek-official'] },
|
||||
{ value: featureId('timeout-policy'), choices: ['default'] },
|
||||
],
|
||||
'none',
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'agent', '--description=test', '--provider=deepseek', '--api-key=key',
|
||||
'agent', '--description=test', '--provider=deepseek-official', '--api-key=key',
|
||||
'--interface=embed', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port,
|
||||
@@ -451,7 +451,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
name, '--description=test', '--provider=deepseek', '--api-key=key',
|
||||
name, '--description=test', '--provider=deepseek-official', '--api-key=key',
|
||||
'--interface=embed', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port,
|
||||
@@ -467,7 +467,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
|
||||
describe('create command composition', () => {
|
||||
const argv = (directory: string, install: boolean): string[] => [
|
||||
directory, '--description=test', '--provider=deepseek', '--api-key=key',
|
||||
directory, '--description=test', '--provider=deepseek-official', '--api-key=key',
|
||||
'--interface=embed', '--pm=npm', install ? '--install' : '--no-install',
|
||||
]
|
||||
|
||||
@@ -490,7 +490,7 @@ describe('create command composition', () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-headless-cmd-'))
|
||||
temporary.push(root)
|
||||
const spec = JSON.stringify({
|
||||
directory: 'agent', description: 'test', provider: 'deepseek', apiKey: 'key',
|
||||
directory: 'agent', description: 'test', provider: 'deepseek-official', apiKey: 'key',
|
||||
model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
|
||||
features: [{ id: 'persistence', options: ['jsonl'] }],
|
||||
})
|
||||
@@ -510,7 +510,7 @@ describe('create command composition', () => {
|
||||
const ok = commandContext(root)
|
||||
ok.stdin.isTTY = false
|
||||
ok.stdout.isTTY = false
|
||||
const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek', apiKey: 'key', features: [] })
|
||||
const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek-official', apiKey: 'key', features: [] })
|
||||
await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0)
|
||||
expect(ok.readStdout()).toContain('{"type":"done"}')
|
||||
// stdout stays pure NDJSON: every line parses, human progress goes to stderr
|
||||
@@ -573,7 +573,7 @@ describe('create command composition', () => {
|
||||
expect(install).toHaveBeenCalledOnce()
|
||||
expect(build).toHaveBeenCalledOnce()
|
||||
const spec = JSON.stringify({
|
||||
directory: 'json-agent', description: 'test', provider: 'deepseek', apiKey: 'key',
|
||||
directory: 'json-agent', description: 'test', provider: 'deepseek-official', apiKey: 'key',
|
||||
model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: true, features: [],
|
||||
})
|
||||
const json = commandContext(root)
|
||||
|
||||
@@ -64,7 +64,7 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', ()
|
||||
releaseVersion: '0.0.1',
|
||||
linkWorkspaceRoot: repoRoot,
|
||||
features: [
|
||||
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'test-key' } },
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'test-key' } },
|
||||
{ id: featureId('bash'), options: ['local'] },
|
||||
{ id: featureId('app'), options: ['embed'] },
|
||||
{ id: featureId('persistence'), options: ['jsonl'] },
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/sdk/helper/README.md
|
||||
README.md: 8c6ed9e87be0a97af67849793edb7fa30ffb33ab
|
||||
README.zh.md: e8844aab14dd0494144251de5e0a9f5cfcaf37be
|
||||
README.md: 416fcab9815e50ca662333eb6925cc37eb0c41c4
|
||||
README.zh.md: c50ec7aafa03377bd11759c50eeb2422a12fab68
|
||||
|
||||
@@ -8,9 +8,9 @@ The package owns the builtin typed-spec catalog, provider/app behavior entities,
|
||||
|
||||
All business and document validation completes before commit writes any affected file. Commit detects external edits made after the session opened, but deliberately provides no cross-file rollback after writing starts.
|
||||
|
||||
Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes only the automation bridge; interactive services belong to TUI or Web compositions.
|
||||
Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, and timeout policy. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes only the automation bridge; interactive services belong to host compositions.
|
||||
|
||||
`SdkProject.open()` requires only readable root `package.json` and `cordis.yml`. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically.
|
||||
`SdkProject.open()` requires only readable root `package.json` and `cordis.yml`, but rejects a config that references the removed `@deepseek-ai/dsh-tui` root or a subpath. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically.
|
||||
|
||||
`.env.example` follows the currently selected features. `.env` is append-only: helper may add a missing differently named variable, but never updates or removes existing content.
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
所有业务验证与文档验证都会在提交写入任何受影响文件前完成。提交会检测编辑会话打开后发生的外部修改,但在开始写入后,有意不提供跨文件回滚。
|
||||
|
||||
内置功能包括提供方、bash、app、持久化、HMR(热模块替换)、filesystem、todo、skill(技能)、web、subagent、工作流、压缩(compaction)、钩子、repeat-tool guard、timeout policy 和 ask-user。目录负责功能选项、必需和非默认 Cordis 插件配置、功能依赖、资源贡献与往返标记;create 与 config 使用同一注册表和配置器。ACP(Agent Client Protocol)应用选项只贡献自动化桥;交互式服务属于 TUI 或 Web 组合。
|
||||
内置功能包括提供方、bash、app、持久化、HMR(热模块替换)、filesystem、todo、skill(技能)、web、subagent、工作流、压缩(compaction)、钩子、repeat-tool guard 和 timeout policy。目录负责功能选项、必需和非默认 Cordis 插件配置、功能依赖、资源贡献与往返标记;create 与 config 使用同一注册表和配置器。ACP(Agent Client Protocol)应用选项只贡献自动化桥;交互式服务属于宿主组合。
|
||||
|
||||
`SdkProject.open()` 只要求根目录下的 `package.json` 和 `cordis.yml` 可读。Cordis 配置项用于锚定功能安装;如果某个包只存在于链接的 NPM 依赖闭包中,则该功能仍视为不存在。一旦所属的 Cordis 配置项存在,资源结构不完整就是 `inconsistent`,无法自动修改。
|
||||
`SdkProject.open()` 只要求根目录下的 `package.json` 和 `cordis.yml` 可读,但会拒绝引用已移除的 `@deepseek-ai/dsh-tui` 包根或其子路径的配置。Cordis 配置项用于锚定功能安装;如果某个包只存在于链接的 NPM 依赖闭包中,则该功能仍视为不存在。一旦所属的 Cordis 配置项存在,资源结构不完整就是 `inconsistent`,无法自动修改。
|
||||
|
||||
`.env.example` 跟随当前所选功能。`.env` 仅追加:helper 可以补充缺失且名称不同的变量,但绝不会更新或删除现有内容。
|
||||
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
* @module @deepseek-ai/dsh-helper/features/builtin/app
|
||||
*/
|
||||
|
||||
import { JsExpression } from '../../documents/cordis-yaml-file.ts'
|
||||
import { featureId } from '../../ids.ts'
|
||||
import type { ProjectProfile } from '../../project/types.ts'
|
||||
import type { ProjectProfile, RunInterface } from '../../project/types.ts'
|
||||
import {
|
||||
createAppPackageScripts,
|
||||
createAppProjectArtifacts,
|
||||
@@ -18,9 +17,7 @@ import {
|
||||
} from '../feature.ts'
|
||||
import { ProjectContribution, type ProjectResource } from '../resources.ts'
|
||||
import {
|
||||
cordisConfigEntry,
|
||||
npmCordisConfigEntry,
|
||||
optionalString,
|
||||
ownedTextFile,
|
||||
packageScript,
|
||||
requiredString,
|
||||
@@ -30,10 +27,10 @@ const ID = featureId('app')
|
||||
|
||||
function appProjectResources(
|
||||
profile: ProjectProfile,
|
||||
runInterface: 'acp' | 'tui' | 'embed',
|
||||
runInterface: RunInterface,
|
||||
): readonly ProjectResource[] {
|
||||
const context = createProjectTemplateContext(profile, runInterface)
|
||||
const scripts = createAppPackageScripts(context)
|
||||
const scripts = createAppPackageScripts()
|
||||
return [
|
||||
...createAppProjectArtifacts(context).map(document => (
|
||||
ownedTextFile(ID, document.relativePath, document.serialize())
|
||||
@@ -44,10 +41,10 @@ function appProjectResources(
|
||||
}
|
||||
|
||||
class AppOption extends FeatureOption {
|
||||
override readonly id: 'acp' | 'tui' | 'embed'
|
||||
override readonly id: RunInterface
|
||||
override readonly label: string
|
||||
|
||||
constructor(id: 'acp' | 'tui' | 'embed', label: string) {
|
||||
constructor(id: RunInterface, label: string) {
|
||||
super()
|
||||
this.id = id
|
||||
this.label = label
|
||||
@@ -57,7 +54,6 @@ class AppOption extends FeatureOption {
|
||||
override markerConfigEntries(): readonly { id: string; name: string }[] {
|
||||
switch (this.id) {
|
||||
case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }]
|
||||
case 'tui': return [{ id: 'tui', name: '@deepseek-ai/dsh-tui' }]
|
||||
case 'embed': return []
|
||||
}
|
||||
}
|
||||
@@ -66,7 +62,7 @@ class AppOption extends FeatureOption {
|
||||
override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean {
|
||||
if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile)
|
||||
return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop')
|
||||
&& !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp' || entry.name === '@deepseek-ai/dsh-tui')
|
||||
&& !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')
|
||||
}
|
||||
|
||||
override contribution(profile: ProjectProfile): ProjectContribution {
|
||||
@@ -80,36 +76,13 @@ class AppOption extends FeatureOption {
|
||||
config: { model: profile.runtime.model },
|
||||
}, ['model'], config => requiredString(config, 'model')),
|
||||
])
|
||||
case 'tui':
|
||||
return new ProjectContribution([
|
||||
...appProjectResources(profile, this.id),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'user-interaction',
|
||||
name: '@deepseek-ai/dsh-user-interaction',
|
||||
}),
|
||||
cordisConfigEntry(ID, {
|
||||
id: 'tui-prompt',
|
||||
name: '@deepseek-ai/dsh-tui/prompt',
|
||||
}),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'tui',
|
||||
name: '@deepseek-ai/dsh-tui',
|
||||
config: {
|
||||
welcome: 'TUI agent ready. Give it a coding task.',
|
||||
sessionId: new JsExpression('process.env.DSH_SDK_SESSION_ID'),
|
||||
},
|
||||
}, ['welcome', 'sessionId'], config => [
|
||||
...optionalString(config, 'welcome'),
|
||||
...config.sessionId instanceof JsExpression ? [] : requiredString(config, 'sessionId'),
|
||||
]),
|
||||
])
|
||||
case 'embed':
|
||||
return new ProjectContribution(appProjectResources(profile, this.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Required app selection represented by ACP, TUI, or embed options. */
|
||||
/** Required app selection represented by ACP or embed options. */
|
||||
export class AppFeature extends ExclusiveOptionFeature {
|
||||
override readonly id = ID
|
||||
override readonly summary = 'Run interface'
|
||||
@@ -117,7 +90,6 @@ export class AppFeature extends ExclusiveOptionFeature {
|
||||
override readonly requires = [featureId('spine')]
|
||||
override readonly options = [
|
||||
new AppOption('acp', 'ACP automation server'),
|
||||
new AppOption('tui', 'Terminal TUI'),
|
||||
new AppOption('embed', 'Embedded context'),
|
||||
]
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry
|
||||
required: true,
|
||||
baseResources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'bash-env', package: '@deepseek-ai/dsh-bash-env' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' },
|
||||
],
|
||||
options: [
|
||||
@@ -159,7 +160,7 @@ config:
|
||||
],
|
||||
options: [
|
||||
{
|
||||
id: 'deepseek',
|
||||
id: 'deepseek-official',
|
||||
label: 'DeepSeek search',
|
||||
default: true,
|
||||
markers: [{ id: 'web-search-deepseek', name: '@deepseek-ai/dsh-web-search-deepseek' }],
|
||||
@@ -214,7 +215,14 @@ config:
|
||||
id: 'subagent',
|
||||
summary: 'Delegate work to child agents',
|
||||
mode: 'multiple',
|
||||
baseResources: [{ kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }],
|
||||
// In-process options select continuable background delegation; the
|
||||
// follow-up adapter remains an independently loadable global tool.
|
||||
baseResources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks-local' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
id: 'spawn',
|
||||
@@ -226,7 +234,7 @@ config:
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'tool-subagent',
|
||||
package: '@deepseek-ai/dsh-tool-subagent',
|
||||
config: { provider: 'spawn' } satisfies ToolSubagentConfig,
|
||||
config: { provider: 'spawn', backgroundMode: 'continuable' } satisfies ToolSubagentConfig,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -239,7 +247,11 @@ config:
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'tool-subagent-fork',
|
||||
package: '@deepseek-ai/dsh-tool-subagent',
|
||||
config: { provider: 'fork', toolName: 'subagent_fork' } satisfies ToolSubagentConfig,
|
||||
config: {
|
||||
provider: 'fork',
|
||||
toolName: 'subagent_fork',
|
||||
backgroundMode: 'continuable',
|
||||
} satisfies ToolSubagentConfig,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -351,21 +363,5 @@ config:
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ask-user',
|
||||
summary: 'Ask the user from the model loop',
|
||||
mode: 'single',
|
||||
supportedInterfaces: ['tui'],
|
||||
options: [{
|
||||
id: 'default',
|
||||
label: 'ask_user_question tool',
|
||||
default: true,
|
||||
resources: [{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'tool-ask-user',
|
||||
package: '@deepseek-ai/dsh-tool-ask-user',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
]), profile)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const DEFAULT_MODEL = 'deepseek-v4-flash'
|
||||
const API_KEY_COMMENT = 'Required before start; an empty value makes provider startup fail.'
|
||||
|
||||
class DeepSeekOption extends FeatureOption {
|
||||
override readonly id = 'deepseek'
|
||||
override readonly id = 'deepseek-official'
|
||||
override readonly label = 'DeepSeek'
|
||||
override readonly secrets = [{
|
||||
id: 'apiKey',
|
||||
@@ -76,7 +76,7 @@ export class ProviderFeature extends ExclusiveOptionFeature {
|
||||
|
||||
/** Prefer the direct-fetch adapter and its public endpoint defaults. */
|
||||
override defaultOptions(): readonly string[] {
|
||||
return ['deepseek']
|
||||
return ['deepseek-official']
|
||||
}
|
||||
|
||||
/** Recover literal endpoint overrides from either provider entry. */
|
||||
|
||||
@@ -250,7 +250,7 @@ class DefinedFeature extends Feature {
|
||||
this.required = spec.required ?? false
|
||||
this.requires = (spec.requires ?? []).map(requirement => featureId(requirement.id))
|
||||
this.suggests = (spec.suggests ?? []).map(featureId)
|
||||
this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'tui', 'embed']
|
||||
this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'embed']
|
||||
}
|
||||
|
||||
override defaultOptions(): readonly string[] {
|
||||
|
||||
@@ -113,7 +113,7 @@ export abstract class Feature {
|
||||
/** Features recommended during creation. */
|
||||
readonly suggests: readonly FeatureId[] = []
|
||||
/** Front doors under which this feature is meaningful. */
|
||||
readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'tui', 'embed']
|
||||
readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'embed']
|
||||
|
||||
/**
|
||||
* Options selected when installation has no override.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import { scrubbedParentEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
|
||||
import { promisify } from 'node:util'
|
||||
import type { PackageJsonFile } from '../documents/package-json-file.ts'
|
||||
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
|
||||
@@ -60,7 +60,7 @@ export async function probePackageManagerVersion(name: PackageManagerName, cwd:
|
||||
*/
|
||||
export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (environment === undefined) return scrubbedParentEnv()
|
||||
return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name)))
|
||||
return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name)))
|
||||
}
|
||||
|
||||
/** Node child-process command runner with inherited stdio and quiescent completion. */
|
||||
|
||||
@@ -90,6 +90,7 @@ export class ProjectEditSession implements FeatureProjectView {
|
||||
this.profile = source.profile
|
||||
this.documents = source.cloneDocuments()
|
||||
for (const feature of registry.all()) {
|
||||
/* v8 ignore next -- no current built-in feature is interface-specific */
|
||||
if (!feature.isApplicable(this.profile)) continue
|
||||
const installation = feature.inspect(this)
|
||||
this.states.set(feature.id, {
|
||||
@@ -507,6 +508,7 @@ export class ProjectEditSession implements FeatureProjectView {
|
||||
const view = this.projectView(profile)
|
||||
for (const feature of this.registry.all()) {
|
||||
const state = this.states.get(feature.id)
|
||||
/* v8 ignore next 5 -- no current built-in feature is interface-specific */
|
||||
if (!feature.isApplicable(profile)) {
|
||||
if (state?.state === 'enabled') {
|
||||
throw new Error(`feature ${feature.id} is not available for ${profile.runInterface}`)
|
||||
@@ -549,7 +551,7 @@ export class ProjectEditSession implements FeatureProjectView {
|
||||
|
||||
private finalProfile(): ProjectProfile {
|
||||
const runInterface = this.states.get(featureId('app'))?.selection?.options[0]
|
||||
if (runInterface !== 'acp' && runInterface !== 'tui' && runInterface !== 'embed') return this.profile
|
||||
if (runInterface !== 'acp' && runInterface !== 'embed') return this.profile
|
||||
return { ...this.profile, runInterface }
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,11 @@ const OPTIONAL_DOCUMENTS = [
|
||||
] as const
|
||||
|
||||
function runInterface(entries: readonly CordisConfigEntry[]): RunInterface {
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui'
|
||||
|| entry.name.startsWith('@deepseek-ai/dsh-tui/'))) {
|
||||
throw new Error('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
|
||||
}
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')) return 'acp'
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui')) return 'tui'
|
||||
return 'embed'
|
||||
}
|
||||
|
||||
@@ -146,7 +149,7 @@ export class SdkProject {
|
||||
static create(root: string, request: ProjectCreationRequest): SdkProject {
|
||||
const app = request.features.find(selection => selection.id === 'app')
|
||||
const selectedInterface = app?.options[0]
|
||||
if (selectedInterface !== 'acp' && selectedInterface !== 'tui' && selectedInterface !== 'embed') {
|
||||
if (selectedInterface !== 'acp' && selectedInterface !== 'embed') {
|
||||
throw new Error('project creation requires one app feature option')
|
||||
}
|
||||
const profile: ProjectProfile = {
|
||||
@@ -178,6 +181,7 @@ export class SdkProject {
|
||||
* Load an existing project from required and SDK-managed optional files.
|
||||
* @param root - existing project directory.
|
||||
* @returns disk-backed project snapshot.
|
||||
* @throws When the config references the removed `@deepseek-ai/dsh-tui` root or a subpath.
|
||||
*/
|
||||
static async open(root: string): Promise<SdkProject> {
|
||||
const absolute = resolve(root)
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts'
|
||||
import type { FeatureId } from '../ids.ts'
|
||||
|
||||
/** Runtime front door selected for a generated project. */
|
||||
export type RunInterface = 'acp' | 'tui' | 'embed'
|
||||
export type RunInterface = 'acp' | 'embed'
|
||||
|
||||
/** Values shared by the required provider and app features. */
|
||||
interface ProjectRuntimeOptions {
|
||||
|
||||
@@ -9,16 +9,10 @@ Built with the DeepSeek Harness SDK using the {{model}} model.
|
||||
|
||||
Run `{{packageManager}} start` and configure a programmatic ACP client to launch this project. Standard output is reserved for ACP JSON-RPC.
|
||||
{{else}}
|
||||
{{#if isTui}}
|
||||
## Run in a terminal
|
||||
|
||||
Run `{{packageManager}} start` to start the interactive agent.
|
||||
{{else}}
|
||||
## Embed the harness
|
||||
|
||||
Import and call the exported `main()` from `index.ts` in your host application.
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -8,47 +8,13 @@ import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts'
|
||||
|
||||
/** Boot this project's cordis.yml when invoked by dsh-scripts. */
|
||||
export async function main(boot: SdkBootContext) {
|
||||
{{#if isTui}}
|
||||
const model = boot.args.model
|
||||
if (typeof model !== 'string' || model.length === 0) throw new Error('TUI startup requires --model=<name>')
|
||||
const resume = boot.args.resume
|
||||
if (resume !== undefined && (typeof resume !== 'string' || resume.length === 0)) {
|
||||
throw new Error('TUI startup requires --resume=<session-id>')
|
||||
}
|
||||
const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)
|
||||
process.env.DSH_SDK_SESSION_ID = sessionId
|
||||
{{/if}}
|
||||
const ctx = await startSDK(new URL('./cordis.yml', import.meta.url))
|
||||
{{#if isTui}}
|
||||
try {
|
||||
if (resume === undefined) {
|
||||
await ctx.agents.create({
|
||||
sessionId,
|
||||
meta: { cwd: boot.cwd },
|
||||
agentOptions: { model },
|
||||
})
|
||||
} else {
|
||||
await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model },
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await ctx.fiber.dispose()
|
||||
} catch (disposeError) {
|
||||
throw new AggregateError([error, disposeError], 'TUI startup and cleanup failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
{{else}}
|
||||
{{#if isEmbed}}
|
||||
await ctx.agents.create({
|
||||
sessionId: SessionId(`main-session-${randomUUID()}`),
|
||||
meta: { cwd: boot.cwd },
|
||||
agentOptions: { model: {{modelLiteral}} },
|
||||
})
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface ProjectTemplateContext {
|
||||
model: string
|
||||
modelLiteral: string
|
||||
isAcp: boolean
|
||||
isTui: boolean
|
||||
isEmbed: boolean
|
||||
packageManager: PackageManagerName
|
||||
installArgs: string
|
||||
@@ -60,7 +59,6 @@ export function createProjectTemplateContext(
|
||||
model: profile.runtime.model,
|
||||
modelLiteral: JSON.stringify(profile.runtime.model),
|
||||
isAcp: runInterface === 'acp',
|
||||
isTui: runInterface === 'tui',
|
||||
isEmbed: runInterface === 'embed',
|
||||
packageManager: profile.packageManager.name,
|
||||
installArgs: profile.packageManager.installCommand().join(' '),
|
||||
@@ -104,10 +102,9 @@ export function createAppProjectArtifacts(
|
||||
}
|
||||
|
||||
/** Build package scripts owned by the selected app feature option. */
|
||||
export function createAppPackageScripts(context: ProjectTemplateContext): Readonly<Record<'dev' | 'start', string>> {
|
||||
const modelArg = context.isTui ? ` -- --model=${JSON.stringify(context.model)}` : ''
|
||||
export function createAppPackageScripts(): Readonly<Record<'dev' | 'start', string>> {
|
||||
return {
|
||||
dev: `dsh-sdk dev index.ts${modelArg}`,
|
||||
start: `dsh-sdk start index.js${modelArg}`,
|
||||
dev: 'dsh-sdk dev index.ts',
|
||||
start: 'dsh-sdk start index.js',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ overrides:
|
||||
expect(() => loadHelperTemplate('../bad.tpl')).toThrow('must not contain a directory')
|
||||
expect(createBaselineProjectArtifacts({
|
||||
name: 'demo', description: 'demo', releaseVersion: '0.0.1', model: 'model', modelLiteral: '"model"', packageManager: 'yarn',
|
||||
isAcp: false, isTui: false, isEmbed: true,
|
||||
isAcp: false, isEmbed: true,
|
||||
installArgs: 'install', buildArgs: 'build',
|
||||
}).map(document => document.relativePath)).toContain('.yarnrc.yml')
|
||||
expect(() => new LocalPluginBlueprint('---', 'plugin')).toThrow('invalid local plugin name')
|
||||
@@ -309,7 +309,12 @@ describe('package manager strategies', () => {
|
||||
await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2')
|
||||
const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) }
|
||||
await expect(npm.build('/tmp', killed)).rejects.toThrow('killed by SIGTERM')
|
||||
expect(scrubEnvironment({ PATH: '/bin', API_KEY: 'secret', TOKEN_VALUE: 'secret' })).toEqual({ PATH: '/bin' })
|
||||
expect(scrubEnvironment({
|
||||
PATH: '/bin',
|
||||
API_KEY: 'secret',
|
||||
DB_PASSWORD: 'secret',
|
||||
TOKEN_VALUE: 'secret',
|
||||
})).toEqual({ PATH: '/bin' })
|
||||
})
|
||||
|
||||
it('probes versions and runs real child-process boundaries', async () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ function selection(id: string, options: readonly string[], secrets?: Record<stri
|
||||
function request(
|
||||
extra: readonly FeatureSelection[] = [],
|
||||
plugins: readonly LocalPluginBlueprint[] = [],
|
||||
app: 'acp' | 'tui' | 'embed' = 'tui',
|
||||
app: 'acp' | 'embed' = 'embed',
|
||||
bash: 'local' | 'sandbox' = 'local',
|
||||
): ProjectCreationRequest {
|
||||
return {
|
||||
@@ -61,7 +61,7 @@ function request(
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
features: [
|
||||
selection('provider', ['deepseek'], { apiKey: 'test-key' }),
|
||||
selection('provider', ['deepseek-official'], { apiKey: 'test-key' }),
|
||||
selection('bash', [bash]),
|
||||
selection('app', [app]),
|
||||
selection('persistence', ['jsonl']),
|
||||
@@ -115,16 +115,23 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(acp.readEnvironment('.env', 'KEY')).toBe('value')
|
||||
expect(() => acp.readEnvironment('.env.example', 'KEY')).not.toThrow()
|
||||
expect(acp.document('tsconfig.json')).toBeInstanceOf(TextProjectFile)
|
||||
const tui = await make('dsh-open-tui', {}, `- id: provider
|
||||
await expect(make('dsh-open-tui', {}, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [provider-model] }
|
||||
- id: tui
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
`)).rejects.toThrow('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
|
||||
await expect(make('dsh-open-tui-subpath', {}, `- id: tui-prompt
|
||||
name: '@deepseek-ai/dsh-tui/prompt'
|
||||
`)).rejects.toThrow('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
|
||||
const embedded = await make('dsh-open-embed', {}, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [provider-model] }
|
||||
`, { 'yarn.lock': '' })
|
||||
expect(tui.profile.runInterface).toBe('tui')
|
||||
expect(tui.profile.runtime.model).toBe('provider-model')
|
||||
expect(tui.profile.packageManager.name).toBe('yarn')
|
||||
expect(tui.profile.name).toBe(tui.root.split('/').at(-1))
|
||||
expect(embedded.profile.runInterface).toBe('embed')
|
||||
expect(embedded.profile.runtime.model).toBe('provider-model')
|
||||
expect(embedded.profile.packageManager.name).toBe('yarn')
|
||||
expect(embedded.profile.name).toBe(embedded.root.split('/').at(-1))
|
||||
const pnpm = await make('dsh-open-pnpm', { name: 'pnpm' }, '[]\n', { 'pnpm-lock.yaml': '' })
|
||||
expect(pnpm.profile.packageManager.name).toBe('pnpm')
|
||||
const defaults = await make('dsh-open-default', { name: 'default', packageManager: 'npm@10.0.0' }, '[]\n')
|
||||
@@ -134,10 +141,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(() => SdkProject.create(defaults.root, { ...request(), features: [] })).toThrow('requires one app')
|
||||
await expect(make('dsh-open-invalid-manager', { name: 'bad', packageManager: 'bad' }, '[]\n'))
|
||||
.rejects.toThrow('invalid packageManager field')
|
||||
const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: tui
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
config: { model: '' }
|
||||
- id: provider
|
||||
const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [fallback-model] }
|
||||
`)
|
||||
@@ -166,28 +170,17 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
const index = await readFile(join(project.root, 'index.ts'), 'utf8')
|
||||
expect(index).toContain('SdkBootContext')
|
||||
expect(index).toContain('agents.create')
|
||||
expect(index).toContain('boot.args.resume')
|
||||
expect(index).not.toContain('boot.args.resume')
|
||||
expect(index).not.toContain('AgentId')
|
||||
expect(index).toContain('const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)')
|
||||
expect(index).toContain('process.env.DSH_SDK_SESSION_ID = sessionId')
|
||||
expect(index).toContain('resumeSessionId: sessionId')
|
||||
expect(index).toContain('await ctx.fiber.dispose()')
|
||||
expect(index).toContain("new AggregateError([error, disposeError], 'TUI startup and cleanup failed')")
|
||||
expect(index).toContain('SessionId(`main-session-${randomUUID()}`)')
|
||||
expect(project.packageManifest().scripts).toEqual({
|
||||
dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"',
|
||||
dev: 'dsh-sdk dev index.ts',
|
||||
build: 'dsh-sdk build',
|
||||
typecheck: 'tsc -b',
|
||||
start: 'dsh-sdk start index.js -- --model="deepseek-v4-flash"',
|
||||
start: 'dsh-sdk start index.js',
|
||||
config: 'dsh-sdk config',
|
||||
})
|
||||
expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=')
|
||||
expect(project.cordis.entry('tui')?.config?.sessionId).toMatchObject({
|
||||
source: 'process.env.DSH_SDK_SESSION_ID',
|
||||
})
|
||||
expect(await readFile(join(project.root, 'cordis.yml'), 'utf8'))
|
||||
.toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID')
|
||||
expect(project.cordis.entry('tui')?.config).not.toHaveProperty('model')
|
||||
expect(project.cordis.entry('tui-prompt')?.name).toBe('@deepseek-ai/dsh-tui/prompt')
|
||||
expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
|
||||
expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant')
|
||||
expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant')
|
||||
@@ -204,6 +197,17 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models')
|
||||
})
|
||||
|
||||
it.each(['spawn', 'fork'] as const)('mounts Task controls for %s subagents', async (option) => {
|
||||
const project = await createCommitted([selection('subagent', [option])])
|
||||
expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks-local')
|
||||
expect(project.cordis.entry('tool-tasks')?.name).toBe('@deepseek-ai/dsh-tool-tasks')
|
||||
expect(project.packageManifest().dependencies).toMatchObject({
|
||||
'@deepseek-ai/dsh-tasks-local': '^0.0.1',
|
||||
'@deepseek-ai/dsh-tool-tasks': '^0.0.1',
|
||||
})
|
||||
expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks')
|
||||
})
|
||||
|
||||
it('round-trips embed app projects without a front-door Cordis config entry', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-'))
|
||||
temporary.push(root)
|
||||
@@ -218,13 +222,12 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(app.selection).toEqual(selection('app', ['embed']))
|
||||
expect(committed.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
|
||||
expect(committed.cordis.entry('acp')).toBeUndefined()
|
||||
expect(committed.cordis.entry('tui')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits the sandbox workspace-write example as inactive Cordis config', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-sandbox-bash-'))
|
||||
temporary.push(root)
|
||||
const creation = request([], [], 'tui', 'sandbox')
|
||||
const creation = request([], [], 'embed', 'sandbox')
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
@@ -315,7 +318,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
const modifiedRegistry = createBuiltinRegistry(modified.profile)
|
||||
expect(() => { modified.edit(modifiedRegistry).configureFeature(
|
||||
modifiedRegistry.get(featureId('app')),
|
||||
selection('app', ['tui']),
|
||||
selection('app', ['acp']),
|
||||
) }).toThrow('feature-owned file was modified: README.md')
|
||||
|
||||
const manifest = PackageJsonFile.parse(await readFile(join(embed.root, 'package.json'), 'utf8'))
|
||||
@@ -326,30 +329,18 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
.toContain('missing package.json script dev')
|
||||
})
|
||||
|
||||
it('rejects ask-user on non-interactive app interfaces', async () => {
|
||||
const project = await createCommitted([selection('ask-user', ['default'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const embed = project.edit(registry)
|
||||
embed.configureFeature(registry.get(featureId('app')), selection('app', ['embed']))
|
||||
await expect(embed.commit()).rejects.toThrow('feature ask-user is not available for embed')
|
||||
|
||||
const acp = project.edit(registry)
|
||||
acp.configureFeature(registry.get(featureId('app')), selection('app', ['acp']))
|
||||
await expect(acp.commit()).rejects.toThrow('feature ask-user is not available for acp')
|
||||
})
|
||||
|
||||
it('supports disabled feature reconfiguration and rejects invalid state operations', async () => {
|
||||
const project = await createCommitted([selection('todo', ['default'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
expect(edit.inspections()).not.toHaveLength(0)
|
||||
const todo = registry.get(featureId('todo'))
|
||||
edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek']))
|
||||
edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek-official']))
|
||||
edit.disableFeature(todo)
|
||||
edit.configureFeature(todo, selection('todo', ['default']))
|
||||
edit.enableFeature(todo)
|
||||
expect(() => { edit.enableFeature(registry.get(featureId('ask-user'))) }).toThrow('not installed')
|
||||
expect(() => { edit.disableFeature(registry.get(featureId('ask-user'))) }).toThrow('not installed')
|
||||
expect(() => { edit.enableFeature(registry.get(featureId('workflow'))) }).toThrow('not installed')
|
||||
expect(() => { edit.disableFeature(registry.get(featureId('workflow'))) }).toThrow('not installed')
|
||||
expect(() => { edit.setCustomPluginDisabled('missing', true) }).toThrow('does not exist')
|
||||
const committed = await edit.commit()
|
||||
expect(committed.changes.enabledFeatures).toContain('todo')
|
||||
@@ -362,7 +353,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
const edit = project.edit(registry)
|
||||
edit.setCustomPluginDisabled('sample', true)
|
||||
expect(edit.cordisConfigEntries().find(entry => entry.id === 'sample')?.disabled).toBe(true)
|
||||
expect(() => { edit.setCustomPluginDisabled('tui', true) }).toThrow('builtin feature')
|
||||
expect(() => { edit.setCustomPluginDisabled('agent-loop', true) }).toThrow('builtin feature')
|
||||
const next = (await edit.commit()).project
|
||||
const enable = next.edit(createBuiltinRegistry(next.profile))
|
||||
enable.setCustomPluginDisabled('sample', false)
|
||||
@@ -457,8 +448,8 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
}
|
||||
const internals = edit as unknown as Internals
|
||||
const collidingEntry: ProjectResource = {
|
||||
kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:tui'),
|
||||
entry: { id: 'tui', name: 'other-package' }, ownedConfigKeys: [],
|
||||
kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:agent-loop'),
|
||||
entry: { id: 'agent-loop', name: 'other-package' }, ownedConfigKeys: [],
|
||||
}
|
||||
expect(() => { internals.applyResource(collidingEntry, undefined) }).toThrow('is owned by')
|
||||
const existingFile: ProjectResource = {
|
||||
@@ -598,7 +589,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(installation.diagnostics).toContain('missing package.json dependencies entry @deepseek-ai/dsh-llm-deepseek')
|
||||
const partialEdit = partial.edit(createBuiltinRegistry(partial.profile))
|
||||
const provider = createBuiltinRegistry(partial.profile).get(featureId('provider'))
|
||||
expect(() => { partialEdit.configureFeature(provider, selection('provider', ['deepseek'])) }).toThrow('inconsistent')
|
||||
expect(() => { partialEdit.configureFeature(provider, selection('provider', ['deepseek-official'])) }).toThrow('inconsistent')
|
||||
expect(() => { partialEdit.enableFeature(provider) }).toThrow('inconsistent')
|
||||
expect(() => { partialEdit.disableFeature(provider) }).toThrow('required feature')
|
||||
})
|
||||
@@ -615,7 +606,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
const edit = project.edit(builtin)
|
||||
const web = builtin.get(featureId('web'))
|
||||
expect(() => { edit.disableFeature(web) }).toThrow('inconsistent')
|
||||
expect(() => { edit.installFeature(web, selection('web', ['deepseek'])) }).toThrow('inconsistent')
|
||||
expect(() => { edit.installFeature(web, selection('web', ['deepseek-official'])) }).toThrow('inconsistent')
|
||||
|
||||
class RequiresWeb extends FixedFeature {
|
||||
override readonly id = featureId('requires-web')
|
||||
@@ -656,7 +647,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
const project = await createCommitted([selection('web', ['exa'], { apiKey: 'exa' })])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek']))
|
||||
edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek-official']))
|
||||
expect(edit.readEnvironment('.env.example', 'EXA_API_KEY')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -676,7 +667,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
const edit = reopened.edit(registry)
|
||||
edit.configureFeature(
|
||||
registry.get(featureId('provider')),
|
||||
selection('provider', ['deepseek'], { apiKey: 'replacement' }),
|
||||
selection('provider', ['deepseek-official'], { apiKey: 'replacement' }),
|
||||
)
|
||||
edit.installFeature(registry.get(featureId('web')), selection('web', ['exa'], { apiKey: 'exa-key' }))
|
||||
const withExa = (await edit.commit()).project
|
||||
@@ -686,7 +677,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
}
|
||||
const nextRegistry = createBuiltinRegistry(withExa.profile)
|
||||
const remove = withExa.edit(nextRegistry)
|
||||
remove.configureFeature(nextRegistry.get(featureId('web')), selection('web', ['deepseek']))
|
||||
remove.configureFeature(nextRegistry.get(featureId('web')), selection('web', ['deepseek-official']))
|
||||
await remove.commit()
|
||||
expect(await readFile(join(withExa.root, '.env'), 'utf8')).toBe(`${original}EXA_API_KEY=exa-key\n`)
|
||||
})
|
||||
@@ -810,7 +801,7 @@ describe('extension points', () => {
|
||||
})
|
||||
expect(exclusive.defaultOptions(profile)).toEqual(['one'])
|
||||
expect(exclusive.isApplicable(profile)).toBe(true)
|
||||
expect(exclusive.isApplicable({ ...profile, runInterface: 'tui' })).toBe(false)
|
||||
expect(exclusive.isApplicable({ ...profile, runInterface: 'acp' })).toBe(false)
|
||||
expect(exclusive.requirements(selection('defined', ['one']))).toEqual([
|
||||
{ id: 'base' }, { id: 'option', options: ['required'] },
|
||||
])
|
||||
@@ -824,7 +815,7 @@ describe('extension points', () => {
|
||||
expect(entry?.validateConfig?.({ nested: { value: 2 }, list: ['a', 'b'], nullable: null })).toEqual([])
|
||||
expect(entry?.validateConfig?.({ nested: [], list: 'bad' })).toHaveLength(3)
|
||||
expect(() => exclusive.normalizeSelection(selection('other', ['one']), profile)).toThrow('does not belong')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'tui' }))
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'acp' }))
|
||||
.toThrow('not available')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['missing']), profile)).toThrow('unknown')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one', 'two']), profile)).toThrow('exactly one')
|
||||
@@ -832,7 +823,7 @@ describe('extension points', () => {
|
||||
id: 'fixed', summary: 'Fixed', mode: 'single', options: [option],
|
||||
}])).toHaveLength(2)
|
||||
expect(() => new FeatureRegistry([], profile).get(featureId('missing'))).toThrow('unknown feature')
|
||||
expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'tui' }))
|
||||
expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'acp' }))
|
||||
.toBeUndefined()
|
||||
class Unsupported extends FixedFeature {
|
||||
override readonly id = featureId('unsupported')
|
||||
@@ -915,12 +906,6 @@ describe('extension points', () => {
|
||||
resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp')
|
||||
expect(acpEntry?.entry.id).toBe('acp')
|
||||
expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1)
|
||||
const tuiEntry = builtins.get(featureId('app')).contribution(selection('app', ['tui']), profile).resources
|
||||
.find((resource): resource is CordisConfigEntryResource =>
|
||||
resource.kind === 'cordis-config-entry' && resource.entry.id === 'tui')
|
||||
expect(tuiEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([
|
||||
'sessionId must be a non-empty string',
|
||||
])
|
||||
const embedOption = app.options.find(option => option.id === 'embed')
|
||||
expect(embedOption?.markerConfigEntries(profile)).toEqual([])
|
||||
expect(embedOption?.contribution(profile, {}).resources.map(resource => resource.kind)).toEqual([
|
||||
@@ -928,7 +913,7 @@ describe('extension points', () => {
|
||||
])
|
||||
expect(embedOption?.matchesConfigEntries([
|
||||
{ id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' },
|
||||
{ id: 'tui', name: '@deepseek-ai/dsh-tui' },
|
||||
{ id: 'acp', name: '@deepseek-ai/dsh-acp' },
|
||||
], profile)).toBe(false)
|
||||
const spineAgentLoop = builtins.get(featureId('spine')).contribution(selection('spine', ['default']), profile).resources
|
||||
.find((resource): resource is CordisConfigEntryResource =>
|
||||
@@ -936,12 +921,12 @@ describe('extension points', () => {
|
||||
expect(spineAgentLoop?.validateConfig?.({ agents: 'main' })).toEqual(['agents must be an array'])
|
||||
expect(spineAgentLoop?.validateConfig?.({ agents: ['main'] })).toEqual(['agents must be empty'])
|
||||
expect(spineAgentLoop?.validateConfig?.({ agents: [] })).toEqual([])
|
||||
expect(builtins.get(featureId('provider')).defaultOptions(profile)).toEqual(['deepseek'])
|
||||
expect(builtins.get(featureId('provider')).defaultOptions(profile)).toEqual(['deepseek-official'])
|
||||
expect(() => builtins.get(featureId('provider')).contribution({
|
||||
id: featureId('provider'), options: ['custom'], values: { baseURL: 1 },
|
||||
}, profile)).toThrow('baseURL must be a string')
|
||||
const alternateModel = builtins.get(featureId('provider')).contribution({
|
||||
id: featureId('provider'), options: ['deepseek'],
|
||||
id: featureId('provider'), options: ['deepseek-official'],
|
||||
}, { ...profile, runtime: { model: 'other' } }).resources
|
||||
.find(resource => resource.kind === 'cordis-config-entry')
|
||||
expect(alternateModel?.entry.config?.models).toEqual(['other'])
|
||||
|
||||
@@ -376,14 +376,14 @@ describe('feature configurator', () => {
|
||||
name: 'demo',
|
||||
description: 'demo',
|
||||
runtime: { model: 'deepseek-v4-flash' },
|
||||
runInterface: 'tui',
|
||||
runInterface: 'embed',
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
}
|
||||
|
||||
it('shares exclusive, multiple, fixed, and secret behavior', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
const port = new QueuePromptPort(['sqlite', ['spawn', 'fork'], 'deepseek', 'new-key'])
|
||||
const port = new QueuePromptPort(['sqlite', ['spawn', 'fork'], 'deepseek-official', 'new-key'])
|
||||
const configurator = new FeatureConfigurator(port)
|
||||
await expect(configurator.configure(registry.get(featureId('persistence')), profile)).resolves.toMatchObject({
|
||||
options: ['sqlite'],
|
||||
@@ -394,7 +394,7 @@ describe('feature configurator', () => {
|
||||
await expect(configurator.configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'old-key' } },
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'old-key' } },
|
||||
)).resolves.toMatchObject({ secrets: { apiKey: 'new-key' } })
|
||||
expect(port.requests).toEqual([
|
||||
'Choose durable session storage',
|
||||
@@ -412,13 +412,13 @@ describe('feature configurator', () => {
|
||||
})
|
||||
const requiredSecret = new FeatureConfigurator(new QueuePromptPort([]))
|
||||
await expect(requiredSecret.configure(
|
||||
registry.get(featureId('provider')), profile, undefined, ['deepseek'], { apiKey: '' },
|
||||
registry.get(featureId('provider')), profile, undefined, ['deepseek-official'], { apiKey: '' },
|
||||
)).rejects.toThrow('required')
|
||||
const keep = new FeatureConfigurator(new QueuePromptPort(['deepseek', '']))
|
||||
const keep = new FeatureConfigurator(new QueuePromptPort(['deepseek-official', '']))
|
||||
await expect(keep.configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'old' } },
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'old' } },
|
||||
)).resolves.toMatchObject({ secrets: { apiKey: 'old' } })
|
||||
const custom = registry.get(featureId('provider'))
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort(['custom'])).configure(
|
||||
|
||||
@@ -30,16 +30,14 @@
|
||||
"lib/dev/tsdown-config.js",
|
||||
"lib/local-plugin-loader-hooks.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-helper": "workspace:^",
|
||||
"@deepseek-ai/dsh-telemetry": "workspace:^",
|
||||
"commander": "^15.0.0",
|
||||
"node-addon-require-builtin": "^0.1.0"
|
||||
"node-addon-require-builtin": "^0.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
|
||||
@@ -56,7 +56,7 @@ function targetRunInterface(
|
||||
desired: ReadonlyMap<string, NestedMultiSelectValue<string, string>>,
|
||||
): RunInterface {
|
||||
const selected = desired.get('feature:app')?.choices[0]
|
||||
return selected === 'acp' || selected === 'tui' || selected === 'embed' ? selected : current
|
||||
return selected === 'acp' || selected === 'embed' ? selected : current
|
||||
}
|
||||
|
||||
/** Reconcile one tree selection into domain commands, then review and commit once. */
|
||||
@@ -135,6 +135,7 @@ export class ConfigWorkflow {
|
||||
runInterface: targetRunInterface(project.profile.runInterface, desiredByTarget),
|
||||
}
|
||||
for (const feature of features) {
|
||||
/* v8 ignore next -- no current built-in feature is interface-specific */
|
||||
if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature))
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Change file: package.json
|
||||
{
|
||||
"default": true,
|
||||
"label": "DeepSeek",
|
||||
"value": "deepseek",
|
||||
"value": "deepseek-official",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
@@ -84,15 +84,10 @@ Change file: package.json
|
||||
"choiceMode": "exclusive",
|
||||
"choices": [
|
||||
{
|
||||
"default": false,
|
||||
"default": true,
|
||||
"label": "ACP automation server",
|
||||
"value": "acp",
|
||||
},
|
||||
{
|
||||
"default": true,
|
||||
"label": "Terminal TUI",
|
||||
"value": "tui",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "Embedded context",
|
||||
@@ -173,7 +168,7 @@ Change file: package.json
|
||||
{
|
||||
"default": true,
|
||||
"label": "DeepSeek search",
|
||||
"value": "deepseek",
|
||||
"value": "deepseek-official",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
@@ -280,16 +275,6 @@ Change file: package.json
|
||||
"value": "feature:timeout-policy",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Ask the user from the model loop",
|
||||
"required": false,
|
||||
"value": "feature:ask-user",
|
||||
"warning": undefined,
|
||||
},
|
||||
],
|
||||
"showChanges": true,
|
||||
},
|
||||
|
||||
@@ -92,9 +92,9 @@ async function baseProject(): Promise<SdkProject> {
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
features: [
|
||||
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } },
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'key' } },
|
||||
{ id: featureId('bash'), options: ['local'] },
|
||||
{ id: featureId('app'), options: ['tui'] },
|
||||
{ id: featureId('app'), options: ['acp'] },
|
||||
{ id: featureId('persistence'), options: ['jsonl'] },
|
||||
],
|
||||
localPlugins: [],
|
||||
|
||||
@@ -85,7 +85,7 @@ function commandContext(cwd: string): DshSdkCommandContext & { readStdout: () =>
|
||||
function creation(
|
||||
extra: ProjectCreationRequest['features'] = [],
|
||||
localPlugins: readonly LocalPluginBlueprint[] = [],
|
||||
app: 'acp' | 'tui' | 'embed' = 'embed',
|
||||
app: 'acp' | 'embed' = 'embed',
|
||||
): ProjectCreationRequest {
|
||||
return {
|
||||
name: 'config-agent',
|
||||
@@ -94,7 +94,7 @@ function creation(
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
features: [
|
||||
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } },
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'key' } },
|
||||
{ id: featureId('bash'), options: ['local'] },
|
||||
{ id: featureId('app'), options: [app] },
|
||||
{ id: featureId('persistence'), options: ['jsonl'] },
|
||||
@@ -107,7 +107,7 @@ function creation(
|
||||
async function committedProject(
|
||||
extra: ProjectCreationRequest['features'] = [],
|
||||
localPlugins: readonly LocalPluginBlueprint[] = [],
|
||||
app: 'acp' | 'tui' | 'embed' = 'embed',
|
||||
app: 'acp' | 'embed' = 'embed',
|
||||
): Promise<SdkProject> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-config-workflow-'))
|
||||
temporary.push(root)
|
||||
@@ -526,7 +526,7 @@ describe('ConfigWorkflow', () => {
|
||||
const workflow = new ConfigWorkflow(new QueuePort([
|
||||
[
|
||||
{ value: 'feature:provider', choices: ['custom'] },
|
||||
{ value: 'feature:app', choices: ['tui'] },
|
||||
{ value: 'feature:app', choices: ['acp'] },
|
||||
{ value: 'feature:persistence', choices: ['jsonl'] },
|
||||
],
|
||||
'https://provider.example/v1',
|
||||
@@ -537,31 +537,11 @@ describe('ConfigWorkflow', () => {
|
||||
const provider = result.commit?.project.cordis.entry('llm-pi-ai')
|
||||
expect(provider?.config?.apiKey).toBeDefined()
|
||||
expect(provider?.config?.baseURL).toBe('https://provider.example/v1')
|
||||
expect(result.commit?.project.cordis.entry('tui')).toBeDefined()
|
||||
expect(result.commit?.project.cordis.entry('acp')).toBeDefined()
|
||||
expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined()
|
||||
expect(result.commit?.project.cordis.entry('agent-core')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables ask-user when switching its app interface to ACP', async () => {
|
||||
const project = await committedProject([
|
||||
{ id: featureId('ask-user'), options: ['default'] },
|
||||
], [], 'tui')
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const output = outputBuffer()
|
||||
const workflow = new ConfigWorkflow(new QueuePort([
|
||||
[
|
||||
{ value: 'feature:provider', choices: ['deepseek'] },
|
||||
{ value: 'feature:app', choices: ['acp'] },
|
||||
{ value: 'feature:persistence', choices: ['jsonl'] },
|
||||
{ value: 'feature:ask-user', choices: ['default'] },
|
||||
],
|
||||
true,
|
||||
]), output.stream, async () => {})
|
||||
const result = await workflow.run(project, registry)
|
||||
expect(result.commit?.project.profile.runInterface).toBe('acp')
|
||||
expect(result.commit?.project.cordis.entry('tool-ask-user')?.disabled).toBe(true)
|
||||
expect(output.read()).toContain('Disable feature: ask-user')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh-sdk create', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/sdk/sdk-client/README.md
|
||||
README.md: 3ac4de540401f6f40dab3e84f7005f91d024aee8
|
||||
README.zh.md: 95fb6b2887a7bc748a610f7fedae4be1aa2af623
|
||||
README.md: f27ec256330254156c45b136c21308529ea99e3d
|
||||
README.zh.md: bd23762500a4da469839da8ddb50455e3419b553
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
|
||||
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level owned-run API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
|
||||
|
||||
Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend, tests, automation — which know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern.
|
||||
Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — including the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend and automation — that know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern.
|
||||
|
||||
## DeepSeekHarness
|
||||
|
||||
@@ -13,28 +13,26 @@ import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
|
||||
|
||||
await using harness = new DeepSeekHarness({
|
||||
launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] },
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
maxTokens: 49_152,
|
||||
})
|
||||
const result = await harness.run('say hi')
|
||||
console.log(result.status, result.finalResponse)
|
||||
console.log(result.finalResponse)
|
||||
```
|
||||
|
||||
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route and optional positive `maxTokens` output cap); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), root-session `events`, and raw `notifications` for that session plus descendants discovered from `subagent.started`, all in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation.
|
||||
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route and optional positive `maxTokens` output cap); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle.
|
||||
|
||||
`run(input, { sessionId?, onNotification? })` owns one activity interval: it queues the prompt, waits until its `MessageId` appears in a durable `agent/inbox/spliced` receipt, then collects through the next whole-agent `idle`. It returns `RunResult { sessionId, finalResponse, events, notifications }`. `finalResponse` is the last committed root-session assistant text in that interval, not a response causally assigned to the prompt; steering, injected context, and other queued work may contribute before idle. `events` contains root-session events, while `notifications` also contains descendants discovered from `subagent.started`, all in wire order. The result carries no prompt-level status or turn reason. Transport loss, timeout, and protocol violations reject; model outcomes remain observable in the event stream without being attributed to one input.
|
||||
|
||||
## HarnessClient
|
||||
|
||||
The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
|
||||
The protocol client under the owned-run API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `prompt()` returns the queued message id as soon as the runtime accepts it; it never waits for agent activity. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
|
||||
|
||||
`close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. The ladder is private to this client: it runs outside any harness context, so it cannot ride the [`dsh-subprocess`](../../subprocess/README.md) service — the seam's documented exception for SDK-managed transports. It is idempotent, and a closed client refuses reuse.
|
||||
|
||||
`HarnessClientOptions.env` replaces the child environment entirely when given (`undefined` inherits the parent's); callers own credential policy — `scrubbedParentEnv` from `dsh-subprocess` is the shared scrub base for isolation-minded launches.
|
||||
|
||||
## Testing
|
||||
|
||||
Keyless unit tests drive a scripted fake runtime subprocess (`tests/fake-runtime.ts`, protocol-only, env-scripted) over real stdio: turn loop, session-tree scoping, timeout/death/malformed-response surfaces, and the dispose ladder. The [SDK snapshot suite](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) drives the real `dsh-jsonrpc-agent` runtime through this client keylessly via `llm-replay`, pinning the notification stream, the turn result, and the persisted logs; `DSH_SNAPSHOT=record` re-records against the live API.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this is a client-process library; the model runs in the spawned runtime, whose experience is owned by the plugins its `cordis.yml` composes.
|
||||
@@ -47,5 +45,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **No bundled-runtime resolution** — callers name the runtime executable explicitly; packaged-executable discovery stays Python-side until a TypeScript distribution consumer exists.
|
||||
- **No mid-turn cancel** — the wire has no prompt-cancel method; abandoning a turn means closing the runtime (see the protocol's [Known Limitations](../sdk-protocol/README.md)).
|
||||
- **One in-flight prompt per session** — a server-side rule this client surfaces as a `JsonRpcResponseError`; independent sessions run concurrently on one runtime.
|
||||
- **No per-prompt result or cancel** — low-level `prompt()` returns only an enqueue receipt; high-level `run()` owns receipt-to-idle collection, and abandoning it means closing the runtime.
|
||||
- **Client→server notifications and server→client requests are unimplemented** on both wire ends; the transport carries them for future approval flows.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层轮次 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
|
||||
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层自有运行 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
|
||||
|
||||
与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方——[`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端、测试、自动化——它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。
|
||||
与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方,包括 [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端和自动化;它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。
|
||||
|
||||
## DeepSeekHarness
|
||||
|
||||
@@ -13,28 +13,26 @@ import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
|
||||
|
||||
await using harness = new DeepSeekHarness({
|
||||
launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] },
|
||||
provider: 'deepseek',
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
maxTokens: 49_152,
|
||||
})
|
||||
const result = await harness.run('say hi')
|
||||
console.log(result.status, result.finalResponse)
|
||||
console.log(result.finalResponse)
|
||||
```
|
||||
|
||||
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被回收。`start()` 记忆化 `initialize` 握手(工作区 cwd——在通过协议传输之前解析为绝对路径——加 provider/model 路由和可选的正整数 `maxTokens` 输出上限);握手失败会回收运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个提示词轮次,在配对的 `session.finished` 到达时完成,并返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本)、根会话的 `events`,以及该会话和通过 `subagent.started` 发现的后代的原始 `notifications`,均按协议传输顺序排列。模型层失败会返回 `status: 'error'` 的结果,绝不会导致 Promise 被拒绝;Promise 被拒绝意味着传输丢失、超时或协议违例。
|
||||
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被回收。`start()` 记忆化 `initialize` 握手(工作区 cwd——在通过协议传输之前解析为绝对路径——加 provider/model 路由和可选的正整数 `maxTokens` 输出上限);握手失败会回收运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄。
|
||||
|
||||
`run(input, { sessionId?, onNotification? })` 拥有一个活动区间:它将提示词排入队列,等待其 `MessageId` 出现在持久的 `agent/inbox/spliced` 回执中,然后持续收集到整个 agent 下一次进入 `idle`。它返回 `RunResult { sessionId, finalResponse, events, notifications }`。`finalResponse` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的响应;steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中。`events` 包含根会话事件,`notifications` 还包含通过 `subagent.started` 发现的后代,均按协议传输顺序排列。结果不携带提示词级状态或轮次原因。传输丢失、超时和协议违例会导致 Promise 被拒绝;模型结果仍可在事件流中观察,但不会归属于某一输入。
|
||||
|
||||
## HarnessClient
|
||||
|
||||
轮次 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;协议层没有取消机制,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
|
||||
自有运行 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`prompt()` 在运行时接受排队消息后立即返回该消息的 ID,绝不等待 agent 活动。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
|
||||
|
||||
`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该 seam 所记录的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。
|
||||
|
||||
`HarnessClientOptions.env` 给定时整体替换子进程环境(`undefined` 原样继承父进程环境);凭据策略归调用方——`dsh-subprocess` 的 `scrubbedParentEnv` 是面向隔离启动的共享擦除基底。
|
||||
|
||||
## 测试
|
||||
|
||||
免密钥单元测试通过真实 stdio 驱动一个脚本化伪运行时子进程(`tests/fake-runtime.ts`,纯协议、环境变量脚本化):轮次循环、会话树范围限定、超时、进程死亡和响应畸形场景,以及 dispose(资源释放)阶梯。[SDK 快照套件](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) 经由 `llm-replay` 免密钥地通过本客户端驱动真实 `dsh-jsonrpc-agent` 运行时,固定通知流、轮次结果与持久化日志;`DSH_SNAPSHOT=record` 对真实 API 重录。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为这是一个客户端进程库;模型运行在 spawn 出的运行时中,其体验由该运行时的 `cordis.yml` 所组合的插件决定。
|
||||
@@ -47,5 +45,5 @@ console.log(result.status, result.finalResponse)
|
||||
|
||||
- **无捆绑运行时解析**——调用方显式指定运行时可执行文件;打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费方。
|
||||
- **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见协议的 [已知限制](../sdk-protocol/README.md))。
|
||||
- **每会话同时只有一个在途提示词**——服务端规则,本客户端将其呈现为 `JsonRpcResponseError`;相互独立的会话可在同一运行时上并发。
|
||||
- **没有逐提示词结果或取消**——低层 `prompt()` 只返回入队回执;高层 `run()` 负责从回执收集到 idle,放弃该过程意味着关闭运行时。
|
||||
- **客户端→服务端通知与服务端→客户端请求**在协议两端都未实现;传输层为未来审批流保留了承载能力。
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* High-level turns API over {@link HarnessClient}: `DeepSeekHarness` owns one
|
||||
* High-level run API over {@link HarnessClient}: `DeepSeekHarness` owns one
|
||||
* runtime subprocess across many sessions; `HarnessSession.run` sends a
|
||||
* prompt and settles with the final response once `session.finished` arrives.
|
||||
* prompt and settles when the whole agent next becomes idle.
|
||||
* Mirrors the Python SDK's `DeepSeekHarness`/`Session` pair.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-client/api
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { resolve } from 'node:path'
|
||||
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { HarnessClient, isRecord, SdkProtocolError } from './client.ts'
|
||||
import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, TurnResult } from './types.ts'
|
||||
import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, RunResult } from './types.ts'
|
||||
|
||||
/**
|
||||
* Reusable SDK for running DeepSeek Harness agent turns in a runtime
|
||||
@@ -37,7 +37,7 @@ export class DeepSeekHarness implements AsyncDisposable {
|
||||
// process's cwd, but the wire cwd is resolved again inside the child — a
|
||||
// relative value would double-resolve (e.g. `worker` → `worker/worker`).
|
||||
this.cwd = resolve(options.cwd ?? options.launch.cwd ?? process.cwd())
|
||||
this.provider = options.provider ?? 'deepseek'
|
||||
this.provider = options.provider ?? 'deepseek-official'
|
||||
this.model = options.model ?? 'deepseek-v4-flash'
|
||||
this.maxTokens = options.maxTokens
|
||||
}
|
||||
@@ -93,9 +93,9 @@ export class DeepSeekHarness implements AsyncDisposable {
|
||||
* Run one prompt on a fresh (or named) session.
|
||||
* @param input - prompt text, or content blocks sent verbatim.
|
||||
* @param options - optional session id and per-notification observer.
|
||||
* @returns the settled turn result.
|
||||
* @returns the owned activity interval.
|
||||
*/
|
||||
run(input: string | ContentBlock[], options?: RunOptions): Promise<TurnResult> {
|
||||
run(input: string | ContentBlock[], options?: RunOptions): Promise<RunResult> {
|
||||
return this.session(options?.sessionId).run(input, options)
|
||||
}
|
||||
|
||||
@@ -127,8 +127,7 @@ export interface RunOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* One SDK session: a stable id plus the turn loop that pairs a
|
||||
* `session/prompt` with its `session.finished`.
|
||||
* One SDK session: a stable id plus owned activity intervals.
|
||||
*/
|
||||
export class HarnessSession {
|
||||
/**
|
||||
@@ -138,27 +137,23 @@ export class HarnessSession {
|
||||
constructor(readonly harness: DeepSeekHarness, readonly id: string) {}
|
||||
|
||||
/**
|
||||
* Run one prompt turn to settlement.
|
||||
* Queue one prompt, then observe the whole session through its next idle.
|
||||
* @param input - prompt text, or content blocks sent verbatim.
|
||||
* @param options - optional per-notification observer.
|
||||
* @returns the settled turn result; rejects on transport loss, timeout, or
|
||||
* a protocol error — never on a model-level failure (that is
|
||||
* `status: 'error'` in the result).
|
||||
* @returns the owned activity interval; rejects on transport loss, timeout,
|
||||
* or a protocol error.
|
||||
*/
|
||||
async run(input: string | ContentBlock[], options?: Pick<RunOptions, 'onNotification'>): Promise<TurnResult> {
|
||||
async run(input: string | ContentBlock[], options?: Pick<RunOptions, 'onNotification'>): Promise<RunResult> {
|
||||
await this.harness.start()
|
||||
const client = this.harness.client
|
||||
const contentBlocks = normalizeInput(input)
|
||||
const events: SessionEvent[] = []
|
||||
const notifications: HarnessNotification[] = []
|
||||
let status: TurnResult['status'] = 'error'
|
||||
let reason: TurnEndReason | undefined
|
||||
let finished = false
|
||||
|
||||
const subscription = client.subscribeSessionTree(this.id)
|
||||
const collect = (notification: HarnessNotification): void => {
|
||||
if (notification.method === 'session.event' && notification.params.sessionId === this.id) {
|
||||
// Wire boundary: the envelope feeds the typed TurnResult, so a
|
||||
// Wire boundary: the envelope feeds the typed RunResult, so a
|
||||
// malformed runtime surfaces as a protocol error, not as type-invalid
|
||||
// data (or a TypeError out of finalResponse).
|
||||
const event = validatedSessionEvent(notification.params.event)
|
||||
@@ -167,37 +162,31 @@ export class HarnessSession {
|
||||
events.push(event)
|
||||
return
|
||||
}
|
||||
if (notification.method === 'session.finished' && notification.params.sessionId === this.id) {
|
||||
reason = validatedTurnEndReason(notification.params.reason)
|
||||
notifications.push(notification)
|
||||
options?.onNotification?.(notification)
|
||||
status = notification.params.status === 'ok' ? 'ok' : 'error'
|
||||
finished = true
|
||||
return
|
||||
}
|
||||
notifications.push(notification)
|
||||
options?.onNotification?.(notification)
|
||||
}
|
||||
const accepted = client.prompt(this.id, contentBlocks)
|
||||
// Drain concurrently so observers see progress while the prompt request
|
||||
// is still pending (its response arrives only after settlement).
|
||||
const drain = (async () => {
|
||||
while (!finished) collect(await subscription.next())
|
||||
})()
|
||||
try {
|
||||
await Promise.all([accepted, drain])
|
||||
const messageId = await client.prompt(this.id, contentBlocks)
|
||||
let received = false
|
||||
while (true) {
|
||||
const notification = await subscription.next()
|
||||
if (!received) {
|
||||
if (notification.method !== 'session.event'
|
||||
|| notification.params.sessionId !== this.id
|
||||
|| !isInboxReceipt(notification.params.event, messageId)) continue
|
||||
received = true
|
||||
}
|
||||
collect(notification)
|
||||
if (notification.method === 'session.status'
|
||||
&& notification.params.sessionId === this.id
|
||||
&& notification.params.status === 'idle') break
|
||||
}
|
||||
} finally {
|
||||
// On a prompt rejection the drain is still parked on next(); closing the
|
||||
// subscription settles it, and the swallow keeps that secondary
|
||||
// TransportClosedError from surfacing as an unhandled rejection.
|
||||
subscription.close()
|
||||
await drain.catch(() => {})
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: this.id,
|
||||
status,
|
||||
reason,
|
||||
finalResponse: finalResponse(events),
|
||||
events,
|
||||
notifications,
|
||||
@@ -232,18 +221,16 @@ function validatedSessionEvent(value: unknown): SessionEvent {
|
||||
return value as unknown as SessionEvent
|
||||
}
|
||||
|
||||
/** Validate a wire `session.finished` reason (absent, or a kind-tagged record). */
|
||||
function validatedTurnEndReason(value: unknown): TurnEndReason | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (!isRecord(value) || typeof value.kind !== 'string') {
|
||||
throw new SdkProtocolError(`session.finished carried a malformed reason: ${JSON.stringify(value)}`)
|
||||
}
|
||||
return value as unknown as TurnEndReason
|
||||
/** Whether a raw session event is the durable enqueue receipt for `messageId`. */
|
||||
function isInboxReceipt(value: unknown, messageId: string): boolean {
|
||||
if (!isRecord(value) || value.type !== 'agent/inbox/spliced' || !isRecord(value.data)) return false
|
||||
const inserted = value.data.inserted
|
||||
return Array.isArray(inserted) && inserted.some(message => isRecord(message) && message.id === messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the concatenated text of the last assistant message.
|
||||
* @param events - the turn's `session.event` payloads in wire order.
|
||||
* @param events - the activity interval's `session.event` payloads in wire order.
|
||||
* @returns the final response text, or `''` when no assistant message exists.
|
||||
*/
|
||||
export function finalResponse(events: SessionEvent[]): string {
|
||||
|
||||
@@ -275,17 +275,18 @@ export class HarnessClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one prompt turn to settlement (the response arrives only after the
|
||||
* turn settled; progress streams as notifications meanwhile).
|
||||
* Queue one prompt and return its durable inbox identity.
|
||||
* @param sessionId - target session; an unknown id creates it.
|
||||
* @param contentBlocks - the user message, sent verbatim.
|
||||
* @returns the queued message id.
|
||||
*/
|
||||
async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise<void> {
|
||||
async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise<string> {
|
||||
const params: SessionPromptParams = { sessionId, contentBlocks }
|
||||
const result = await this.request('session/prompt', { ...params })
|
||||
if (!isRecord(result) || result.accepted !== true) {
|
||||
throw new SdkProtocolError(`session/prompt was not accepted: ${JSON.stringify(result)}`)
|
||||
if (!isRecord(result) || typeof result.messageId !== 'string') {
|
||||
throw new SdkProtocolError(`session/prompt returned no message id: ${JSON.stringify(result)}`)
|
||||
}
|
||||
return result.messageId
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* TypeScript client SDK for the DeepSeek Harness runtime: spawn the
|
||||
* `dsh-jsonrpc-agent` runtime as a subprocess and drive agent turns over
|
||||
* stdio JSON-RPC. `DeepSeekHarness` is the high-level turns API;
|
||||
* stdio JSON-RPC. `DeepSeekHarness` is the high-level run API;
|
||||
* `HarnessClient` is the lower-level protocol client. A pure library — it
|
||||
* registers nothing on a Cordis context; the runtime process it spawns is a
|
||||
* complete harness configured by its own `cordis.yml`.
|
||||
@@ -25,5 +25,5 @@ export type {
|
||||
HarnessClientOptions,
|
||||
HarnessNotification,
|
||||
NotificationFilter,
|
||||
TurnResult,
|
||||
RunResult,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
/**
|
||||
* Types for the TypeScript SDK client: launch options, notification shapes,
|
||||
* and turn results.
|
||||
* and owned activity results.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-client/types
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SdkRunStatus } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** One server-to-client notification as received off the wire. */
|
||||
export interface HarnessNotification {
|
||||
/** The JSON-RPC method name (`session.event`, `session.finished`, `subagent.started`, `subagent.finished`). */
|
||||
/** The JSON-RPC notification method name. */
|
||||
method: string
|
||||
/** The raw params object; see `HarnessSdkNotificationMap` for the shapes per method. */
|
||||
params: Record<string, unknown>
|
||||
@@ -51,7 +50,7 @@ export interface DeepSeekHarnessOptions {
|
||||
launch: HarnessClientOptions
|
||||
/** Workspace cwd recorded on every SDK-created session (default: the launch cwd, else `process.cwd()`). */
|
||||
cwd?: string
|
||||
/** Provider route for SDK-created agents (default `deepseek`). */
|
||||
/** Provider route for SDK-created agents (default `deepseek-official`). */
|
||||
provider?: string
|
||||
/** Model for SDK-created agents (default `deepseek-v4-flash`). */
|
||||
model?: string
|
||||
@@ -59,15 +58,11 @@ export interface DeepSeekHarnessOptions {
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
/** The settled outcome of one {@link HarnessSession.run} turn. */
|
||||
export interface TurnResult {
|
||||
/** The session the turn ran on. */
|
||||
/** One owned session activity interval, from enqueue receipt through idle. */
|
||||
export interface RunResult {
|
||||
/** The session the activity ran on. */
|
||||
sessionId: string
|
||||
/** Deployment-mapped turn outcome from `session.finished`. */
|
||||
status: SdkRunStatus
|
||||
/** Why the last message-triggered turn ended; `undefined` when no turn ran. */
|
||||
reason: TurnEndReason | undefined
|
||||
/** Concatenated text of the session's last assistant message (empty when none). */
|
||||
/** Concatenated text of the interval's last assistant message (empty when none). */
|
||||
finalResponse: string
|
||||
/** Every `session.event` payload for the root session, in wire order. */
|
||||
events: SessionEvent[]
|
||||
|
||||
@@ -142,13 +142,6 @@ function runTurn(sessionId: string): void {
|
||||
lastAssistantMessage: [{ type: 'text', text: 'child says hi' }],
|
||||
})
|
||||
}
|
||||
notify('session.finished', {
|
||||
sessionId,
|
||||
status: env.FAKE_STATUS ?? 'ok',
|
||||
...(env.FAKE_MALFORMED_REASON !== undefined
|
||||
? { reason: 'not-a-record' }
|
||||
: reasonKind === 'none' ? {} : { reason: { kind: reasonKind } }),
|
||||
})
|
||||
}
|
||||
|
||||
function sessionIdOf(params: Record<string, unknown> | undefined): string {
|
||||
@@ -197,8 +190,20 @@ reader.on('line', (line) => {
|
||||
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
|
||||
return
|
||||
case 'session/prompt': {
|
||||
const sessionId = sessionIdOf(frame.params)
|
||||
const messageId = `fake-user-${seq}`
|
||||
event(sessionId, 'agent/inbox/spliced', {
|
||||
target: 'next-turn',
|
||||
start: 0,
|
||||
inserted: [{
|
||||
id: messageId,
|
||||
role: 'user',
|
||||
content: [],
|
||||
source: { kind: 'user' },
|
||||
}],
|
||||
})
|
||||
notify('session.status', { sessionId, status: 'running' })
|
||||
if (env.FAKE_STREAM_THEN_MALFORMED !== undefined) {
|
||||
const sessionId = sessionIdOf(frame.params)
|
||||
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then cut short' } })
|
||||
respond({})
|
||||
return
|
||||
@@ -208,9 +213,9 @@ reader.on('line', (line) => {
|
||||
respond({})
|
||||
return
|
||||
}
|
||||
const sessionId = sessionIdOf(frame.params)
|
||||
runTurn(sessionId)
|
||||
respond({ accepted: true })
|
||||
notify('session.status', { sessionId, status: 'idle' })
|
||||
respond({ messageId })
|
||||
return
|
||||
}
|
||||
case 'shutdown':
|
||||
|
||||
@@ -13,6 +13,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DeepSeekHarness,
|
||||
HarnessClient,
|
||||
HarnessSession,
|
||||
JsonRpcResponseError,
|
||||
RequestTimeoutError,
|
||||
SdkProtocolError,
|
||||
@@ -53,17 +54,74 @@ async function tempDir(prefix: string): Promise<string> {
|
||||
}
|
||||
|
||||
describe('DeepSeekHarness', () => {
|
||||
it('ignores notifications that precede the submitted message receipt', async () => {
|
||||
const notifications = [
|
||||
{ method: 'session.status', params: { sessionId: 'owned', status: 'running' } },
|
||||
{
|
||||
method: 'session.event',
|
||||
params: { sessionId: 'owned', event: { type: 'turn/start', data: { turn: 1 } } },
|
||||
},
|
||||
{
|
||||
method: 'session.event',
|
||||
params: {
|
||||
sessionId: 'owned',
|
||||
event: { type: 'agent/inbox/spliced', data: { inserted: null } },
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'session.event',
|
||||
params: {
|
||||
sessionId: 'owned',
|
||||
event: {
|
||||
type: 'agent/inbox/spliced',
|
||||
seq: 0,
|
||||
time: 0,
|
||||
data: {
|
||||
target: 'next-turn',
|
||||
start: 0,
|
||||
inserted: [{ id: 'accepted-message', role: 'user', content: [], source: { kind: 'user' } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ method: 'session.status', params: { sessionId: 'owned', status: 'idle' } },
|
||||
] as HarnessNotification[]
|
||||
let closed = false
|
||||
const harness = {
|
||||
start: () => Promise.resolve(),
|
||||
client: {
|
||||
prompt: () => Promise.resolve('accepted-message'),
|
||||
subscribeSessionTree: () => ({
|
||||
next: async () => {
|
||||
const notification = notifications.shift()
|
||||
if (notification === undefined) throw new Error('scripted notification queue exhausted')
|
||||
return notification
|
||||
},
|
||||
tryNext: () => notifications.shift(),
|
||||
close: () => { closed = true },
|
||||
async * [Symbol.asyncIterator]() {},
|
||||
}),
|
||||
},
|
||||
} as unknown as DeepSeekHarness
|
||||
|
||||
const result = await new HarnessSession(harness, 'owned').run('go')
|
||||
|
||||
expect(result.notifications.map(notification => notification.method))
|
||||
.toEqual(['session.event', 'session.status'])
|
||||
expect(result.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('runs a turn end to end and reuses the runtime across sessions', async () => {
|
||||
const harness = harnessWith({ FAKE_TEXT: 'turn answer' })
|
||||
const first = await harness.run('say hi')
|
||||
expect(first.status).toBe('ok')
|
||||
expect(first.reason).toEqual({ kind: 'completed' })
|
||||
expect(first.finalResponse).toBe('turn answer')
|
||||
expect(first.events.map(event => event.type)).toEqual(['turn/start', 'assistant/chunk', 'assistant/message', 'turn/end'])
|
||||
expect(first.events.map(event => event.type)).toEqual([
|
||||
'agent/inbox/spliced', 'turn/start', 'assistant/chunk', 'assistant/message', 'turn/end',
|
||||
])
|
||||
|
||||
// Same subprocess, second session: ids differ, protocol state is reusable.
|
||||
const second = await harness.run([{ type: 'text', text: 'again' }])
|
||||
expect(second.status).toBe('ok')
|
||||
expect(second.sessionId).not.toBe(first.sessionId)
|
||||
await harness.close()
|
||||
})
|
||||
@@ -76,13 +134,12 @@ describe('DeepSeekHarness', () => {
|
||||
onNotification: (n) => { seen.push(n) },
|
||||
})
|
||||
|
||||
expect(result.status).toBe('ok')
|
||||
// The child session's events arrive through subagent.started lineage.
|
||||
expect(seen.map(n => n.method)).toContain('subagent.started')
|
||||
expect(seen.map(n => n.method)).toContain('subagent.finished')
|
||||
const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child')
|
||||
expect(childEvents.length).toBeGreaterThan(0)
|
||||
// TurnResult.events is the root session's typed stream; descendants retain
|
||||
// RunResult.events is the root session's typed stream; descendants retain
|
||||
// their session ids in the raw notification stream above.
|
||||
expect(result.events.every(event => event.type !== 'assistant/message'
|
||||
|| event.data.message.content[0]?.type !== 'text'
|
||||
@@ -90,22 +147,6 @@ describe('DeepSeekHarness', () => {
|
||||
await harness.close()
|
||||
})
|
||||
|
||||
it('reports an error status with the turn-end reason', async () => {
|
||||
const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'max-tokens' })
|
||||
const result = await harness.run('overflow')
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.reason).toEqual({ kind: 'max-tokens' })
|
||||
await harness.close()
|
||||
})
|
||||
|
||||
it('omits the reason when the runtime settled without one', async () => {
|
||||
const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'none' })
|
||||
const result = await harness.run('no turn')
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.reason).toBeUndefined()
|
||||
await harness.close()
|
||||
})
|
||||
|
||||
it('sends the configured cwd/provider/model/maxTokens in the handshake exactly once', async () => {
|
||||
const dir = await tempDir('sdk-client-init-')
|
||||
const recordFile = join(dir, 'init.jsonl')
|
||||
@@ -176,7 +217,6 @@ describe('DeepSeekHarness', () => {
|
||||
// Retry spawns a NEW subprocess through a fresh client (close is permanent).
|
||||
const result = await harness.run('again')
|
||||
expect(harness.client).not.toBe(firstClient)
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.finalResponse).toBe('second boot answer')
|
||||
await harness.close()
|
||||
// close() is terminal: a handshake failure after it must not respawn.
|
||||
@@ -194,7 +234,7 @@ describe('DeepSeekHarness', () => {
|
||||
await using harness = new DeepSeekHarness({ launch: fakeLaunch() })
|
||||
captured = harness
|
||||
const result = await harness.run('scoped')
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.finalResponse).toBe('hello from fake runtime')
|
||||
}
|
||||
// After scope exit the runtime is closed: reuse fails loudly.
|
||||
await expect(captured.run('after')).rejects.toThrow(TransportClosedError)
|
||||
@@ -311,14 +351,15 @@ describe('HarnessClient', () => {
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
|
||||
const all = client.subscribe()
|
||||
const finishedOnly = client.subscribe(n => n.method === 'session.finished')
|
||||
const idleOnly = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
|
||||
const firstPending = all.next()
|
||||
await client.prompt('sub-test', normalizeInput('go'))
|
||||
|
||||
const first = await all.next()
|
||||
const first = await firstPending
|
||||
expect(first.method).toBe('session.event')
|
||||
const finished = await finishedOnly.next()
|
||||
expect(finished.method).toBe('session.finished')
|
||||
expect(finishedOnly.tryNext()).toBeUndefined()
|
||||
const idle = await idleOnly.next()
|
||||
expect(idle.method).toBe('session.status')
|
||||
expect(idleOnly.tryNext()).toBeUndefined()
|
||||
|
||||
// A bare unbounded request with omitted params sends `{}` on the wire.
|
||||
const identity = await client.request('initialize') as { serverInfo: { name: string } }
|
||||
@@ -328,12 +369,12 @@ describe('HarnessClient', () => {
|
||||
const collected: string[] = []
|
||||
for await (const notification of all) {
|
||||
collected.push(notification.method)
|
||||
if (notification.method === 'session.finished') break
|
||||
if (notification.method === 'session.status' && notification.params.status === 'idle') break
|
||||
}
|
||||
expect(collected.at(-1)).toBe('session.finished')
|
||||
expect(collected.at(-1)).toBe('session.status')
|
||||
|
||||
all.close()
|
||||
finishedOnly.close()
|
||||
idleOnly.close()
|
||||
await expect(all.next()).rejects.toThrow('notification subscription closed')
|
||||
await client.close()
|
||||
})
|
||||
@@ -346,11 +387,11 @@ describe('HarnessClient', () => {
|
||||
const broken = client.subscribe(() => { throw new Error('filter exploded') })
|
||||
// A non-Error throw is normalized rather than crashing dispatch.
|
||||
const brokenNonError = client.subscribe(() => { throw 'string boom' })
|
||||
const healthy = client.subscribe(n => n.method === 'session.finished')
|
||||
const healthy = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
|
||||
await client.prompt('filter-contain', normalizeInput('go'))
|
||||
|
||||
// The sibling subscription and the read loop are undisturbed.
|
||||
expect((await healthy.next()).method).toBe('session.finished')
|
||||
expect((await healthy.next()).method).toBe('session.status')
|
||||
// Each broken subscription failed with ITS OWN error and detached.
|
||||
await expect(broken.next()).rejects.toThrow('filter exploded')
|
||||
await expect(brokenNonError.next()).rejects.toThrow('string boom')
|
||||
@@ -445,10 +486,6 @@ describe('wire payload validation', () => {
|
||||
await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError)
|
||||
})
|
||||
|
||||
it('rejects a malformed session.finished reason as a protocol error', async () => {
|
||||
const harness = harnessWith({ FAKE_MALFORMED_REASON: '1' })
|
||||
await expect(harness.run('bad-reason')).rejects.toThrow(SdkProtocolError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stderr tail bound', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/sdk/sdk-protocol/README.md
|
||||
README.md: 62b26d4a82d358fa4efcb7ab84036e5f4848057f
|
||||
README.zh.md: 11677c6119c7da407d95ee38ad9f8f7a552c15de
|
||||
README.md: 2d141e6f62ac0d324933996c282a2d0380af29f2
|
||||
README.zh.md: bc459f8817c9477a451015f7629168e246ee531c
|
||||
|
||||
@@ -15,14 +15,14 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim
|
||||
| Direction | Method | Types |
|
||||
|---|---|---|
|
||||
| client→server | `initialize` | `InitializeParams` → `InitializeResult` |
|
||||
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (answered only after turn settlement) |
|
||||
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (durable enqueue receipt) |
|
||||
| client→server | `shutdown` | no params → `{}` |
|
||||
| server→client | `session.event` | `SessionEventNotification` (every session in the runtime, unfiltered) |
|
||||
| server→client | `session.finished` | `SessionFinishedNotification` (one per accepted prompt) |
|
||||
| server→client | `session.status` | `SessionStatusNotification` (whole-agent `running`/`idle` transition) |
|
||||
| server→client | `subagent.started` | `SubagentStartedNotification` |
|
||||
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
|
||||
|
||||
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission leaves the provider default in control. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
|
||||
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按换行分帧的 JSON-RPC 2.0 传输类,加上协议两端共同使用的具名请求、结果与通知类型。包(package)根枚举协议消费方接口;源模块不支持深层导入。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者复现这些结构但不导入它们)。纯库——无插件、无 Config、无注册。
|
||||
DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按换行分帧的 JSON-RPC 2.0 传输类,加上协议两端共同使用的具名请求、结果与通知类型。包根枚举协议消费方接口;源模块不支持深层导入。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者复现这些结构但不导入它们)。纯库——无插件、无 Config、无注册。
|
||||
|
||||
## 传输
|
||||
|
||||
@@ -15,14 +15,14 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按
|
||||
| 方向 | 方法 | 类型 |
|
||||
|---|---|---|
|
||||
| client→server | `initialize` | `InitializeParams` → `InitializeResult` |
|
||||
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(仅在轮次结算完成后应答) |
|
||||
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(持久入队回执) |
|
||||
| client→server | `shutdown` | 无参数 → `{}` |
|
||||
| server→client | `session.event` | `SessionEventNotification`(运行时内每个会话,不过滤) |
|
||||
| server→client | `session.finished` | `SessionFinishedNotification`(每个获准的提示词请求一条) |
|
||||
| server→client | `session.status` | `SessionStatusNotification`(整个 agent(智能体)的 `running`/`idle` 转换) |
|
||||
| server→client | `subagent.started` | `SubagentStartedNotification` |
|
||||
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) |
|
||||
|
||||
`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent(智能体)及其进程内后代的每次对话模型输出;省略时由提供方默认值控制。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式契约的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。
|
||||
`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式契约的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -17,7 +17,7 @@ export type {
|
||||
InitializeResult,
|
||||
SdkRunStatus,
|
||||
SessionEventNotification,
|
||||
SessionFinishedNotification,
|
||||
SessionStatusNotification,
|
||||
SessionPromptParams,
|
||||
SessionPromptResult,
|
||||
SubagentFinishedNotification,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
/** Parameters for the process-wide SDK handshake. */
|
||||
@@ -38,10 +38,10 @@ export interface SessionPromptParams {
|
||||
contentBlocks: ContentBlock[]
|
||||
}
|
||||
|
||||
/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */
|
||||
/** Durable enqueue receipt for one prompt. */
|
||||
export interface SessionPromptResult {
|
||||
/** Always `true`; the turn outcome is the paired `session.finished` notification. */
|
||||
accepted: true
|
||||
/** Identity of the queued user message. */
|
||||
messageId: string
|
||||
}
|
||||
|
||||
/** Deployment-mapped SDK outcome: `ok` for an accepted result, `error` otherwise. */
|
||||
@@ -55,14 +55,12 @@ export interface SessionEventNotification {
|
||||
event: SessionEvent
|
||||
}
|
||||
|
||||
/** `session.finished` payload: one per accepted prompt, after turn settlement. */
|
||||
export interface SessionFinishedNotification {
|
||||
/** The settled session. */
|
||||
/** Whole-agent lifecycle state for one session. */
|
||||
export interface SessionStatusNotification {
|
||||
/** Session whose live agent changed status. */
|
||||
sessionId: string
|
||||
/** Deployment-mapped turn outcome (see `maxTokensAsSuccess` on the server). */
|
||||
status: SdkRunStatus
|
||||
/** Why the last message-triggered turn ended; absent when no turn ran. */
|
||||
reason: TurnEndReason | undefined
|
||||
/** The whole-agent state after the transition. */
|
||||
status: 'idle' | 'running'
|
||||
}
|
||||
|
||||
/** `subagent.started` payload: an in-runtime child session was created. */
|
||||
@@ -94,7 +92,7 @@ export interface SubagentFinishedNotification {
|
||||
/** Server-to-client notifications by JSON-RPC method name. */
|
||||
export interface HarnessSdkNotificationMap {
|
||||
'session.event': SessionEventNotification
|
||||
'session.finished': SessionFinishedNotification
|
||||
'session.status': SessionStatusNotification
|
||||
'subagent.started': SubagentStartedNotification
|
||||
'subagent.finished': SubagentFinishedNotification
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ describe('JsonRpcLineTransport', () => {
|
||||
const response = await b.request('echo', { value: 42 })
|
||||
expect(response).toEqual({ echoed: { value: 42 } })
|
||||
|
||||
a.notify('session.finished', { sessionId: 'main', status: 'ok' })
|
||||
a.notify('session.status', { sessionId: 'main', status: 'idle' })
|
||||
a.notify('heartbeat')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(notifications).toEqual([
|
||||
{ method: 'session.finished', params: { sessionId: 'main', status: 'ok' } },
|
||||
{ method: 'session.status', params: { sessionId: 'main', status: 'idle' } },
|
||||
{ method: 'heartbeat', params: {} },
|
||||
])
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/sdk/telemetry/README.md
|
||||
README.md: 1d33915f36e0af10eedac5f9ab34f2534268a327
|
||||
README.zh.md: d71aa36be66a250056dd20a1ca9a88836b28c184
|
||||
README.zh.md: af54cc2c78cb360d4305eeaf584c8330a0b7efa5
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
用于 dsh-sdk 工具链的启动器侧 telemetry 原语。这是启动器在执行每个命令时导入的普通库;它**不是** Cordis 插件,因为 `build` 与首次初始化的 `create` 从不启动 Cordis。将 reporter 接入启动器命令分发,并把 telemetry consent 功能加入 `dsh-helper` 目录,属于各自所属包(package)的职责,而不是此包的职责。
|
||||
用于 dsh-sdk 工具链的启动器侧 telemetry 原语。这是启动器在执行每个命令时导入的普通库;它**不是** Cordis 插件,因为 `build` 与首次初始化的 `create` 从不启动 Cordis。将 reporter 接入启动器命令分发,并把 telemetry consent 功能加入 `dsh-helper` 目录,属于各自所属包的职责,而不是此包的职责。
|
||||
|
||||
| 导出 | 职责 |
|
||||
|---|---|
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user