mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/fix/web-transcript-projection' into feat/manual-compact-single-lock
# Conflicts: # apps/cli/config/base.cordis.yml # apps/cli/cordis.yml # docs/event-producer-consumer.md # examples/package.json # examples/tui-agent/README.i18n.yaml # examples/tui-agent/README.md # examples/tui-agent/README.zh.md # pnpm-lock.yaml
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/ui/app-boot/README.md
|
||||
README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad
|
||||
README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06
|
||||
README.md: 4d8c7de65515a251f227075c7baf041fc1b210c8
|
||||
README.zh.md: d9b69a2b102a60685524288f75edeaabb21802db
|
||||
|
||||
@@ -10,9 +10,10 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
|
||||
| `assertEntriesActive(ctx, binName)` | Throw when a settled enabled fiber is not ACTIVE, including missing injected services for PENDING entries |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
|
||||
| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable |
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and ACTIVE, and return the root context |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
@@ -24,10 +25,10 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
|
||||
|
||||
## Personal config
|
||||
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
|
||||
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
|
||||
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
|
||||
|
||||
@@ -45,4 +46,3 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec
|
||||
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
|
||||
- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables.
|
||||
- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps.
|
||||
- **Personal patches see only the booted file's own entries** — an overlay leaf that reaches its base through a nested include entry (the Code Mode configs) resolves personal patch ids against the overlay's top-level entries, not the included subtree.
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
|
||||
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
|
||||
| `assertEntriesActive(ctx, binName)` | 树结算后,如果已启用的 fiber 未处于 ACTIVE 状态,则抛出异常;对于 PENDING 条目还会列出缺失的注入服务 |
|
||||
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 |
|
||||
| `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载且处于 ACTIVE 状态,最后返回根上下文 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
@@ -24,10 +25,10 @@
|
||||
|
||||
## 个人配置
|
||||
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
|
||||
|
||||
- **`.env`**:在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`。
|
||||
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
|
||||
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
|
||||
|
||||
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。
|
||||
|
||||
@@ -45,4 +46,3 @@
|
||||
- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
|
||||
- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。
|
||||
- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。
|
||||
- **个人 patch 只能看到已启动文件自身的条目**:如果 overlay 叶子通过嵌套 include 条目访问其基础配置(例如 Code Mode 配置),个人 patch id 只会在 overlay 的顶层条目中解析,不会进入被 include 的子树。
|
||||
|
||||
@@ -10,7 +10,7 @@ import { pathToFileURL } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Context } from 'cordis'
|
||||
import { Context, type FiberState } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
@@ -94,20 +94,56 @@ export function loadPersonalPatches(
|
||||
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
|
||||
throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`)
|
||||
}
|
||||
return parsePatchList(binName, file, content, 'personal patches')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a
|
||||
* `--config <path>` overlay applied over the shared base. Same file format as
|
||||
* {@link loadPersonalPatches}, but a missing file throws, because the caller
|
||||
* named this file — its absence is a misconfiguration, not "no overlay".
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param file - absolute path of the overlay file.
|
||||
* @returns the parsed patch list.
|
||||
*/
|
||||
export function loadOverlayPatches(binName: string, file: string): PatchOptions[] {
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(file, 'utf8')
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`)
|
||||
}
|
||||
return parsePatchList(binName, file, content, 'overlay')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one loader patch list: a top-level YAML array of
|
||||
* `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and
|
||||
* `insert` lists, `!!js` expressions allowed). Every shape failure throws,
|
||||
* because a patch file that cannot be applied at all is a misconfiguration; a
|
||||
* single patch whose target row is absent stays a per-entry Loader warning, so
|
||||
* one overlay shared across surfaces does not have to match every tree.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param file - the source path, quoted in errors.
|
||||
* @param content - the file's text.
|
||||
* @param label - what to call this list in errors (`personal patches`, `overlay`).
|
||||
* @returns the parsed patch list.
|
||||
*/
|
||||
function parsePatchList(
|
||||
binName: string, file: string, content: string, label: string,
|
||||
): PatchOptions[] {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(content, { schema: personalPatchesSchema })
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to parse personal patches ${file}: ${String(error)}`)
|
||||
throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${binName}: personal patches ${file} must be a top-level YAML array of loader patch entries`)
|
||||
throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
|
||||
}
|
||||
// A present personal config that cannot apply is a misconfiguration and must
|
||||
// fail loud here — the include only warns per entry at mount.
|
||||
parsed.forEach((entry, index) => {
|
||||
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
||||
throw new Error(`${binName}: personal patches entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
|
||||
throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
|
||||
}
|
||||
})
|
||||
return parsed as PatchOptions[]
|
||||
@@ -156,16 +192,30 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime mirrors for Cordis's erased const-enum fiber states. */
|
||||
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
||||
const FIBER_PENDING = 0 as FiberState.PENDING
|
||||
|
||||
/**
|
||||
* Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
|
||||
* session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`
|
||||
* makes `id` readable as the bare identifier `resumeSessionId` in a config
|
||||
* `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
|
||||
* so resuming a session needs no environment variable. A bin that never
|
||||
* provides it leaves the identifier undeclared, so configs read it defensively
|
||||
* (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
|
||||
* Reject enabled Loader entries whose fibers did not reach ACTIVE after settle.
|
||||
* @param ctx - The settled application root.
|
||||
* @param binName - Diagnostic prefix.
|
||||
*/
|
||||
export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
|
||||
export function assertEntriesActive(ctx: Context, binName: string): void {
|
||||
const failures: string[] = []
|
||||
for (const entry of ctx.loader.entries()) {
|
||||
if (entry.fiber === undefined || entry.disabled || entry.fiber.state === FIBER_ACTIVE) continue
|
||||
if (entry.fiber.state === FIBER_PENDING) {
|
||||
const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined)
|
||||
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
||||
} else {
|
||||
failures.push(`${entry.options.name}: fiber state ${String(entry.fiber.state)}`)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`${binName}: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the Loader against `absoluteConfigPath` and return only after the whole
|
||||
@@ -183,7 +233,7 @@ export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
|
||||
* (see {@link resolveConfigPath}).
|
||||
* @param patches - optional overlay patches applied over the included tree
|
||||
* (see {@link loadPersonalPatches}); an empty list mounts none.
|
||||
* @param prepare - optional host setup run against the root context before any Loader entry mounts.
|
||||
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
|
||||
* @returns the root context once every entry has started.
|
||||
*/
|
||||
export async function boot(
|
||||
@@ -193,10 +243,10 @@ export async function boot(
|
||||
prepare?: (ctx: Context) => Promise<void> | void,
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await prepare?.(ctx)
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await prepare?.(ctx)
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: {
|
||||
@@ -206,6 +256,7 @@ export async function boot(
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, binName)
|
||||
assertEntriesActive(ctx, binName)
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
|
||||
addHarnessSourceSection, assertEntriesActive, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
@@ -157,6 +157,25 @@ describe('assertEntriesLoaded', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadOverlayPatches', () => {
|
||||
it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => {
|
||||
const dir = tmp()
|
||||
const valid = join(dir, 'valid.yml')
|
||||
writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n')
|
||||
expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }])
|
||||
expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`)
|
||||
const malformed = join(dir, 'malformed.yml')
|
||||
writeFileSync(malformed, ': bad')
|
||||
expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`)
|
||||
const mapping = join(dir, 'mapping.yml')
|
||||
writeFileSync(mapping, 'id: target\n')
|
||||
expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array')
|
||||
const scalar = join(dir, 'scalar.yml')
|
||||
writeFileSync(scalar, '- scalar\n')
|
||||
expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot', () => {
|
||||
it('boots a leaf config through the real Loader and settles the tree', async () => {
|
||||
const dir = tmp()
|
||||
@@ -176,7 +195,11 @@ describe('boot', () => {
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
|
||||
const prepared: Context[] = []
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) })
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => {
|
||||
expect(hostCtx.loader).toBeDefined()
|
||||
expect([...hostCtx.loader.entries()]).toEqual([])
|
||||
prepared.push(hostCtx)
|
||||
})
|
||||
try {
|
||||
expect(prepared).toEqual([ctx])
|
||||
} finally {
|
||||
@@ -189,6 +212,33 @@ describe('boot', () => {
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
|
||||
})
|
||||
|
||||
it('rejects a settled tree with a pending inject and names every missing service', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'waiting.mjs'), "export const inject = ['alpha', 'beta']\nexport function apply() {}\n")
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow('./waiting.mjs: pending (waiting for services: alpha, beta)')
|
||||
})
|
||||
|
||||
it('uses singular diagnostics for one missing pending dependency', () => {
|
||||
const ctx = {
|
||||
loader: { entries: () => [{ disabled: false, options: { name: 'waiting' }, fiber: { state: 0, inject: { alpha: {} } } }] },
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow('waiting: pending (waiting for service: alpha)')
|
||||
})
|
||||
|
||||
it('reports unknown pending dependencies and unexpected fiber states', () => {
|
||||
const entries = [
|
||||
{ disabled: false, options: { name: 'unknown' }, fiber: { state: 0, inject: {} } },
|
||||
{ disabled: false, options: { name: 'failed' }, fiber: { state: 3, inject: {} } },
|
||||
]
|
||||
const ctx = {
|
||||
loader: { entries: () => entries },
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
expect(() =>{ assertEntriesActive(ctx, NAME) }).toThrow(`${NAME}: 2 entries did not activate\nunknown: pending (waiting for services: unknown)\nfailed: fiber state 3`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addHarnessSourceSection', () => {
|
||||
|
||||
@@ -126,3 +126,52 @@ describe('include refresh with overlay patches', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('include patches layered over one base', () => {
|
||||
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
|
||||
// The surface/`--config`/personal composition: `dsh` includes one shared
|
||||
// base and applies each source as its own patch list at the SAME include
|
||||
// level, because patches never cross an include boundary. A later layer
|
||||
// must therefore be able to reach a row an earlier layer inserted —
|
||||
// otherwise every surface-only row (the whole TUI front door) would be
|
||||
// invisible to the user's `~/.dsh/config.yaml`.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: base',
|
||||
" name: 'cordis:include'",
|
||||
' config:',
|
||||
' path: ./base.yml',
|
||||
' patches:',
|
||||
// Layer 1 (a surface overlay): patch a base row and add two of its own.
|
||||
' - id: shared',
|
||||
' config:',
|
||||
' value: surface',
|
||||
' - insert:',
|
||||
' - id: surface-kept',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: surface-default',
|
||||
' - id: surface-dropped',
|
||||
' name: ./noop.mjs',
|
||||
// Layer 2 (the user): reconfigure one inserted row and disable the other.
|
||||
' - id: surface-kept',
|
||||
' config:',
|
||||
' value: personal',
|
||||
' - id: surface-dropped',
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' })
|
||||
expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' })
|
||||
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped')
|
||||
expect(dropped?.options.disabled).toBe(true)
|
||||
expect(dropped?.fiber).toBeUndefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/ui/tui/README.md
|
||||
README.md: c8b7fabf5232cd94027c3c63d6f1cb7eb39ea309
|
||||
README.zh.md: f4abee7c7759846af2e53a59514c364a141c7f0b
|
||||
README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e
|
||||
README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d
|
||||
|
||||
@@ -75,7 +75,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
|
||||
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops extension admission, unloads the `ctx.tui` provider and its dependent plugins, aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. A user exit disposes the application root so sibling resources close, then exits; a five-second fallback prevents one stuck disposer from trapping the process.
|
||||
|
||||
## Color
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose(资源释放)会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。
|
||||
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose(资源释放)会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。用户退出会先 dispose 应用根上下文以关闭同级资源,再退出进程;五秒兜底可避免某个卡住的 disposer 困住进程。
|
||||
|
||||
## 颜色
|
||||
|
||||
|
||||
@@ -29,7 +29,13 @@ import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
|
||||
export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice {
|
||||
readonly agent: Agent
|
||||
readonly runtime: TuiRuntime
|
||||
readonly sessionQuery: SessionQueryService | undefined
|
||||
/**
|
||||
* The optional session-query service, re-read at each use. `sessionQuery` is
|
||||
* mounted by an independent plugin, and a flat config tree gives no ordering
|
||||
* guarantee between it and this front door, so a value captured once at
|
||||
* construction can be `undefined` even though the service arrives moments later.
|
||||
*/
|
||||
readonly sessionQuery: (this: void) => SessionQueryService | undefined
|
||||
readonly ui: TUI
|
||||
readonly editor: HintEditor
|
||||
/** Current agent status, re-read at each resume precondition point. */
|
||||
@@ -74,9 +80,11 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
events: live.events.map(event => structuredClone(event)),
|
||||
}
|
||||
} else {
|
||||
/* v8 ignore next -- caller checks the optional service before mapping records */
|
||||
if (sessionQuery === undefined) throw new Error('session query is unavailable')
|
||||
snapshot = await sessionQuery.readSession(record.header.id)
|
||||
const readQuery = sessionQuery()
|
||||
/* v8 ignore start -- caller proves the optional service before mapping records */
|
||||
if (readQuery === undefined) throw new Error('session query is unavailable')
|
||||
/* v8 ignore stop */
|
||||
snapshot = await readQuery.readSession(record.header.id)
|
||||
}
|
||||
return summarizeResumeCandidate(
|
||||
record,
|
||||
@@ -104,11 +112,13 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
* resolve the exact identity and workspace the host will re-exec into.
|
||||
*/
|
||||
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
|
||||
/* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */
|
||||
if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
|
||||
const query = sessionQuery()
|
||||
/* v8 ignore start -- showResume alone calls this after proving the optional service exists */
|
||||
if (query === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
|
||||
/* v8 ignore stop */
|
||||
const initialStatus = deps.agentStatus()
|
||||
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
|
||||
const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId)
|
||||
const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId)
|
||||
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
|
||||
const candidate = await readResumeCandidate(
|
||||
record,
|
||||
@@ -177,13 +187,14 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
|
||||
return
|
||||
}
|
||||
if (sessionQuery === undefined) {
|
||||
const listQuery = sessionQuery()
|
||||
if (listQuery === undefined) {
|
||||
deps.appendNotice('Resume is not available: session query is not mounted.', 'warning')
|
||||
return
|
||||
}
|
||||
const scan = ++resumeScan
|
||||
void resumeOverlay?.close()
|
||||
void sessionQuery.listSessions().then(async (records) => {
|
||||
void listQuery.listSessions().then(async (records) => {
|
||||
if (deps.isDisposed() || scan !== resumeScan) return
|
||||
// Every workspace in the store is summarized; the picker owns the
|
||||
// current-workspace/all-workspaces scope split over the whole set.
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type SlashCommand,
|
||||
type TerminalColorScheme,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import { Service, type Context, type Fiber } from 'cordis'
|
||||
import { Service, type Context, type Fiber, type FiberState } from 'cordis'
|
||||
import {
|
||||
assembleContextFor,
|
||||
installAgentLlmTarget,
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
TuiExtensionServiceImpl,
|
||||
TuiOverlayManager,
|
||||
} from './extension/overlay-manager.ts'
|
||||
|
||||
import {
|
||||
parseTuiPromptTemplate,
|
||||
renderTuiPromptTemplate,
|
||||
@@ -172,6 +173,9 @@ export type {
|
||||
TuiViewport,
|
||||
} from './extension/types.ts'
|
||||
|
||||
/** First terminal Cordis state: FAILED, DISPOSED, and UNLOADING are unusable. */
|
||||
const FIBER_FAILED = 3 as FiberState.FAILED
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Terminal-only interaction service, available only while a TUI is mounted. */
|
||||
@@ -184,8 +188,6 @@ declare module 'cordis' {
|
||||
tuiGoodbyeMessage: string | undefined
|
||||
/** Skill the launcher wants auto-invoked as the fresh session's first turn; absent leaves it to the user. */
|
||||
tuiInitialSkill: string | undefined
|
||||
/** Launcher-owned session-store root the app bundle defaults to; absent keeps the bundle's project-local default. */
|
||||
launcherSessionsRoot: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,16 +232,6 @@ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
|
||||
*/
|
||||
export const INITIAL_SKILL_KEY = 'tuiInitialSkill'
|
||||
|
||||
/**
|
||||
* Context key a launcher sets before any Loader entry mounts
|
||||
* (`ctx.provide(SESSIONS_ROOT_KEY, root)`) to supply its session-store root as
|
||||
* the app bundle's default persistence root. Shared-store policy (one store
|
||||
* across every cwd) belongs to the launcher — the dsh CLI resolves it under the
|
||||
* Harness home — never to a plugin; a bundle without this slot keeps its own
|
||||
* project-local default, and an explicit `persistenceRoot` config still wins.
|
||||
*/
|
||||
export const SESSIONS_ROOT_KEY = 'launcherSessionsRoot'
|
||||
|
||||
/**
|
||||
* Optional terminal-local interaction service provided by one mounted TUI.
|
||||
*
|
||||
@@ -315,7 +307,6 @@ export function createTuiChat(
|
||||
const sessionId = SessionId(config.sessionId ?? 'main')
|
||||
const agent = ctx.agents.get(sessionId)
|
||||
if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
|
||||
const sessionQuery = ctx.get('sessionQuery')
|
||||
const resolved = resolveTuiConfig(config)
|
||||
const palette = createPalette(resolved.theme.color)
|
||||
const mdTheme = markdownTheme(palette)
|
||||
@@ -877,7 +868,14 @@ export function createTuiChat(
|
||||
resolved,
|
||||
palette,
|
||||
overlayManager,
|
||||
sessionQuery,
|
||||
// Optional and independently mounted. Cordis transiently leaves this sibling
|
||||
// non-ACTIVE during command callbacks, so the non-strict read is intentional;
|
||||
// terminal fiber states still exclude failed, closing, and closed providers.
|
||||
sessionQuery: () => {
|
||||
const implementation = ctx.reflect._getImpl('sessionQuery', false)
|
||||
if (implementation === undefined || implementation.fiber.state >= FIBER_FAILED) return undefined
|
||||
return ctx.get('sessionQuery', false)
|
||||
},
|
||||
ui,
|
||||
editor,
|
||||
appendNotice,
|
||||
@@ -1689,9 +1687,35 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi
|
||||
if (existing !== undefined) start(existing)
|
||||
}
|
||||
|
||||
const ROOT_DISPOSE_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* Dispose the whole application before process exit, with a bounded fallback.
|
||||
* @param ctx - The TUI plugin context whose root owns sibling resources.
|
||||
* @param code - Process status to report.
|
||||
* @param exit - Exit boundary, replaceable by tests.
|
||||
*/
|
||||
export function disposeRootAndExit(
|
||||
ctx: Context,
|
||||
code: number,
|
||||
exit: (status: number) => void = (status) => { process.exit(status) },
|
||||
): void {
|
||||
let exited = false
|
||||
const exitOnce = (): void => {
|
||||
if (exited) return
|
||||
exited = true
|
||||
exit(code)
|
||||
}
|
||||
const timeout = setTimeout(exitOnce, ROOT_DISPOSE_TIMEOUT_MS)
|
||||
void ctx.root.fiber.dispose().then(
|
||||
() => { clearTimeout(timeout); exitOnce() },
|
||||
() => { clearTimeout(timeout); exitOnce() },
|
||||
)
|
||||
}
|
||||
|
||||
/** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */
|
||||
/* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat,
|
||||
and the tui-agent PTY smoke covers the real entry */
|
||||
and apps/cli PTY smokes cover the real entry */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes')
|
||||
@@ -1711,7 +1735,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
initialSkill === undefined ? {} : { initialSkill },
|
||||
), {
|
||||
terminal: new ProcessTerminal(),
|
||||
exit: code => process.exit(code),
|
||||
exit: (code) => { disposeRootAndExit(ctx, code) },
|
||||
...resumeHost === undefined ? {} : { handoffResume: (sessionId, cwd) => resumeHost.handoff(sessionId, cwd) },
|
||||
...goodbyeMessage === undefined ? {} : { goodbyeMessage },
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ import SessionReferenceService, { formatSessionReferenceMention } from '@deepsee
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import {
|
||||
createTuiChat,
|
||||
disposeRootAndExit,
|
||||
FILE_REFERENCE_PROMPT,
|
||||
mountTui,
|
||||
renderSkillInvocation,
|
||||
@@ -503,6 +504,41 @@ describe('goodbye message and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('allows a transient session-query state but rejects a terminal state', async () => {
|
||||
let queryCtx: Context | undefined
|
||||
let listCalls = 0
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
async configureContext(ctx) {
|
||||
await ctx.plugin({
|
||||
apply(child: Context) {
|
||||
queryCtx = child
|
||||
child.provide('sessionQuery', {
|
||||
listSessions: async () => { listCalls++; return [] },
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
if (queryCtx === undefined) throw new Error('query provider did not mount')
|
||||
const activeState = queryCtx.fiber.state
|
||||
queryCtx.fiber.state = 0
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(listCalls).toBe(1)
|
||||
result.terminal.send('\u001B')
|
||||
await tick()
|
||||
queryCtx.fiber.state = 5
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('session query is not mounted')
|
||||
expect(listCalls).toBe(1)
|
||||
queryCtx.fiber.state = activeState
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('keeps persisted query records readable without a persistence service', async () => {
|
||||
const target = header('query-only-persisted', 10, '/workspace')
|
||||
const result = await setup({
|
||||
@@ -5059,6 +5095,59 @@ describe('TUI extension service', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('application exit', () => {
|
||||
it('disposes the root fiber rather than only the TUI child before exiting', async () => {
|
||||
const rootDispose = vi.fn(() => Promise.resolve())
|
||||
const childDispose = vi.fn(() => Promise.resolve())
|
||||
const ctx = {
|
||||
root: { fiber: { dispose: rootDispose } },
|
||||
fiber: { dispose: childDispose },
|
||||
} as unknown as Context
|
||||
const exit = vi.fn()
|
||||
disposeRootAndExit(ctx, 7, exit)
|
||||
await Promise.resolve()
|
||||
expect(rootDispose).toHaveBeenCalledOnce()
|
||||
expect(childDispose).not.toHaveBeenCalled()
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('forces exit when root disposal does not settle', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let settle!: () => void
|
||||
const disposal = new Promise<void>((resolve) => { settle = resolve })
|
||||
const ctx = {
|
||||
root: { fiber: { dispose: () => disposal } },
|
||||
} as unknown as Context
|
||||
const exit = vi.fn()
|
||||
disposeRootAndExit(ctx, 9, exit)
|
||||
await vi.advanceTimersByTimeAsync(4_999)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(9)
|
||||
settle()
|
||||
await disposal
|
||||
await Promise.resolve()
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('exits after a rejected root disposal without an unhandled rejection', async () => {
|
||||
const ctx = {
|
||||
root: { fiber: { dispose: () => Promise.reject(new Error('cleanup failed')) } },
|
||||
} as unknown as Context
|
||||
const exit = vi.fn()
|
||||
disposeRootAndExit(ctx, 5, exit)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(exit).toHaveBeenCalledWith(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal mounting', () => {
|
||||
it('starts immediately when the configured agent already exists', async () => {
|
||||
const ctx = new Context()
|
||||
|
||||
Reference in New Issue
Block a user