Merge commit 'refs/codex-unblock/20260722-pr338-master' into HEAD
# Conflicts: # docs/event-producer-consumer.md # examples/acp-agent/tests/acp.snapshot.ts # packages/core/session/src/index.ts # packages/examples/acp-demo/src/index.ts # packages/session-persistence/session-persistence-jsonl/src/format.ts # vitest.config.ts
This commit is contained in:
@@ -15,13 +15,14 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
|
||||
| `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it |
|
||||
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
|
||||
| `@deepseek-ai/dsh-session-checkpoint-policy` | semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
|
||||
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
|
||||
| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately |
|
||||
| ~~`@deepseek-ai/dsh-user-approval`~~ | **omitted by default** — permission policy is deployment-specific; sandbox/approval leaves opt in and the ACP bridge then supplies the answerer |
|
||||
| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../../ui/acp/README.md)) |
|
||||
| ~~`hmr`~~ | **omitted** — the editor owns the subprocess |
|
||||
|
||||
Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead.
|
||||
The app owns this cluster through one ordered Cordis effect. Teardown drains the ACP bridge before removing the checkpoint policy or persistence backend, so a graceful disconnect persists the real closing `step/end` and `turn/end` events rather than leaving crash recovery to synthesize them. Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead.
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
|
||||
"@deepseek-ai/dsh-app-boot": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
@@ -60,6 +61,7 @@
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}),
|
||||
* human-command registry, JSONL session persistence, and the
|
||||
* {@link @deepseek-ai/dsh-acp} bridge. It writes nothing to stdout.
|
||||
* {@link @deepseek-ai/dsh-acp} bridge. The app owns those plugins through one
|
||||
* ordered lifecycle so ACP sessions quiesce before persistence detaches. It
|
||||
* writes nothing to stdout.
|
||||
* It pre-creates no agents and leaves adapters, executors, and optional tools to
|
||||
* the leaf, which must likewise avoid stdout loggers. Named exports are
|
||||
* required so Loader retains this plugin's `Config` schema (see
|
||||
@@ -21,6 +23,7 @@ import SessionPersistenceJsonl, {
|
||||
JsonlCompressionSchema,
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'acp-demo'
|
||||
@@ -104,22 +107,27 @@ export const Config: z<Config> = z.object({
|
||||
* NO agents (its `agents` list defaults to `[]`) and carries the deployment
|
||||
* `persona`; the JSONL backend persists under `persistenceRoot`; the ACP
|
||||
* bridge owns stdout for JSON-RPC and creates one agent per `session/new`
|
||||
* from the provider/model pair. No logger, no `hmr` — stdout stays pure.
|
||||
* from the provider/model pair. The composite effect unloads in reverse order,
|
||||
* keeping checkpoint and persistence listeners attached until ACP agents have
|
||||
* flushed their closing events. No logger, no `hmr` — stdout stays pure.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const goals = config.goals ?? {}
|
||||
ctx.plugin(CommandService)
|
||||
if (goals !== false) ctx.plugin(commandGoal)
|
||||
ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals })
|
||||
ctx.plugin(UserInteractionService)
|
||||
// Same rationale as the Config schema above: each front door forwards its own
|
||||
// persistence passthroughs rather than sharing a facade with stdio-demo.
|
||||
/* jscpd:ignore-start */
|
||||
ctx.plugin(SessionPersistenceJsonl, {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...config.packChunks !== undefined ? { packChunks: config.packChunks } : {},
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
ctx.plugin(acp, { provider: config.provider, model: config.model })
|
||||
ctx.effect(function* () {
|
||||
yield ctx.plugin(CommandService).dispose
|
||||
if (goals !== false) yield ctx.plugin(commandGoal).dispose
|
||||
yield ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }).dispose
|
||||
yield ctx.plugin(UserInteractionService).dispose
|
||||
// Same rationale as the Config schema above: each front door forwards its own
|
||||
// persistence passthroughs rather than sharing a facade with stdio-demo.
|
||||
/* jscpd:ignore-start */
|
||||
yield ctx.plugin(SessionPersistenceJsonl, {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...config.packChunks !== undefined ? { packChunks: config.packChunks } : {},
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
}).dispose
|
||||
/* jscpd:ignore-end */
|
||||
yield ctx.plugin(sessionCheckpointPolicy).dispose
|
||||
yield ctx.plugin(acp, { provider: config.provider, model: config.model }).dispose
|
||||
}, 'acp-demo.composition')
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ const dshPackages = [
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
|
||||
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', 'util/paths',
|
||||
'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl',
|
||||
'ui/acp', 'examples/acp-demo', 'util/paths',
|
||||
]
|
||||
const vendorPackages = [
|
||||
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
{
|
||||
"path": "../../ui/tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-checkpoint-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
|
||||
@@ -55,13 +55,18 @@
|
||||
"@cordisjs/plugin-timer": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
@@ -70,11 +75,13 @@
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"node-addon-landlock-run": "0.0.0-test.0",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import * as agentSpine from '../src/index.ts'
|
||||
|
||||
const bwrapUsable = spawnSync('bwrap', [
|
||||
'--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true',
|
||||
], { timeout: 5_000, stdio: 'ignore' }).status === 0
|
||||
const landlockUsable = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, stdio: 'ignore' }).status === 0
|
||||
const seatbeltUsable = process.platform === 'darwin'
|
||||
&& spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'workspace-write', workspaceRoot: homedir() }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }).status === 0
|
||||
const processSandboxUsable = bwrapUsable || landlockUsable || seatbeltUsable
|
||||
|
||||
let ctx: Context | undefined
|
||||
let projectA: string
|
||||
let projectB: string
|
||||
const tempDirs: string[] = []
|
||||
|
||||
async function projectDir(label: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(homedir(), `dsh-${label}-`))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function expectMissing(path: string): Promise<void> {
|
||||
await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
}
|
||||
|
||||
function resultText(result: ToolResult): string {
|
||||
return result.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
projectA = await projectDir('project-a')
|
||||
projectB = await projectDir('project-b')
|
||||
const fallbackRoot = await projectDir('fallback')
|
||||
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot })
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 })
|
||||
await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot })
|
||||
await ctx.plugin(agentSpine, {
|
||||
workspaceContext: false,
|
||||
skills: { enabled: false },
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: false,
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function agents() {
|
||||
const active = ctx as Context
|
||||
const [a, b] = await Promise.all([
|
||||
active.agents.create({ sessionId: SessionId('project-a-session'), meta: { cwd: projectA } }),
|
||||
active.agents.create({ sessionId: SessionId('project-b-session'), meta: { cwd: projectB } }),
|
||||
])
|
||||
return { active, agentA: a.agent, agentB: b.agent }
|
||||
}
|
||||
|
||||
describe('one-context multi-project sandbox', () => {
|
||||
it.skipIf(!processSandboxUsable)('confines concurrent bash calls to each calling session workspace', async () => {
|
||||
const { active, agentA, agentB } = await agents()
|
||||
const [aOwn, bOwn, aCross, bCross] = await Promise.all([
|
||||
active.tools.execute({
|
||||
callId: CallId('bash-a-own'), name: 'bash', agent: agentA,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { command: 'printf a > a-owned.txt', description: 'Write project A marker' },
|
||||
}),
|
||||
active.tools.execute({
|
||||
callId: CallId('bash-b-own'), name: 'bash', agent: agentB,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { command: 'printf b > b-owned.txt', description: 'Write project B marker' },
|
||||
}),
|
||||
active.tools.execute({
|
||||
callId: CallId('bash-a-cross'), name: 'bash', agent: agentA,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { command: `printf cross > ../${basename(projectB)}/from-a.txt`, description: 'Attempt project B write' },
|
||||
}),
|
||||
active.tools.execute({
|
||||
callId: CallId('bash-b-cross'), name: 'bash', agent: agentB,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { command: `printf cross > ../${basename(projectA)}/from-b.txt`, description: 'Attempt project A write' },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(aOwn.isError).toBe(false)
|
||||
expect(bOwn.isError).toBe(false)
|
||||
expect(aCross.isError).toBe(false)
|
||||
expect(bCross.isError).toBe(false)
|
||||
expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]')
|
||||
expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]')
|
||||
expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a')
|
||||
expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b')
|
||||
await expectMissing(join(projectB, 'from-a.txt'))
|
||||
await expectMissing(join(projectA, 'from-b.txt'))
|
||||
})
|
||||
|
||||
it('confines concurrent filesystem writes to each calling session workspace', async () => {
|
||||
const { active, agentA, agentB } = await agents()
|
||||
const [aOwn, bOwn, aCross, bCross] = await Promise.all([
|
||||
active.tools.execute({
|
||||
callId: CallId('fs-a-own'), name: 'write', agent: agentA,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { file_path: 'a-owned.txt', content: 'a' },
|
||||
}),
|
||||
active.tools.execute({
|
||||
callId: CallId('fs-b-own'), name: 'write', agent: agentB,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { file_path: 'b-owned.txt', content: 'b' },
|
||||
}),
|
||||
active.tools.execute({
|
||||
callId: CallId('fs-a-cross'), name: 'write', agent: agentA,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { file_path: join(projectB, 'from-a.txt'), content: 'cross' },
|
||||
}),
|
||||
active.tools.execute({
|
||||
callId: CallId('fs-b-cross'), name: 'write', agent: agentB,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { file_path: join(projectA, 'from-b.txt'), content: 'cross' },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(aOwn.isError).toBe(false)
|
||||
expect(bOwn.isError).toBe(false)
|
||||
expect(aCross.isError).toBe(true)
|
||||
expect(bCross.isError).toBe(true)
|
||||
expect(resultText(aCross)).toContain('[sandbox: file access denied under workspace-write mode]')
|
||||
expect(resultText(bCross)).toContain('[sandbox: file access denied under workspace-write mode]')
|
||||
expect(await readFile(join(projectA, 'a-owned.txt'), 'utf8')).toBe('a')
|
||||
expect(await readFile(join(projectB, 'b-owned.txt'), 'utf8')).toBe('b')
|
||||
await expectMissing(join(projectB, 'from-a.txt'))
|
||||
await expectMissing(join(projectA, 'from-b.txt'))
|
||||
})
|
||||
|
||||
it.skipIf(!processSandboxUsable)('keeps symlink-sensitive session cwd semantics aligned across bash, fs, and policy', async () => {
|
||||
const active = ctx as Context
|
||||
const lexicalRoot = await projectDir('lexical-workspace')
|
||||
const physicalRoot = await projectDir('physical-workspace')
|
||||
const physicalChild = join(physicalRoot, 'child')
|
||||
await mkdir(physicalChild)
|
||||
const link = join(lexicalRoot, 'link')
|
||||
await symlink(physicalChild, link, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const sessionCwd = `${link}/..`
|
||||
const handle = await active.agents.create({
|
||||
sessionId: SessionId('symlink-parent-session'),
|
||||
meta: { cwd: sessionCwd },
|
||||
})
|
||||
|
||||
const [bashOwn, bashLexical, fsOwn, fsLexical] = await Promise.all([
|
||||
active.tools.execute({
|
||||
callId: CallId('bash-symlink-own'), name: 'bash', agent: handle.agent,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { command: 'printf bash > bash-owned.txt', description: 'Write physical workspace marker' },
|
||||
}),
|
||||
active.tools.execute({
|
||||
callId: CallId('bash-symlink-lexical'), name: 'bash', agent: handle.agent,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { command: `printf escaped > ${join(lexicalRoot, 'bash-escaped.txt')}`, description: 'Attempt lexical workspace write' },
|
||||
}),
|
||||
active.tools.execute({
|
||||
callId: CallId('fs-symlink-own'), name: 'write', agent: handle.agent,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { file_path: 'fs-owned.txt', content: 'fs' },
|
||||
}),
|
||||
active.tools.execute({
|
||||
callId: CallId('fs-symlink-lexical'), name: 'write', agent: handle.agent,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { file_path: join(lexicalRoot, 'fs-escaped.txt'), content: 'escaped' },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(bashOwn.isError).toBe(false)
|
||||
expect(resultText(bashOwn)).not.toContain('[sandbox:')
|
||||
expect(bashLexical.isError).toBe(false)
|
||||
expect(resultText(bashLexical)).toContain('[sandbox: file access denied under workspace-write mode]')
|
||||
expect(fsOwn.isError).toBe(false)
|
||||
expect(fsLexical.isError).toBe(true)
|
||||
expect(resultText(fsLexical)).toContain('[sandbox: file access denied under workspace-write mode]')
|
||||
expect(await readFile(join(physicalRoot, 'bash-owned.txt'), 'utf8')).toBe('bash')
|
||||
expect(await readFile(join(physicalRoot, 'fs-owned.txt'), 'utf8')).toBe('fs')
|
||||
await expectMissing(join(lexicalRoot, 'bash-escaped.txt'))
|
||||
await expectMissing(join(lexicalRoot, 'fs-escaped.txt'))
|
||||
})
|
||||
|
||||
it.skipIf(!processSandboxUsable)('resolves parent traversal from a symlinked session root consistently', async () => {
|
||||
const active = ctx as Context
|
||||
const lexicalRoot = await projectDir('lexical-parent')
|
||||
const physicalRoot = await projectDir('physical-parent')
|
||||
const physicalChild = join(physicalRoot, 'child')
|
||||
await mkdir(physicalChild)
|
||||
const link = join(lexicalRoot, 'link')
|
||||
await symlink(physicalChild, link, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
await writeFile(join(lexicalRoot, 'shared.txt'), 'from-lexical-parent')
|
||||
await writeFile(join(physicalRoot, 'shared.txt'), 'from-physical-parent')
|
||||
const handle = await active.agents.create({
|
||||
sessionId: SessionId('symlink-root-parent-path-session'),
|
||||
meta: { cwd: link },
|
||||
})
|
||||
|
||||
const [bashRead, fsRead] = await Promise.all([
|
||||
active.tools.execute({
|
||||
callId: CallId('bash-symlink-parent-read'), name: 'bash', agent: handle.agent,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { command: 'cat ../shared.txt', description: 'Read through the physical parent' },
|
||||
}),
|
||||
active.tools.execute({
|
||||
callId: CallId('fs-symlink-parent-read'), name: 'read', agent: handle.agent,
|
||||
signal: new AbortController().signal,
|
||||
arguments: { file_path: '../shared.txt' },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(bashRead.isError).toBe(false)
|
||||
expect(fsRead.isError).toBe(false)
|
||||
expect(resultText(bashRead)).toContain('from-physical-parent')
|
||||
expect(resultText(fsRead)).toContain('from-physical-parent')
|
||||
expect(resultText(bashRead)).not.toContain('from-lexical-parent')
|
||||
expect(resultText(fsRead)).not.toContain('from-lexical-parent')
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
@@ -58,6 +59,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -14,7 +14,7 @@ import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
const CLI_NAME = 'dsh-cli-demo'
|
||||
const DEFAULT_CONFIG_PATH = './cordis.yml'
|
||||
const OUTPUT_FORMATS = ['text', 'json', 'stream-json'] as const
|
||||
const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] <task>\n`
|
||||
const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] (-p <task> | <task>)\n`
|
||||
|
||||
/** Supported CLI output encodings. */
|
||||
export type OutputFormat = typeof OUTPUT_FORMATS[number]
|
||||
@@ -73,6 +73,7 @@ interface ParsedArguments {
|
||||
readonly config?: string
|
||||
readonly 'output-format'?: string
|
||||
readonly help?: boolean
|
||||
readonly prompt?: string
|
||||
}
|
||||
readonly positionals: string[]
|
||||
}
|
||||
@@ -129,6 +130,7 @@ export function parseCliArgs(args: readonly string[]): CliCommand {
|
||||
config: { type: 'string' },
|
||||
'output-format': { type: 'string' },
|
||||
help: { type: 'boolean' },
|
||||
prompt: { type: 'string', short: 'p' },
|
||||
},
|
||||
allowPositionals: true,
|
||||
strict: true,
|
||||
@@ -138,12 +140,16 @@ export function parseCliArgs(args: readonly string[]): CliCommand {
|
||||
}
|
||||
|
||||
if (parsed.values.help === true) return { kind: 'help' }
|
||||
if (parsed.positionals.length !== 1) {
|
||||
throw new CliArgumentError(`expected exactly one positional task, received ${parsed.positionals.length}`)
|
||||
const prompt = parsed.values.prompt
|
||||
if (prompt !== undefined && parsed.positionals.length > 0) {
|
||||
throw new CliArgumentError('-p/--prompt and a positional task are mutually exclusive')
|
||||
}
|
||||
// Cardinality was checked above, so index zero exists.
|
||||
if (prompt === undefined && parsed.positionals.length !== 1) {
|
||||
throw new CliArgumentError(`expected exactly one positional task or -p, received ${parsed.positionals.length} positional(s)`)
|
||||
}
|
||||
// Cardinality was checked above, so the fallback index zero exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const task = parsed.positionals[0]!
|
||||
const task = prompt ?? parsed.positionals[0]!
|
||||
if (task.trim().length === 0) throw new CliArgumentError('task must not be blank')
|
||||
|
||||
const requestedFormat = parsed.values['output-format'] ?? 'text'
|
||||
|
||||
@@ -15,6 +15,7 @@ import SessionPersistenceJsonl, {
|
||||
JsonlCompressionSchema,
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
|
||||
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
|
||||
@@ -94,4 +95,5 @@ export function apply(ctx: Context, config: Config): void {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
})
|
||||
ctx.plugin(sessionCheckpointPolicy)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ import { fileURLToPath } from 'node:url'
|
||||
import { zstdDecompress } from 'node:zlib'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
|
||||
* The consumer's mock model is an example-local TypeScript plugin (Node 22.19+ — the engines
|
||||
* floor — strips types natively, so plain `node` loads it), its config carries a `disabled:
|
||||
* true` unresolvable entry (the fail-loud entry-load guard must not mistake an intentionally
|
||||
* fiber-less entry for a failed import), and the optional spill pair loads from the consumer
|
||||
* install — so every passing boot proves all three alongside the CLI's own output contract.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js')
|
||||
const decompress = promisify(zstdDecompress)
|
||||
@@ -15,8 +24,10 @@ const dshPackages = [
|
||||
'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session',
|
||||
'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
|
||||
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl',
|
||||
'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy',
|
||||
'session-persistence/session-persistence-jsonl',
|
||||
'context/workspace-context',
|
||||
'spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention',
|
||||
]
|
||||
const vendorPackages = ['cordis', 'loader', 'include', 'timer', 'schemastery', 'cosmokit']
|
||||
|
||||
@@ -35,15 +46,18 @@ async function makeConsumer(): Promise<string> {
|
||||
const nodeModules = join(dir, 'node_modules')
|
||||
for (const rel of dshPackages) await linkPackage(join(repoRoot, 'packages', rel), nodeModules)
|
||||
for (const rel of vendorPackages) await linkPackage(join(repoRoot, 'vendor', rel), nodeModules)
|
||||
await writeFile(join(dir, 'mock-llm.mjs'), [
|
||||
"import { LlmAdapter } from '@deepseek-ai/dsh-llm'",
|
||||
await writeFile(join(dir, 'mock-llm.ts'), [
|
||||
// Real type annotations: this file exists to prove plain Node's type
|
||||
// stripping loads an example-local TS plugin from a built consumer.
|
||||
"import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'",
|
||||
"import type { Context } from 'cordis'",
|
||||
'class Mock extends LlmAdapter {',
|
||||
' async * stream(options) {',
|
||||
" const text = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''",
|
||||
' async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {',
|
||||
" const text: string = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''",
|
||||
" yield { type: 'block-start', index: 0, blockType: 'text' }",
|
||||
" if (text === 'hang') {",
|
||||
" yield { type: 'text-delta', index: 0, text: 'partial' }",
|
||||
' await new Promise((resolve, reject) => {',
|
||||
' await new Promise<never>((resolve, reject) => {',
|
||||
" const timer = setTimeout(() => reject(new Error('hang timeout')), 30000)",
|
||||
" const onAbort = () => { clearTimeout(timer); reject(new Error('aborted')) }",
|
||||
' if (options.signal.aborted) onAbort()',
|
||||
@@ -60,12 +74,12 @@ async function makeConsumer(): Promise<string> {
|
||||
'}',
|
||||
"export const name = 'built-cli-mock'",
|
||||
"export const inject = ['llm']",
|
||||
"export function apply(ctx) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }",
|
||||
"export function apply(ctx: Context) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }",
|
||||
'',
|
||||
].join('\n'))
|
||||
await writeFile(join(dir, 'cordis.yml'), [
|
||||
'- id: mock-llm',
|
||||
" name: './mock-llm.mjs'",
|
||||
" name: './mock-llm.ts'",
|
||||
'- id: bash',
|
||||
" name: '@deepseek-ai/dsh-bash-local'",
|
||||
'- id: cli-agent',
|
||||
@@ -76,6 +90,18 @@ async function makeConsumer(): Promise<string> {
|
||||
" persona: 'built CLI test'",
|
||||
" persistenceRoot: './.sessions'",
|
||||
' workspaceContext: false',
|
||||
'- id: spill-local',
|
||||
" name: '@deepseek-ai/dsh-spill-local'",
|
||||
'- id: spill-policy',
|
||||
" name: '@deepseek-ai/dsh-spill-policy'",
|
||||
' config:',
|
||||
' maxInlineBytes: 50000',
|
||||
// A `disabled: true` entry settles without a fiber by design; the fail-loud
|
||||
// entry-load guard must not mistake it for a failed import. The nonexistent
|
||||
// path makes that distinction observable while a clean run proves boot continued.
|
||||
'- id: off',
|
||||
" name: './does-not-exist.ts'",
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
return dir
|
||||
|
||||
@@ -162,15 +162,19 @@ describe('parseCliArgs', () => {
|
||||
kind: 'run', configPath: 'custom.yml', outputFormat: 'stream-json', task: 'do it',
|
||||
})
|
||||
expect(parseCliArgs(['--', '-task'])).toMatchObject({ task: '-task' })
|
||||
expect(parseCliArgs(['-p', 'flag task'])).toMatchObject({ task: 'flag task' })
|
||||
expect(parseCliArgs(['--prompt', 'long-flag task'])).toMatchObject({ task: 'long-flag task' })
|
||||
expect(parseCliArgs(['--help', 'ignored'])).toEqual({ kind: 'help' })
|
||||
})
|
||||
|
||||
it('rejects missing, blank, extra, invalid-format, and unsupported flags', () => {
|
||||
expect(() => parseCliArgs([])).toThrow('received 0')
|
||||
expect(() => parseCliArgs([' '])).toThrow('must not be blank')
|
||||
expect(() => parseCliArgs(['-p', ' '])).toThrow('must not be blank')
|
||||
expect(() => parseCliArgs(['one', 'two'])).toThrow('received 2')
|
||||
expect(() => parseCliArgs(['-p', 'task', 'positional'])).toThrow('mutually exclusive')
|
||||
expect(() => parseCliArgs(['--output-format', 'xml', 'task'])).toThrow('unsupported output format')
|
||||
expect(() => parseCliArgs(['-p', 'task'])).toThrow('Unknown option')
|
||||
expect(() => parseCliArgs(['-x', 'task'])).toThrow('Unknown option')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../agent-spine-demo"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-checkpoint-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tui": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
|
||||
@@ -67,6 +68,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -21,13 +21,13 @@ import SessionPersistenceJsonl, {
|
||||
JsonlCompressionSchema,
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as uiTui from '@deepseek-ai/dsh-tui'
|
||||
|
||||
export const name = 'tui-demo'
|
||||
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
|
||||
const DEFAULT_WELCOME = 'ready.'
|
||||
|
||||
/** App config routed to the spine, TUI, configured agent, and JSONL backend. */
|
||||
export interface Config {
|
||||
@@ -51,8 +51,15 @@ export interface Config {
|
||||
persistenceRoot?: string
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
persistenceCompression?: JsonlCompression
|
||||
/** 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. */
|
||||
@@ -84,7 +91,8 @@ export const Config: z<Config> = z.object({
|
||||
sessionTitle: agentCore.SessionTitleConfigSchema,
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
welcome: z.string().default(DEFAULT_WELCOME),
|
||||
welcome: z.string(),
|
||||
resumeCommand: z.string(),
|
||||
ui: uiTui.TuiConfigSchema,
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
@@ -112,10 +120,12 @@ export function composeTuiApp(ctx: Context, config: Config): void {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
})
|
||||
ctx.plugin(sessionCheckpointPolicy)
|
||||
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)
|
||||
})
|
||||
@@ -33,6 +33,7 @@ describe('dsh-tui-demo app', () => {
|
||||
persistenceRoot: '/tmp/tui-sessions',
|
||||
persistenceCompression: 'none',
|
||||
welcome: 'TUI ready',
|
||||
resumeCommand: 'dsh --resume {session}',
|
||||
ui: { color: false, maxToolOutputLines: 3 },
|
||||
skills: { tool: { catalogDescriptionMaxLength: 8 } },
|
||||
toolBash: { enableRunInBackground: false },
|
||||
@@ -44,6 +45,7 @@ describe('dsh-tui-demo app', () => {
|
||||
'CommandService',
|
||||
'command-goal',
|
||||
'SessionPersistenceJsonl',
|
||||
'session-checkpoint-policy',
|
||||
'UserInteractionService',
|
||||
'ui-tui',
|
||||
'agent-spine-demo',
|
||||
@@ -51,10 +53,15 @@ describe('dsh-tui-demo app', () => {
|
||||
])
|
||||
expect(calls[0]?.config).toBeUndefined()
|
||||
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
|
||||
const tuiConfig = calls[4]?.config as { sessionId: string }
|
||||
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
|
||||
const tuiConfig = calls[5]?.config as { sessionId: string }
|
||||
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[5]?.config as {
|
||||
const spineConfig = calls[6]?.config as {
|
||||
readonly agents: Array<Record<string, unknown>>
|
||||
readonly goals: Record<string, never>
|
||||
readonly maxParallelToolCalls: number
|
||||
@@ -88,8 +95,9 @@ describe('dsh-tui-demo app', () => {
|
||||
})
|
||||
|
||||
expect(calls[2]?.config).toEqual({ root: './.sessions' })
|
||||
expect(calls[4]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
|
||||
expect((calls[5]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
|
||||
// No configured welcome forwards none: the TUI banner sweeps in without a subtitle.
|
||||
expect(calls[5]?.config).toEqual({ sessionId: 'persisted-session' })
|
||||
expect((calls[6]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
|
||||
id: 'main',
|
||||
resumeSessionId: 'persisted-session',
|
||||
})
|
||||
@@ -105,12 +113,12 @@ describe('dsh-tui-demo app', () => {
|
||||
workspaceContext: false,
|
||||
})
|
||||
|
||||
const tuiConfig = calls[3]?.config as { sessionId: string }
|
||||
const tuiConfig = calls[4]?.config as { sessionId: string }
|
||||
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
|
||||
expect((calls[4]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
|
||||
expect((calls[5]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
|
||||
.toMatchObject({ sessionId: tuiConfig.sessionId })
|
||||
expect(calls.map(call => call.name)).not.toContain('command-goal')
|
||||
expect(calls[4]?.config).toMatchObject({ goals: false })
|
||||
expect(calls[5]?.config).toMatchObject({ goals: false })
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape so the Loader keeps its schema', () => {
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
{
|
||||
"path": "../../ui/tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-checkpoint-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user