Merge remote-tracking branch 'origin/master' into worktree/pr468-retarget-latest-master
# Conflicts: # docs/config-catalog.md # docs/event-producer-consumer.md # docs/module-graph.md # packages/examples/tui-demo/src/index.ts # packages/examples/tui-demo/tests/tui-agent.spec.ts # packages/ui/tui/README.md # packages/ui/tui/package.json # packages/ui/tui/src/index.ts # packages/ui/tui/tests/tui.spec.ts # packages/ui/tui/tsconfig.json # pnpm-lock.yaml # vitest.config.ts
This commit is contained in:
@@ -11,8 +11,16 @@ import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/
|
||||
const NAME = 'dsh-tui-demo'
|
||||
|
||||
/* v8 ignore start -- thin self-executing composition over the unit-tested
|
||||
dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and
|
||||
built-bin smokes */
|
||||
dsh-app-boot helpers; exercised end-to-end by the tui-agent PTY smoke and
|
||||
the built-bin fail-loud smoke */
|
||||
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree is
|
||||
// logged per-entry rather than rethrown, so a piped launch would otherwise
|
||||
// settle into an idle UI-less process instead of exiting nonzero.
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs; `
|
||||
+ 'use the one-shot dsh-cli-demo bin for pipes and automation\n')
|
||||
process.exit(1)
|
||||
}
|
||||
installFailLoud(NAME)
|
||||
loadEnv(NAME)
|
||||
await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined))
|
||||
|
||||
@@ -29,8 +29,10 @@ import * as uiTui from '@deepseek-ai/dsh-tui'
|
||||
|
||||
export const name = 'tui-demo'
|
||||
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
|
||||
const DEFAULT_WELCOME = 'ready.'
|
||||
|
||||
// Each front door keeps a complete Loader contract so its deployment config is
|
||||
// readable without a cross-package facade.
|
||||
/* jscpd:ignore-start */
|
||||
/** App config routed to the spine, TUI, configured agent, and JSONL backend. */
|
||||
export interface Config {
|
||||
/** Provider route for the `main` agent. */
|
||||
@@ -55,8 +57,15 @@ export interface Config {
|
||||
persistenceCompression?: JsonlCompression
|
||||
/** Cross-session reference discovery and snapshot byte budgets. */
|
||||
sessionReferences?: SessionReferenceConfig
|
||||
/** TUI subtitle rendered on start. Defaults to `ready.`. */
|
||||
/** TUI transcript's optional first line; absent renders nothing on start. */
|
||||
welcome?: string
|
||||
/**
|
||||
* Shell command template the TUI prints on exit and lists under `/resume`,
|
||||
* with `{session}` replaced by the live session id (forwarded to the front
|
||||
* door). Set it to a command that resumes via this app's env var, e.g.
|
||||
* `RESUME_SESSION_ID={session} dsh`.
|
||||
*/
|
||||
resumeCommand?: string
|
||||
/** Full-screen TUI presentation settings. */
|
||||
ui?: uiTui.TuiConfig
|
||||
/** Skill registry, local-provider, and model-facing consumer config. */
|
||||
@@ -73,9 +82,6 @@ export interface Config {
|
||||
workspaceContext: agentCore.Config['workspaceContext']
|
||||
}
|
||||
|
||||
// Each front door keeps a complete Loader schema so its deployment contract is
|
||||
// readable without a cross-package config facade.
|
||||
/* jscpd:ignore-start */
|
||||
export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
@@ -89,7 +95,8 @@ export const Config: z<Config> = z.object({
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
sessionReferences: SessionReferenceService.Config,
|
||||
welcome: z.string().default(DEFAULT_WELCOME),
|
||||
welcome: z.string(),
|
||||
resumeCommand: z.string(),
|
||||
ui: uiTui.TuiConfigSchema,
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
@@ -122,7 +129,8 @@ export function composeTuiApp(ctx: Context, config: Config): void {
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(uiTui, {
|
||||
...config.ui,
|
||||
welcome: config.welcome ?? DEFAULT_WELCOME,
|
||||
...config.welcome === undefined ? {} : { welcome: config.welcome },
|
||||
...config.resumeCommand === undefined ? {} : { resumeCommand: config.resumeCommand },
|
||||
sessionId,
|
||||
})
|
||||
ctx.plugin(agentCore, {
|
||||
|
||||
98
packages/examples/tui-demo/tests/built-bin.e2e.ts
Normal file
98
packages/examples/tui-demo/tests/built-bin.e2e.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, rm, symlink, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
|
||||
* The TUI app owns no non-TTY fallback, so the piped subprocess must refuse to boot with a
|
||||
* nonzero exit and a stderr pointer at the one-shot CLI — the bin guards BEFORE the Loader
|
||||
* because a compose-time throw inside the tree is logged per-entry, not rethrown. The consumer
|
||||
* links only the bin's import chain (dsh-app-boot and its vendored Loader stack): the refusal
|
||||
* fires before any config is read, so no plugin tree is needed. Missing-config fail-loud and
|
||||
* full-boot coverage for the shared dsh-app-boot glue live in cli-demo's built-bin suite; it
|
||||
* skips before build, and interactive TTY behavior is PTY-covered by examples/tui-agent (the
|
||||
* one sanctioned PTY surface).
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const tuiBin = join(repoRoot, 'packages/examples/tui-demo/lib/bin.js')
|
||||
|
||||
// Symlink each package the bin imports at module load by package name so plain
|
||||
// Node resolves its built `main`, matching an installed dependency rather than
|
||||
// tsconfig paths.
|
||||
const dshPackages = ['examples/tui-demo', 'ui/app-boot']
|
||||
const vendorPackages = ['cordis', 'loader', 'include', 'schemastery', 'cosmokit']
|
||||
|
||||
async function pkgName(absDir: string): Promise<string> {
|
||||
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
|
||||
return json.name
|
||||
}
|
||||
|
||||
/** Build a temporary external consumer with built workspace/vendor links. */
|
||||
async function makeConsumer(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'tui-built-bin-'))
|
||||
const nm = join(dir, 'node_modules')
|
||||
for (const rel of dshPackages) {
|
||||
const abs = join(repoRoot, 'packages', rel)
|
||||
const target = join(nm, await pkgName(abs))
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await symlink(abs, target)
|
||||
}
|
||||
for (const v of vendorPackages) {
|
||||
const abs = join(repoRoot, 'vendor', v)
|
||||
const target = join(nm, await pkgName(abs))
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await symlink(abs, target)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
/** Run the built bin in `cwd` with PIPED stdio; resolve with output + exit code. */
|
||||
function runBuiltBin(cwd: string): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// NO tsx — this is the published `node lib/bin.js` path (`--expose-internals`
|
||||
// matches the demo command; the guard fires before the Loader needs it).
|
||||
const child = spawn(process.execPath, ['--expose-internals', tuiBin, './cordis.yml'], {
|
||||
cwd,
|
||||
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (c: string) => { stdout += c })
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (c: string) => { stderr += c })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, 25_000)
|
||||
child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
|
||||
child.on('error', (err) => { clearTimeout(timer); reject(err) })
|
||||
child.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
let consumer: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
// Windows can briefly retain released handles after exit; retry removal.
|
||||
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
consumer = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!existsSync(tuiBin))('dsh-tui-demo BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
|
||||
consumer = await makeConsumer()
|
||||
const { stdout, code, stderr } = await runBuiltBin(consumer)
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
|
||||
expect(stderr).toContain('dsh-cli-demo')
|
||||
// The refusal happens before any plugin mounts: stdout stays silent.
|
||||
expect(stdout).toBe('')
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -38,6 +38,7 @@ describe('dsh-tui-demo app', () => {
|
||||
maxReferenceBytes: 1234,
|
||||
},
|
||||
welcome: 'TUI ready',
|
||||
resumeCommand: 'dsh --resume {session}',
|
||||
ui: { color: false, maxToolOutputLines: 3 },
|
||||
skills: { tool: { catalogDescriptionMaxLength: 8 } },
|
||||
toolBash: { enableRunInBackground: false },
|
||||
@@ -64,7 +65,12 @@ describe('dsh-tui-demo app', () => {
|
||||
maxReferenceBytes: 1234,
|
||||
})
|
||||
const tuiConfig = calls[6]?.config as { sessionId: string }
|
||||
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
|
||||
expect(tuiConfig).toMatchObject({
|
||||
welcome: 'TUI ready',
|
||||
resumeCommand: 'dsh --resume {session}',
|
||||
color: false,
|
||||
maxToolOutputLines: 3,
|
||||
})
|
||||
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
|
||||
const spineConfig = calls[7]?.config as {
|
||||
readonly agents: Array<Record<string, unknown>>
|
||||
@@ -101,7 +107,8 @@ describe('dsh-tui-demo app', () => {
|
||||
|
||||
expect(calls[2]?.config).toEqual({ root: './.sessions' })
|
||||
expect(calls[4]?.config).toEqual({})
|
||||
expect(calls[6]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
|
||||
// No configured welcome forwards none: the TUI banner sweeps in without a subtitle.
|
||||
expect(calls[6]?.config).toEqual({ sessionId: 'persisted-session' })
|
||||
expect((calls[7]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
|
||||
id: 'main',
|
||||
resumeSessionId: 'persisted-session',
|
||||
|
||||
Reference in New Issue
Block a user