mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a twenty-key pass-through Config. Neither file was what its location claimed — apps/cli hardcoded the "example" as the product default and the "demo" bundle was the application — and every capability change had to be made twice. - apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and web.cordis.yml are patch lists stating only what differs per surface - overlays apply as SIBLING patch lists at one include level, because include patches never cross an include boundary. Precedence: base < surface < (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches - `--config` now applies an overlay INSTEAD OF the personal one, so a demo or test tree never inherits the user's route; new `--config-replace` boots a file as the entire tree (the old `--config` behaviour). Both survive /resume - vendor/include: index each `insert`ed row as it is added so a later patch can configure or disable it. Upstream built the id index once before the patch loop, leaving every surface-only row — the whole TUI front door — silently unpatchable from user config. Logged as local modification 8 - session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY; dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it) - delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo; TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests, examples/code-mode survives as an overlay leaf - `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay Three latent defects surfaced and are fixed here: the TUI captured the optional sessionQuery service once at construction and could permanently disable /resume when it won the mount race; the session-store root silently reverted to a project-local ./.sessions; --config-replace was dropped by the resume handoff. Verified by booting each tree through the real Loader (TUI 55 entries, web 75, zero unsettled) rather than reading YAML. All eight terminal snapshots replay byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene and lint clean.
178 lines
7.8 KiB
TypeScript
178 lines
7.8 KiB
TypeScript
/**
|
|
* Config hot-reload resilience of the booted include tree. `dsh-app-boot`
|
|
* installs a fail-loud unhandled-rejection handler, so a `refresh()` that
|
|
* rethrows a config-file parse error would kill a live app on one bad
|
|
* `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
|
|
* callback nobody else catches). These tests pin the vendored
|
|
* `@cordisjs/plugin-include` contract that boot relies on: an invalid file
|
|
* keeps the last good tree, and a valid re-read re-applies overlay patches
|
|
* exactly like the initial load.
|
|
*/
|
|
|
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { Context } from 'cordis'
|
|
import type { Include } from '@cordisjs/plugin-include'
|
|
import { boot } from '../src/index.ts'
|
|
|
|
const NAME = 'dsh-test-bin'
|
|
|
|
const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n'
|
|
|
|
interface TreeFixture {
|
|
ctx: Context
|
|
dir: string
|
|
include: Include
|
|
}
|
|
|
|
async function bootTree(configBody: string): Promise<TreeFixture> {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
|
|
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
|
writeFileSync(join(dir, 'cordis.yml'), configBody)
|
|
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
|
const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
|
|
if (entry?.subtree === undefined) throw new Error('booted tree has no include entry')
|
|
return { ctx, dir, include: entry.subtree as Include }
|
|
}
|
|
|
|
function entryConfig(ctx: Context, id: string): unknown {
|
|
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
|
}
|
|
|
|
describe('include refresh with an invalid file', () => {
|
|
it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
|
|
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
|
|
try {
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
|
|
|
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
|
|
await expect(include.refresh()).resolves.toBeUndefined()
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
|
|
|
// An empty file parses to `undefined` without a YAML error; it must be
|
|
// treated exactly like a parse failure, not crash the entry walk.
|
|
writeFileSync(join(dir, 'cordis.yml'), '')
|
|
await expect(include.refresh()).resolves.toBeUndefined()
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
|
|
|
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
|
|
await include.refresh()
|
|
await ctx.loader.await()
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 2 })
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('include refresh with overlay patches', () => {
|
|
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
|
|
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
|
writeFileSync(join(dir, 'base.yml'), '- id: noop\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:',
|
|
' - id: noop',
|
|
' name: ./noop.mjs',
|
|
' config:',
|
|
' value: patched',
|
|
' - insert:',
|
|
' - id: extra',
|
|
' name: ./noop.mjs',
|
|
'',
|
|
].join('\n'))
|
|
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
|
try {
|
|
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'base')
|
|
if (entry?.subtree === undefined) throw new Error('overlay tree has no base include entry')
|
|
const include = entry.subtree as Include
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
|
|
expect(entryConfig(ctx, 'extra')).toBeUndefined()
|
|
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
|
|
|
|
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited\n')
|
|
await include.refresh()
|
|
await ctx.loader.await()
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched' })
|
|
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(true)
|
|
|
|
// Hot-update of the include entry's own config (the `internal/update`
|
|
// path): the new patches must apply now AND stick for later re-reads —
|
|
// the listener vetoes the fiber restart, so it must persist the new
|
|
// config itself or the next refresh() re-applies the old overlay.
|
|
await entry.update({ config: { path: './base.yml', patches: [{ id: 'noop', name: './noop.mjs', config: { value: 'patched-v2' } }] } })
|
|
await ctx.loader.await()
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
|
|
expect([...ctx.loader.entries()].some(candidate => candidate.options.id === 'extra')).toBe(false)
|
|
|
|
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: edited-2\n')
|
|
await include.refresh()
|
|
await ctx.loader.await()
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
|
|
|
|
// Removing every patch must revert to the file's own values: patching
|
|
// may not bake earlier patch results into the cached parse.
|
|
await entry.update({ config: { path: './base.yml', patches: [] } })
|
|
await ctx.loader.await()
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
}
|
|
})
|
|
})
|
|
|
|
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()
|
|
}
|
|
})
|
|
})
|