Merge latest master into invariant service seam
# Conflicts: # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # docs/capability-seams.md # docs/event-producer-consumer.md # packages/examples/agent-spine-demo/README.md # packages/examples/stdio-demo/tests/built-bin.e2e.ts # scripts/gen-doc-graphs.ts
This commit is contained in:
@@ -31,7 +31,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
|
||||
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
// Keep the Loader config under examples so both modes exercise the same deployable
|
||||
// topology: local fixture source plus bare plugins owned by the examples workspace.
|
||||
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
|
||||
const driver = fileURLToPath(new URL(
|
||||
'../../../../examples/headless-agent/tests/fixtures/time-context-driver.ts',
|
||||
import.meta.url,
|
||||
))
|
||||
const configPath = fileURLToPath(new URL(
|
||||
'../../../../examples/echo-agent/tests/fixtures/context/time-context/cordis.yml',
|
||||
'../../../../examples/headless-agent/tests/fixtures/time-context.cordis.yml',
|
||||
import.meta.url,
|
||||
))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
const PROCESS_TIMEOUT_MS = 30_000
|
||||
const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000
|
||||
const FIRST_REPLY = '[main turn 1] You said: "Time sampled while preparing turn 1, step 1:'
|
||||
const SECOND_REPLY = '[main turn 2] You said: "Time sampled while preparing turn 2, step 1:'
|
||||
|
||||
let child: ChildProcessWithoutNullStreams | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (child !== undefined && child.exitCode === null) child.kill('SIGKILL')
|
||||
child = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
@@ -40,68 +27,25 @@ async function jsonlFiles(dir: string): Promise<string[]> {
|
||||
return paths.flat()
|
||||
}
|
||||
|
||||
async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'time-context-e2e-'))
|
||||
const cwd = workdir
|
||||
return new Promise((resolve, reject) => {
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: binScript,
|
||||
configArgs: [configPath],
|
||||
describe('time-context through a real headless cordis.yml', () => {
|
||||
it('uses the process zone and persists one ordered context event per request', async () => {
|
||||
let events: SessionEvent[] = []
|
||||
const { stderr } = await runLoaderSmoke({
|
||||
label: 'time-context headless smoke',
|
||||
tempDirPrefix: 'time-context-e2e-',
|
||||
binScript: driver,
|
||||
libBinScript: driver,
|
||||
configPath,
|
||||
tsconfigPath: repoTsconfig,
|
||||
exposeInternals: true,
|
||||
env: {
|
||||
TZ: 'Asia/Shanghai',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
env: { TZ: 'Asia/Shanghai' },
|
||||
inspect: async (cwd) => {
|
||||
const logs = await jsonlFiles(join(cwd, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
},
|
||||
})
|
||||
const proc = spawn(launch.command, launch.args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...launch.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let sentSecond = false
|
||||
proc.stdout.setEncoding('utf8')
|
||||
proc.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (!sentSecond && stdout.includes(FIRST_REPLY) && stdout.includes('Try "echo <something>" to see a tool call.\n> ')) {
|
||||
sentSecond = true
|
||||
proc.stdin.end('second\n')
|
||||
}
|
||||
})
|
||||
proc.stderr.setEncoding('utf8')
|
||||
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill('SIGKILL')
|
||||
reject(new Error(`time-context e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, PROCESS_TIMEOUT_MS)
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) resolve({ stdout, stderr })
|
||||
else reject(new Error(`time-context e2e exited ${code}. stdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
})
|
||||
proc.on('error', (error) => { clearTimeout(timer); reject(error) })
|
||||
proc.stdin.write('first\n')
|
||||
})
|
||||
}
|
||||
|
||||
describe('time-context through a real cordis.yml and stdio process', () => {
|
||||
it('uses the process zone and persists one ordered context event per request', async () => {
|
||||
const { stdout, stderr } = await runTwoTurns()
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(stdout).toContain('time-context e2e ready.')
|
||||
expect(stdout).toContain(FIRST_REPLY)
|
||||
expect(stdout).toContain(SECOND_REPLY)
|
||||
|
||||
const logs = await jsonlFiles(join(workdir as string, '.sessions'))
|
||||
expect(logs).toHaveLength(1)
|
||||
const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
|
||||
const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2)
|
||||
|
||||
const contexts = events.filter(event => event.type === 'context/message')
|
||||
@@ -127,5 +71,5 @@ describe('time-context through a real cordis.yml and stdio process', () => {
|
||||
|
||||
const headers = events.filter(event => event.type === 'request/header')
|
||||
expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing')
|
||||
}, TEST_TIMEOUT_MS)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import { sandboxDefineTool, sandboxRegisterTool } from './guard.ts'
|
||||
* A write-through console for one sandbox, tagging every line with the mount
|
||||
* id. Write-through (host stdout/stderr), NOT buffered into the tool result:
|
||||
* a mounted listener fires long after the mount call returned, and its output
|
||||
* must land somewhere the user can see — for the stdio demo, the terminal.
|
||||
* must land somewhere the user can see — for a terminal front door, the host terminal.
|
||||
*/
|
||||
function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | 'debug', (...args: unknown[]) => void> {
|
||||
const tag = `[cordis:${id}]`
|
||||
|
||||
@@ -47,9 +47,9 @@ describe('config-driven session id', () => {
|
||||
it('accepts one exact fresh id and rejects it alongside a resume id', async () => {
|
||||
const exact = await makeCoreContext()
|
||||
await exact.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('stdio-exact'), model: 'mock' }],
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact'), model: 'mock' }],
|
||||
})
|
||||
expect(exact.agents.get(SessionId('stdio-exact'))?.session.id).toBe('stdio-exact')
|
||||
expect(exact.agents.get(SessionId('config-exact'))?.session.id).toBe('config-exact')
|
||||
await exact.fiber.dispose()
|
||||
|
||||
const conflicting = await makeCoreContext()
|
||||
@@ -89,13 +89,13 @@ describe('config-driven session id', () => {
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')]))
|
||||
const config = { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-reload'), model: 'mock' }] }
|
||||
const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), model: 'mock' }] }
|
||||
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
let first: Agent | undefined
|
||||
for (let i = 0; i < 50 && first === undefined; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
first = ctx.agents.get(SessionId('stdio-exact-reload'))
|
||||
first = ctx.agents.get(SessionId('config-exact-reload'))
|
||||
}
|
||||
expect(first).toBeDefined()
|
||||
first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
@@ -106,14 +106,14 @@ describe('config-driven session id', () => {
|
||||
let second: Agent | undefined
|
||||
for (let i = 0; i < 50 && second === undefined; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
second = ctx.agents.get(SessionId('stdio-exact-reload'))
|
||||
second = ctx.agents.get(SessionId('config-exact-reload'))
|
||||
}
|
||||
expect(second).toBeDefined()
|
||||
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
|
||||
second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, second!)
|
||||
await ctx.sessions.flush(second!.session)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('stdio-exact-reload'))
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload'))
|
||||
expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
|
||||
await secondLoop.dispose()
|
||||
@@ -125,7 +125,7 @@ describe('config-driven session id', () => {
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const sessionId = SessionId('stdio-exact-overlap')
|
||||
const sessionId = SessionId('config-exact-overlap')
|
||||
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||
@@ -169,7 +169,7 @@ describe('config-driven session id', () => {
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const sessionId = SessionId('stdio-exact-cancel')
|
||||
const sessionId = SessionId('config-exact-cancel')
|
||||
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||
@@ -213,20 +213,20 @@ describe('config-driven session id', () => {
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('stdio-exact-failure'), model: 'mock' }],
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact-failure'), model: 'mock' }],
|
||||
})
|
||||
|
||||
await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining(
|
||||
'config-driven restore of "stdio-exact-failure" failed: persistence index failed',
|
||||
'config-driven restore of "config-exact-failure" failed: persistence index failed',
|
||||
))
|
||||
expect(failures).toEqual([{ sessionId: SessionId('stdio-exact-failure'), error: failure }])
|
||||
expect(failures).toEqual([{ sessionId: SessionId('config-exact-failure'), error: failure }])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener threw: failure observer failed',
|
||||
)
|
||||
await expect.poll(() => warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener rejected: async failure observer failed',
|
||||
)
|
||||
expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-exact-failure'))).toBeUndefined()
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -251,12 +251,12 @@ describe('config-driven session id', () => {
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('stdio-exact-unrenderable'), model: 'mock' }],
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact-unrenderable'), model: 'mock' }],
|
||||
})
|
||||
|
||||
await expect.poll(() => failures).toEqual([unrenderable])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-driven restore of "stdio-exact-unrenderable" failed: <unrenderable value>',
|
||||
'agent "main": config-driven restore of "config-exact-unrenderable" failed: <unrenderable value>',
|
||||
)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener threw: <unrenderable value>',
|
||||
@@ -281,7 +281,7 @@ describe('config-driven session id', () => {
|
||||
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
|
||||
|
||||
const loop = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }],
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }],
|
||||
})
|
||||
let disposed = false
|
||||
const disposal = loop.dispose().then(() => { disposed = true })
|
||||
@@ -291,7 +291,7 @@ describe('config-driven session id', () => {
|
||||
if (outcome === 'resolve') listing.resolve([])
|
||||
else listing.reject(new Error('startup cancelled by teardown'))
|
||||
await disposal
|
||||
expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined()
|
||||
expect(failures).toEqual([])
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
|
||||
@@ -5,12 +5,12 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
|
||||
| Package | npm name | Role |
|
||||
|---|---|---|
|
||||
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) |
|
||||
| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` |
|
||||
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
|
||||
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
|
||||
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
|
||||
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
|
||||
|
||||
`agent-spine-demo` is the shared bundle; `stdio-demo`, `cli-demo`, and `acp-demo` compose it with terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
|
||||
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
|
||||
|
||||
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)) with the front-door cluster an [Agent Client Protocol](../../ui/acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
|
||||
|
||||
It is the structured counterpart to [`@deepseek-ai/dsh-stdio-demo`](../stdio-demo/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster.
|
||||
It is the structured counterpart to [`@deepseek-ai/dsh-tui-demo`](../tui-demo/README.md): both consume the same spine, but ACP creates sessions from its client and reserves stdout for its wire protocol.
|
||||
|
||||
## What it bakes in — and what it deliberately omits
|
||||
|
||||
@@ -19,7 +19,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
|
||||
| ~~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, so the common mistake — copying a console-logger entry from the stdio config — has no place here. (A leaf author technically *can* still add `@cordisjs/plugin-logger-console` as a sibling entry; the package can't forbid that. So the rule stands: never add a stdout logger to an ACP leaf — stdout is the JSON-RPC channel. Use a stderr exporter if you need logs.)
|
||||
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
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import * as acpAgent from '../src/index.ts'
|
||||
/**
|
||||
* In-process unit coverage for the @deepseek-ai/dsh-acp-demo composition:
|
||||
* mounting it brings up the agent-spine-demo spine + JSONL persistence + the ACP
|
||||
* bridge in one `ctx.plugin`. Unlike the stdio app, this one loads NO
|
||||
* Loader-only plugin (no hmr), so it mounts in a plain Context.
|
||||
* bridge in one `ctx.plugin`. It loads no Loader-only plugin (no hmr), so it
|
||||
* mounts in a plain Context.
|
||||
*
|
||||
* The REAL Loader-path guard (export shape via `unwrapExports`, the headline
|
||||
* ACP operations end-to-end) is the keyless bin smoke in `load-path.e2e.ts`;
|
||||
|
||||
@@ -39,7 +39,7 @@ The spine is everything COMMON to every front door. The swappable and front-door
|
||||
- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
|
||||
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
|
||||
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
|
||||
- **presentation + per-app infra** — the terminal (`dsh-tui` / `dsh-stdio`) or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-stdio-demo`](../stdio-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
|
||||
- **presentation + per-app infra** — the terminal TUI or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
|
||||
|
||||
This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
|
||||
|
||||
@@ -51,7 +51,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
|
||||
```
|
||||
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service without exposing task-control tools. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
|
||||
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
|
||||
|
||||
For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# @deepseek-ai/dsh-cli-demo
|
||||
|
||||
Headless one-shot app and bin for running one agent task without a readline or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits.
|
||||
Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits.
|
||||
|
||||
The package mounts no console logger, readline UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr.
|
||||
The package mounts no console logger, interactive UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -33,7 +33,7 @@ dsh-cli-demo [--config path] [--output-format text|json|stream-json] <task>
|
||||
The root headless-agent example supplies its leaf:
|
||||
|
||||
```sh
|
||||
pnpm run demo:headless -- "inspect the failing test and fix it"
|
||||
pnpm run demo:headless "inspect the failing test and fix it"
|
||||
```
|
||||
|
||||
Loader configs with bare package specifiers require `node --expose-internals` or the Loader's optional native fallback. The root command supplies the Node flag.
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# @deepseek-ai/dsh-stdio-demo
|
||||
|
||||
The **terminal chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)) with JSONL persistence, human interaction, a pre-created `main` agent, and a TTY-selected pi-tui/readline front door. Its `bin` boots a leaf `cordis.yml`.
|
||||
|
||||
It is the terminal counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, while ACP reserves stdout for JSON-RPC and creates sessions from the client.
|
||||
|
||||
## What it bakes in
|
||||
|
||||
A terminal chat always wants the same cluster, so the package owns it rather than trusting each leaf to re-wire it:
|
||||
|
||||
| Plugin | Why it is here |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
|
||||
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
|
||||
| `@cordisjs/plugin-logger-console` | readline diagnostics for non-TTY operation; omitted from the fullscreen TUI path |
|
||||
| `@deepseek-ai/dsh-stdio` | the line-oriented channel for pipes and automation, bound to the exact app-owned agent/session identity |
|
||||
| `@deepseek-ai/dsh-tui` | the fullscreen interactive channel for TTY pairs, bound to the same exact identity |
|
||||
|
||||
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
|
||||
|
||||
The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-spine-demo`, `hmr`, and the two leaf backends.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Routed to |
|
||||
|---|---|---|
|
||||
| `provider` | (required) | the pre-created `main` agent's registered provider route |
|
||||
| `model` | (required) | the pre-created `main` agent's model |
|
||||
| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial |
|
||||
| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
|
||||
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
|
||||
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
|
||||
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
|
||||
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
|
||||
| `welcome` | `ready.` | terminal banner / TUI subtitle |
|
||||
| `ui` | `{ mode: 'auto' }` | terminal mode (`auto` / `readline` / `tui`) and nested TUI presentation config |
|
||||
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
||||
|
||||
Fresh terminal sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-<uuid>` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to the config-created agent and selected UI before agent-core starts; this lets either front door observe `agent-loop/config-start-failed`, and an AgentLoop-only reload restores materialized history under the same id. Readline buffers startup input until `agent/session-start`; the TUI waits to enter fullscreen until the matching root appears. A resumed run binds both components to the exact `resumeSessionId` and keeps the persisted cwd.
|
||||
|
||||
## The bin
|
||||
|
||||
`dsh-stdio-demo [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`, or install the Loader's optional `node-addon-require-builtin` fallback, so the Loader can resolve the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages). The `demo:echo` / `demo:repl` scripts use `--expose-internals`.
|
||||
|
||||
## Example leaf `cordis.yml`
|
||||
|
||||
```yaml
|
||||
# A REPL agent demo: hmr + the DeepSeek adapter + local bash, then this app.
|
||||
- id: hmr
|
||||
name: '@cordisjs/plugin-hmr'
|
||||
config:
|
||||
root: ['.']
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
timeoutMs: 60000
|
||||
- id: stdio-agent
|
||||
name: '@deepseek-ai/dsh-stdio-demo'
|
||||
config:
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
persona: 'You are a coding assistant powered by the {{model}} model.'
|
||||
ui:
|
||||
mode: auto
|
||||
```
|
||||
|
||||
Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app".
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Composed terminal agent request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Child prompt and schema costs repeat per request; user input and tool history grow until compaction. Terminal banners, logger output, cards, and rendered transcripts add zero model tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
User and tool history is append-only while the composed prompt, schemas, child model route, and session prefix remain fixed. A composition change or compaction may invalidate reuse from its first changed token; terminal rendering has no cache effect.
|
||||
|
||||
### Human-answer result
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Through `dsh-tool-ask-user`, successful terminal answers use that package's exact compact JSON shape. Interruption becomes exactly `Error: ask_user_question was interrupted before the user answered`; a closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Only a completed or failed tool call adds retained result tokens; prompts printed while waiting are terminal-only.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One pre-created `main` agent drives the selected terminal UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation.
|
||||
- **The front-door cluster is fixed in code** — the JSONL persistence backend and the ask-user tooling are baked; a different composition is a leaf-level sibling entry or another app package.
|
||||
- **The question tool is not an approval answerer** — this app mounts `user-interaction` and `ask_user_question`, but not `ctx.approval`; a `tools/pre-execute` `ask` therefore fails closed unless the leaf composes an approval service and terminal answerer.
|
||||
@@ -1,190 +0,0 @@
|
||||
/**
|
||||
* The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the
|
||||
* coupled front-door cluster a terminal chat needs — TTY-selected pi-tui/readline
|
||||
* presentation, JSONL session persistence, the user-interaction seam with its
|
||||
* `ask_user_question` tool, and one pre-created agent whose exact shared
|
||||
* agent/session identity the selected UI drives under its `main` display label.
|
||||
* Swappable adapters, executors, optional tools, and HMR stay in the leaf. This
|
||||
* Loader plugin intentionally exposes named exports only; a default export
|
||||
* would hide its `Config` schema (see docs/postmortem/0001).
|
||||
* @module @deepseek-ai/dsh-stdio-demo
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import ConsoleExporter from '@cordisjs/plugin-logger-console'
|
||||
import z from 'schemastery'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import SessionPersistenceJsonl, {
|
||||
JsonlCompressionSchema,
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as uiStdio from '@deepseek-ai/dsh-stdio'
|
||||
import * as uiTui from '@deepseek-ai/dsh-tui'
|
||||
|
||||
export const name = 'stdio-demo'
|
||||
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
|
||||
const DEFAULT_WELCOME = 'ready.'
|
||||
|
||||
/** Terminal front door selected by the app bundle. */
|
||||
export type TerminalMode = 'auto' | 'readline' | 'tui'
|
||||
|
||||
/** App-level terminal selection with nested TUI presentation settings. */
|
||||
export interface UiConfig {
|
||||
/** Select a concrete front door or infer it from the process streams. */
|
||||
mode?: TerminalMode
|
||||
/** Settings forwarded only when the pi-tui front door is selected. */
|
||||
tui?: uiTui.TuiConfig
|
||||
}
|
||||
|
||||
const terminalModeSchema = z.union(['auto', 'readline', 'tui'] as const).default('auto')
|
||||
|
||||
/** Schemastery schema for app-level terminal selection. */
|
||||
export const UiConfigSchema: z<UiConfig> = z.object({
|
||||
mode: terminalModeSchema,
|
||||
tui: uiTui.TuiConfigSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve the app's terminal front door.
|
||||
* @param config - app-level terminal selection.
|
||||
* @param isTTY - whether both process streams are interactive TTYs.
|
||||
* @returns the concrete UI package to mount.
|
||||
*/
|
||||
export function resolveTerminalMode(config: UiConfig | undefined, isTTY: boolean): Exclude<TerminalMode, 'auto'> {
|
||||
const mode = config?.mode ?? 'auto'
|
||||
if (mode === 'auto') return isTTY ? 'tui' : 'readline'
|
||||
if (mode === 'tui' && !isTTY) {
|
||||
throw new Error('stdio-demo: TUI mode requires both stdin and stdout to be TTYs; use mode "readline" for pipes')
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
/**
|
||||
* App config: the swappable per-demo values, each routed to where the app wires
|
||||
* it. `provider`/`model`/`resumeSessionId` configure the pre-created `main` agent (through
|
||||
* {@link @deepseek-ai/dsh-agent-spine-demo}'s forwarded `agents` list); `persona` is
|
||||
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
|
||||
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||
* fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions
|
||||
* keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory;
|
||||
* `welcome` is the UI banner and `ui` configures terminal mode/presentation.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Provider route for the `main` agent. */
|
||||
provider: string
|
||||
/** Model name for the `main` agent (must have a registered adapter). */
|
||||
model: string
|
||||
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
|
||||
maxParallelToolCalls?: number
|
||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
persistenceCompression?: JsonlCompression
|
||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||
welcome?: string
|
||||
/** Terminal front-door selection and pi-tui presentation settings. */
|
||||
ui?: UiConfig
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-core. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
/**
|
||||
* If set, the pre-created agent RESUMES this persisted session id instead of
|
||||
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
||||
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
|
||||
*/
|
||||
resumeSessionId?: string
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
|
||||
workspaceContext: agentCore.Config['workspaceContext']
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
maxParallelToolCalls: z.number().step(1).min(1),
|
||||
persona: z.string(),
|
||||
// The array default is forced to undefined: ABSENT means "lexicographic
|
||||
// order" (the owning dsh-system-prompt schema does the same), while
|
||||
// schemastery's native [] default would read as an invalid configured list.
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
dshHome: z.string(),
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
welcome: z.string().default(DEFAULT_WELCOME),
|
||||
ui: UiConfigSchema,
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
|
||||
resumeSessionId: z.string(),
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Compose the spine with one terminal front door. Persistence and user
|
||||
* interaction mount first; the selected UI then waits on the exact session id
|
||||
* and subscribes to config-start failures before agent-core starts it. Console
|
||||
* logging is readline-only because fullscreen output belongs to pi-tui. The
|
||||
* ask-user tool waits on the completed spine, and HMR remains a leaf concern.
|
||||
* @param ctx - context receiving the app's child plugins.
|
||||
* @param config - app configuration routed to the spine and front door.
|
||||
* @param isTTY - whether both process streams are interactive TTYs.
|
||||
*/
|
||||
export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean): void {
|
||||
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
|
||||
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
|
||||
const mode = resolveTerminalMode(config.ui, isTTY)
|
||||
if (mode === 'readline') ctx.plugin(ConsoleExporter)
|
||||
ctx.plugin(SessionPersistenceJsonl, {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
})
|
||||
ctx.plugin(UserInteractionService)
|
||||
if (mode === 'tui') {
|
||||
ctx.plugin(uiTui, {
|
||||
...config.ui?.tui,
|
||||
welcome: config.welcome ?? DEFAULT_WELCOME,
|
||||
sessionId,
|
||||
})
|
||||
} else {
|
||||
ctx.plugin(uiStdio, {
|
||||
welcome: config.welcome ?? DEFAULT_WELCOME,
|
||||
sessionId,
|
||||
})
|
||||
}
|
||||
ctx.plugin(agentCore, {
|
||||
...agentCore.pickSpineConfig(config),
|
||||
agents: [{
|
||||
id: SessionId('main'),
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
cwd: process.cwd(),
|
||||
...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId },
|
||||
}],
|
||||
})
|
||||
ctx.plugin(toolAskUser)
|
||||
}
|
||||
|
||||
/** Compose the configured terminal front door with the agent app. */
|
||||
/* v8 ignore start -- production stream capability wiring; composeTerminalApp is unit-covered,
|
||||
and the repl-agent PTY smoke covers the interactive process path */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
composeTerminalApp(ctx, config, process.stdin.isTTY && process.stdout.isTTY)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
@@ -1,227 +0,0 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { cp, mkdtemp, mkdir, readdir, rm, symlink, writeFile, readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
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 and
|
||||
* require the banner plus echo round-trip. This catches built-only early-exit and config-resolution
|
||||
* failures masked by tsx source smokes. It skips before build; `--expose-internals` enables Cordis
|
||||
* bare-plugin loading, matching the demo command.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const stdioBin = join(repoRoot, 'packages/examples/stdio-demo/lib/bin.js')
|
||||
const decompress = promisify(zstdDecompress)
|
||||
|
||||
// Symlink each required workspace package by package name so plain Node resolves its built `main`,
|
||||
// matching an installed dependency rather than tsconfig paths.
|
||||
const dshPackages = [
|
||||
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/scope', 'core/system-prompt',
|
||||
'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', 'examples/stdio-demo', 'util/paths',
|
||||
'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction',
|
||||
]
|
||||
const vendorPackages = [
|
||||
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
|
||||
'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
|
||||
}
|
||||
|
||||
async function installWorkspacePackageCopy(absDir: string, target: string): Promise<void> {
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await cp(absDir, target, {
|
||||
recursive: true,
|
||||
filter: source => !source.split('/').includes('node_modules'),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a temporary external consumer with built workspace/vendor links and a mock-backed config.
|
||||
* The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less
|
||||
* entries rather than treating them as import failures.
|
||||
*/
|
||||
async function makeConsumer(
|
||||
welcome: string,
|
||||
disabledBrokenEntry = false,
|
||||
extraDshPackages: string[] = [],
|
||||
extraEntries: string[] = [],
|
||||
): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-'))
|
||||
const nm = join(dir, 'node_modules')
|
||||
for (const rel of [...dshPackages, ...extraDshPackages]) {
|
||||
const abs = join(repoRoot, 'packages', rel)
|
||||
const name = await pkgName(abs)
|
||||
const target = join(nm, name)
|
||||
if (extraDshPackages.includes(rel)) {
|
||||
await installWorkspacePackageCopy(abs, target)
|
||||
} else {
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await symlink(abs, target)
|
||||
}
|
||||
}
|
||||
for (const v of vendorPackages) {
|
||||
const abs = join(repoRoot, 'vendor', v)
|
||||
const name = await pkgName(abs)
|
||||
const target = join(nm, name)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await symlink(abs, target)
|
||||
}
|
||||
// The example's mock model + echo tool are example-local TS plugins (Node
|
||||
// 22.19+ — the engines floor — strips types natively, so plain `node` loads
|
||||
// them); they import the workspace packages the symlinked node_modules now
|
||||
// provides.
|
||||
await cp(join(repoRoot, 'examples/echo-agent/src'), join(dir, 'src'), { recursive: true })
|
||||
await writeFile(join(dir, 'cordis.yml'), [
|
||||
'- id: mock-llm',
|
||||
' name: \'./src/mock-llm.ts\'',
|
||||
'- id: echo-tool',
|
||||
' name: \'./src/echo-tool.ts\'',
|
||||
'- id: bash',
|
||||
' name: \'@deepseek-ai/dsh-bash-local\'',
|
||||
'- id: stdio-agent',
|
||||
' name: \'@deepseek-ai/dsh-stdio-demo\'',
|
||||
' config:',
|
||||
' provider: mock',
|
||||
' model: mock-echo',
|
||||
' persona: \'demo\'',
|
||||
' workspaceContext: false',
|
||||
` welcome: '${welcome}'`,
|
||||
...extraEntries,
|
||||
...disabledBrokenEntry
|
||||
? ['- id: off', ' name: \'./src/does-not-exist.ts\'', ' disabled: true']
|
||||
: [],
|
||||
'',
|
||||
].join('\n'))
|
||||
return dir
|
||||
}
|
||||
|
||||
/** Run the built bin in `cwd` against `configArg` with piped stdin; resolve with stdout/stderr + exit code. */
|
||||
function runBuiltBin(cwd: string, configArg: string, input: string): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// --expose-internals: the cordis Loader resolves bare plugin specifiers via
|
||||
// its internal module loader (active only under this flag); demo:echo passes
|
||||
// it too. NO tsx — this is the published `node lib/bin.js` path.
|
||||
const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
|
||||
cwd,
|
||||
// Mock model: never calls the network, so no key needed.
|
||||
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.write(`${input}\n`)
|
||||
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(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('boots the published bin, prints its banner, and runs the echo tool round-trip', async () => {
|
||||
consumer = await makeConsumer('BUILT-BIN-OK ready.')
|
||||
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(stderr).not.toContain('without inject')
|
||||
// The banner proves boot() awaited the tree (the settle-race regression would
|
||||
// exit 0 with empty stdout); the round-trip proves the whole app mounted.
|
||||
expect(stdout).toContain('BUILT-BIN-OK ready.')
|
||||
expect(stdout).toContain('[tool call] echo')
|
||||
expect(stdout).toContain('[tool result] ECHO: HI')
|
||||
expect(code).toBe(0)
|
||||
const files = await readdir(join(consumer, '.sessions'), { recursive: true })
|
||||
const log = files.find(file => file.endsWith('.jsonl.zstd'))
|
||||
expect(log).toBeDefined()
|
||||
const compressed = await readFile(join(consumer, '.sessions', log!))
|
||||
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
|
||||
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session' })
|
||||
}, 30_000)
|
||||
|
||||
it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => {
|
||||
// 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 the successful round-trip proves boot continued.
|
||||
consumer = await makeConsumer('DISABLED-OK ready.', true)
|
||||
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi')
|
||||
expect(stderr).not.toContain('failed to load')
|
||||
expect(stdout).toContain('DISABLED-OK ready.')
|
||||
expect(stdout).toContain('[tool result] ECHO: HI')
|
||||
expect(code).toBe(0)
|
||||
}, 30_000)
|
||||
|
||||
it('runs two synchronously piped lines as two ordinary turns', async () => {
|
||||
consumer = await makeConsumer('TWO-TURNS ready.')
|
||||
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'first\nsecond')
|
||||
expect(stderr).not.toContain('UNHANDLED')
|
||||
expect(stdout).toContain('[main turn 1]')
|
||||
expect(stdout).toContain('You said: "first"')
|
||||
expect(stdout).toContain('[main turn 2]')
|
||||
expect(stdout).toContain('You said: "second"')
|
||||
expect(code).toBe(0)
|
||||
}, 30_000)
|
||||
|
||||
it('boots when optional spill plugins are loaded from a built consumer install', async () => {
|
||||
consumer = await makeConsumer(
|
||||
'SPILL-OK ready.',
|
||||
false,
|
||||
['spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention'],
|
||||
[
|
||||
'- id: spill-local',
|
||||
' name: \'@deepseek-ai/dsh-spill-local\'',
|
||||
'- id: spill-policy',
|
||||
' name: \'@deepseek-ai/dsh-spill-policy\'',
|
||||
' config:',
|
||||
' maxInlineBytes: 50000',
|
||||
],
|
||||
)
|
||||
const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', '')
|
||||
expect(stderr).not.toContain('failed to load')
|
||||
expect(stderr).not.toContain('Cannot find package')
|
||||
expect(stdout).toContain('SPILL-OK ready.')
|
||||
expect(code).toBe(0)
|
||||
}, 30_000)
|
||||
|
||||
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
|
||||
// boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config
|
||||
// directory cannot break its import; the include plugin's own read must fail loud instead.
|
||||
consumer = await makeConsumer('unused')
|
||||
const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '')
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('config file not found')
|
||||
}, 30_000)
|
||||
|
||||
it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
|
||||
// Existing directory plus missing config exercises the include plugin's fail-loud path.
|
||||
consumer = await makeConsumer('unused')
|
||||
const { code, stderr } = await runBuiltBin(consumer, './does-not-exist.yml', '')
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('config file not found')
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -1,298 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as stdioAgent from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Unit coverage for app composition and config forwarding: pre-created main agent,
|
||||
* agent-spine-demo spine, JSONL backend, and adaptive terminal UI. HMR is a Loader-only leaf concern covered by the
|
||||
* keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise
|
||||
* survive namespace collapse while silently losing its schema.
|
||||
*/
|
||||
async function mount(config: stdioAgent.Config, withBash = false): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
if (withBash) ctx.provide('bash', { sandboxMode: undefined })
|
||||
await ctx.plugin(stdioAgent, config)
|
||||
// The app mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services + the pre-created agent are ready.
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<stdioAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-skills-'))
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
|
||||
const empty: Message[] = []
|
||||
return await agentEvents(ctx, agent).waterfall(
|
||||
'agent/session-prefix', empty, new AbortController().signal,
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-default-skills-'))
|
||||
process.env.DSH_HOME = join(home, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(home, '.agents')
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (oldDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = oldDshHome
|
||||
}
|
||||
if (oldAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = oldAgentsHome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-stdio-demo app', () => {
|
||||
it('selects readline for pipes and dsh-tui for interactive terminal pairs', () => {
|
||||
expect(stdioAgent.resolveTerminalMode(undefined, false)).toBe('readline')
|
||||
expect(stdioAgent.resolveTerminalMode(undefined, true)).toBe('tui')
|
||||
expect(stdioAgent.resolveTerminalMode({ mode: 'readline' }, true)).toBe('readline')
|
||||
expect(stdioAgent.resolveTerminalMode({ mode: 'tui' }, true)).toBe('tui')
|
||||
expect(() => stdioAgent.resolveTerminalMode({ mode: 'tui' }, false)).toThrow('requires both stdin and stdout')
|
||||
})
|
||||
|
||||
it('binds only the selected terminal package to the app-owned exact session identity', () => {
|
||||
const calls: Array<{ name: string; config: unknown }> = []
|
||||
const ctx = {
|
||||
plugin(plugin: { name?: string }, config?: unknown) {
|
||||
calls.push({ name: plugin.name ?? '', config })
|
||||
},
|
||||
} as unknown as Context
|
||||
|
||||
stdioAgent.composeTerminalApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
workspaceContext: false,
|
||||
persistenceCompression: 'none',
|
||||
welcome: 'TUI ready',
|
||||
ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } },
|
||||
}, true)
|
||||
expect(calls.map(call => call.name)).toContain('ui-tui')
|
||||
expect(calls.map(call => call.name)).not.toContain('ui-stdio')
|
||||
expect(calls.map(call => call.name)).not.toContain('ConsoleExporter')
|
||||
expect(calls.find(call => (call.config as { root?: string } | undefined)?.root === './.sessions')?.config).toEqual({
|
||||
root: './.sessions',
|
||||
compression: 'none',
|
||||
})
|
||||
const tuiConfig = calls.find(call => call.name === 'ui-tui')?.config as { sessionId: string }
|
||||
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
|
||||
expect(tuiConfig.sessionId).toMatch(/^main-session-/)
|
||||
const spineConfig = calls.find(call => call.name === 'agent-spine-demo')?.config as {
|
||||
agents: Array<{ id: string; sessionId?: string; resumeSessionId?: string }>
|
||||
}
|
||||
expect(spineConfig.agents[0]).toMatchObject({ id: 'main', sessionId: tuiConfig.sessionId })
|
||||
|
||||
calls.length = 0
|
||||
stdioAgent.composeTerminalApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
resumeSessionId: 'persisted-session',
|
||||
workspaceContext: false,
|
||||
ui: { mode: 'tui' },
|
||||
}, true)
|
||||
expect(calls.find(call => call.name === 'ui-tui')?.config).toMatchObject({
|
||||
sessionId: 'persisted-session', welcome: 'ready.',
|
||||
})
|
||||
expect((calls.find(call => call.name === 'agent-spine-demo')?.config as typeof spineConfig).agents[0])
|
||||
.toMatchObject({ id: 'main', resumeSessionId: 'persisted-session' })
|
||||
|
||||
calls.length = 0
|
||||
stdioAgent.composeTerminalApp(ctx, {
|
||||
provider: 'mock', model: 'mock', workspaceContext: false, ui: { mode: 'readline' },
|
||||
}, false)
|
||||
expect(calls.map(call => call.name)).toContain('ui-stdio')
|
||||
expect(calls.map(call => call.name)).toContain('ConsoleExporter')
|
||||
expect(calls.map(call => call.name)).not.toContain('ui-tui')
|
||||
})
|
||||
|
||||
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
|
||||
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
// The spine services (brought up by the agent-spine-demo bundle) are all present.
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('userInteraction')).toBeDefined()
|
||||
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
|
||||
// The sole pre-created agent the UI drives. `main` is its stable config
|
||||
// label; each fresh process mints a durable combined agent/session id.
|
||||
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
|
||||
const agent = ctx.get('agents')?.list()[0]
|
||||
expect(agent).toBeDefined()
|
||||
expect(agent?.id).toBe(agent?.session.id)
|
||||
expect(agent?.id).toMatch(/^main-session-/)
|
||||
expect(agent?.session.header.cwd).toBe(process.cwd())
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('normalizes an empty resume id to a fresh exact app identity', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
resumeSessionId: '',
|
||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
|
||||
const agent = ctx.get('agents')?.list()[0]
|
||||
expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/)
|
||||
expect(agent?.id).toBe(agent?.session.id)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('defaults persistenceRoot and welcome when omitted', async () => {
|
||||
// Direct apply (NOT via ctx.plugin, which validates+defaults the config
|
||||
// first) so the runtime `DEFAULT_PERSISTENCE_ROOT` / `DEFAULT_WELCOME` fallbacks on
|
||||
// apply()'s last two lines are the ones that fire — covering a
|
||||
// schema-bypassing direct-mount caller.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards explicit project-instruction controls to the bundled spine', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
persona: 'hi',
|
||||
persistenceRoot: '/tmp/dsh-stdio-demo-spec-workspace-context',
|
||||
workspaceContext: false,
|
||||
})
|
||||
await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
|
||||
expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards resumeSessionId onto the pre-created agent when set', async () => {
|
||||
// A resume id defers agent creation until persistence loads; with no backing
|
||||
// session the resume is contained + logged, so no agent registers —
|
||||
// the branch that maps resumeSessionId through is what this covers.
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
persona: 'hi',
|
||||
persistenceRoot: '/tmp/dsh-stdio-demo-spec-resume',
|
||||
resumeSessionId: 'no-such-session',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(ctx.get('agents')?.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards skill config and dshHome into agent-spine-demo', async () => {
|
||||
const skills = await isolatedSkillsConfig(6)
|
||||
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false })
|
||||
ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards maxParallelToolCalls to the bundled agent loop', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
maxParallelToolCalls: 3,
|
||||
persistenceRoot: '/tmp/dsh-stdio-demo-spec-parallel',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards bundled tool config into agent-core', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
workspaceContext: false,
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
|
||||
skills: await isolatedSkillsConfig(),
|
||||
}, true)
|
||||
const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
|
||||
expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
|
||||
.not.toContain('run_in_background')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its name and Config schema', () => {
|
||||
expect(stdioAgent.name).toBe('stdio-demo')
|
||||
expect(stdioAgent.Config).toBeDefined()
|
||||
})
|
||||
|
||||
it('forwards toolOrder through agent-spine-demo to the system-prompt assembly', async () => {
|
||||
const ctx = await mount({
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
toolOrder: ['zulu', TOOL_ORDER_REST],
|
||||
persistenceRoot: '/tmp/dsh-stdio-demo-spec-tool-order',
|
||||
workspaceContext: false,
|
||||
})
|
||||
// The bundle's own bash tools pend on the absent `ctx.bash` executor in
|
||||
// this providerless mount, so register two plain tools to order.
|
||||
for (const name of ['alpha', 'zulu']) {
|
||||
ctx.get('tools')!.register({
|
||||
name,
|
||||
description: name,
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill', 'task_kill', 'task_list', 'task_output'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
|
||||
// A default export would make `unwrapExports` collapse this inject-less namespace and silently
|
||||
// drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly.
|
||||
expect('default' in stdioAgent).toBe(false)
|
||||
expect(typeof stdioAgent.apply).toBe('function')
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(stdioAgent) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(stdioAgent)
|
||||
expect(unwrapped.name).toBe('stdio-demo')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
102
packages/examples/tui-demo/README.md
Normal file
102
packages/examples/tui-demo/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# @deepseek-ai/dsh-tui-demo
|
||||
|
||||
The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`.
|
||||
|
||||
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback.
|
||||
|
||||
## What it bakes in
|
||||
|
||||
| Plugin | Why it is here |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` |
|
||||
| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service |
|
||||
| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool |
|
||||
|
||||
Swappable LLM, bash, filesystem, and other capability providers remain in the leaf config. `@cordisjs/plugin-hmr` also remains a leaf-only development entry because it requires Loader internals.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Routed to |
|
||||
|---|---|---|
|
||||
| `provider` | required | Configured `main` agent provider |
|
||||
| `model` | required | Configured `main` agent model |
|
||||
| `maxParallelToolCalls` | agent-loop default | Bundled loop concurrency cap |
|
||||
| `persona` | — | System-prompt persona template |
|
||||
| `toolOrder` | lexicographic | Explicit model-facing tool order |
|
||||
| `tools` | owner default | Tool presentation mode |
|
||||
| `dshHome` | owner default | Harness home used by bash and skills |
|
||||
| `skills` | owner defaults | Skill registry, local provider, and tool config |
|
||||
| `toolBash` | owner defaults | Model-facing bash tool config |
|
||||
| `toolTasks` | owner defaults | Background-task control-tool config, or `false` |
|
||||
| `workspaceContext` | required | Workspace-instruction config, or `false` |
|
||||
| `persistenceRoot` | `./.sessions` | JSONL persistence root |
|
||||
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
|
||||
| `welcome` | `ready.` | TUI subtitle |
|
||||
| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height |
|
||||
| `resumeSessionId` | — | Exact persisted session to resume |
|
||||
|
||||
Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal.
|
||||
|
||||
## The bin
|
||||
|
||||
`dsh-tui-demo [path-to-cordis.yml]` defaults to `./cordis.yml`, loads the optional cwd `.env`, boots the Cordis Loader, and waits for the full plugin tree. Bare package specifiers require `node --expose-internals` or the Loader's optional native fallback; the repository scripts use `--expose-internals`.
|
||||
|
||||
## Example leaf
|
||||
|
||||
```yaml
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
- id: tui-agent
|
||||
name: '@deepseek-ai/dsh-tui-demo'
|
||||
config:
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
welcome: 'Coding agent ready.'
|
||||
ui:
|
||||
showReasoning: true
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Interactive terminal turn
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each non-empty editor submission becomes a user message; a submission during a running turn becomes steering. The shared spine contributes the configured persona, workspace instructions, skill catalog, and visible tool schemas. TUI rendering itself is not model-visible.
|
||||
|
||||
#### Token effect
|
||||
|
||||
User, assistant, and tool history grows under the normal session and compaction rules. Headers, cards, plans, Markdown styling, and keybindings add no tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only while the composed prompt, schemas, route, and retained history prefix remain stable. Composition changes and compaction can invalidate reuse from the first changed token.
|
||||
|
||||
### Human-question answer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`ask_user_question` retains the tool call and the compact answer or stable interruption error defined by `dsh-tool-ask-user`. The question overlay is terminal-only.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Only the completed or failed tool result adds retained tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; the answer follows the reusable request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **TTY-only** — stdin and stdout must both be terminals; automation uses `dsh-cli-demo`.
|
||||
- **One configured terminal session** — the transcript and editor bind to one exact session id.
|
||||
- **The app cluster is fixed** — JSONL persistence and ask-user tooling are baked in; different policy requires another composition.
|
||||
- **Approval is separate** — this app answers `ctx.userInteraction`, not `ctx.approval`; permission prompts require an approval service and answerer.
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-stdio-demo",
|
||||
"description": "Terminal chat app: agent spine + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent",
|
||||
"name": "@deepseek-ai/dsh-tui-demo",
|
||||
"description": "Full-screen terminal app: agent spine + JSONL persistence + pi-tui front door + pre-created main agent",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"bin": {
|
||||
"dsh-stdio-demo": "lib/bin.js"
|
||||
"dsh-tui-demo": "lib/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -32,7 +32,6 @@
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@cordisjs/plugin-logger-console": "^1.0.0",
|
||||
"@deepseek-ai/dsh-app-boot": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
@@ -41,7 +40,6 @@
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-stdio": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tui": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -52,7 +50,6 @@
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@cordisjs/plugin-logger-console": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
@@ -62,7 +59,6 @@
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-stdio": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-demo [config]`, defaulting to the
|
||||
* Boot a TUI app from a leaf `cordis.yml`; usage is `dsh-tui-demo [config]`, defaulting to the
|
||||
* cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in
|
||||
* dsh-app-boot. The echo-agent and repl-agent demos invoke this bin with their own leaf configs.
|
||||
* @module @deepseek-ai/dsh-stdio-demo/bin
|
||||
* dsh-app-boot. The tui-agent and cordis-agent demos invoke this bin with their own leaf configs.
|
||||
* @module @deepseek-ai/dsh-tui-demo/bin
|
||||
*/
|
||||
|
||||
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
const NAME = 'dsh-stdio-demo'
|
||||
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
|
||||
130
packages/examples/tui-demo/src/index.ts
Normal file
130
packages/examples/tui-demo/src/index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo})
|
||||
* plus JSONL persistence, keyboard-backed user interaction, and one pre-created
|
||||
* agent whose exact session identity the TUI drives. Swappable adapters,
|
||||
* executors, optional tools, and HMR stay in the leaf. This Loader plugin
|
||||
* intentionally exposes named exports only; a default export would hide its
|
||||
* `Config` schema (see docs/postmortem/0001).
|
||||
* @module @deepseek-ai/dsh-tui-demo
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
import SessionPersistenceJsonl, {
|
||||
JsonlCompressionSchema,
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
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 {
|
||||
/** Provider route for the `main` agent. */
|
||||
provider: string
|
||||
/** Model name for the `main` agent; a matching adapter must be registered. */
|
||||
model: string
|
||||
/** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */
|
||||
maxParallelToolCalls?: number
|
||||
/** Deployment persona forwarded to the system-prompt plugin. */
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order forwarded to the system-prompt plugin. */
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry presentation config forwarded through agent-spine-demo. */
|
||||
tools?: ToolsConfig
|
||||
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
|
||||
dshHome?: string
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
persistenceCompression?: JsonlCompression
|
||||
/** TUI subtitle rendered on start. Defaults to `ready.`. */
|
||||
welcome?: string
|
||||
/** Full-screen TUI presentation settings. */
|
||||
ui?: uiTui.TuiConfig
|
||||
/** Skill registry, local-provider, and model-facing consumer config. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-spine-demo. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
/** Persisted session id to resume instead of creating a fresh session. */
|
||||
resumeSessionId?: string
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
|
||||
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(),
|
||||
maxParallelToolCalls: z.number().step(1).min(1),
|
||||
persona: z.string(),
|
||||
// Absent means lexicographic order; schemastery's native array default is [].
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
dshHome: z.string(),
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
welcome: z.string().default(DEFAULT_WELCOME),
|
||||
ui: uiTui.TuiConfigSchema,
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
toolBash: agentCore.ToolBashConfigSchema,
|
||||
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
|
||||
resumeSessionId: z.string(),
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Compose the spine, TUI, JSONL persistence, and user-question tool around one
|
||||
* exact fresh or resumed session identity. The TUI subscribes to startup
|
||||
* failures before the spine creates the agent.
|
||||
* @param ctx - context receiving the app's child plugins.
|
||||
* @param config - validated app configuration.
|
||||
*/
|
||||
export function composeTuiApp(ctx: Context, config: Config): void {
|
||||
const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId
|
||||
const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`)
|
||||
ctx.plugin(SessionPersistenceJsonl, {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
})
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(uiTui, {
|
||||
...config.ui,
|
||||
welcome: config.welcome ?? DEFAULT_WELCOME,
|
||||
sessionId,
|
||||
})
|
||||
ctx.plugin(agentCore, {
|
||||
...agentCore.pickSpineConfig(config),
|
||||
agents: [{
|
||||
id: SessionId('main'),
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
cwd: process.cwd(),
|
||||
...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId },
|
||||
}],
|
||||
})
|
||||
ctx.plugin(toolAskUser)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the configured full-screen terminal app.
|
||||
* @param ctx - context receiving the app's child plugins.
|
||||
* @param config - validated app configuration.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
composeTuiApp(ctx, config)
|
||||
}
|
||||
120
packages/examples/tui-demo/tests/tui-agent.spec.ts
Normal file
120
packages/examples/tui-demo/tests/tui-agent.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as tuiAgent from '../src/index.ts'
|
||||
|
||||
interface PluginCall {
|
||||
readonly name: string
|
||||
readonly config: unknown
|
||||
}
|
||||
|
||||
function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall[] } {
|
||||
const calls: PluginCall[] = []
|
||||
const ctx = {
|
||||
plugin(plugin: { name?: string }, config?: unknown) {
|
||||
calls.push({ name: plugin.name ?? '', config })
|
||||
},
|
||||
} as unknown as Context
|
||||
return { ctx, calls }
|
||||
}
|
||||
|
||||
describe('dsh-tui-demo app', () => {
|
||||
it('composes the TUI cluster around one fresh exact session identity', () => {
|
||||
const { ctx, calls } = recordingContext()
|
||||
tuiAgent.composeTuiApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
maxParallelToolCalls: 3,
|
||||
persona: 'test persona',
|
||||
toolOrder: ['zulu', TOOL_ORDER_REST],
|
||||
tools: { mode: 'code' },
|
||||
dshHome: '/tmp/dsh-home',
|
||||
persistenceRoot: '/tmp/tui-sessions',
|
||||
persistenceCompression: 'none',
|
||||
welcome: 'TUI ready',
|
||||
ui: { color: false, maxToolOutputLines: 3 },
|
||||
skills: { tool: { catalogDescriptionMaxLength: 8 } },
|
||||
toolBash: { enableRunInBackground: false },
|
||||
toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
|
||||
workspaceContext: false,
|
||||
})
|
||||
|
||||
expect(calls.map(call => call.name)).toEqual([
|
||||
'SessionPersistenceJsonl',
|
||||
'UserInteractionService',
|
||||
'ui-tui',
|
||||
'agent-spine-demo',
|
||||
'tool-ask-user',
|
||||
])
|
||||
expect(calls[0]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
|
||||
const tuiConfig = calls[2]?.config as { sessionId: string }
|
||||
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
|
||||
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
|
||||
const spineConfig = calls[3]?.config as {
|
||||
readonly agents: Array<Record<string, unknown>>
|
||||
readonly maxParallelToolCalls: number
|
||||
readonly persona: string
|
||||
readonly toolOrder: string[]
|
||||
readonly tools: { mode: string }
|
||||
}
|
||||
expect(spineConfig).toMatchObject({
|
||||
maxParallelToolCalls: 3,
|
||||
persona: 'test persona',
|
||||
toolOrder: ['zulu', TOOL_ORDER_REST],
|
||||
tools: { mode: 'code' },
|
||||
})
|
||||
expect(spineConfig.agents[0]).toMatchObject({
|
||||
id: 'main',
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
cwd: process.cwd(),
|
||||
sessionId: tuiConfig.sessionId,
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes the configured session and applies runtime defaults', () => {
|
||||
const { ctx, calls } = recordingContext()
|
||||
tuiAgent.composeTuiApp(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
resumeSessionId: 'persisted-session',
|
||||
workspaceContext: false,
|
||||
})
|
||||
|
||||
expect(calls[0]?.config).toEqual({ root: './.sessions' })
|
||||
expect(calls[2]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
|
||||
expect((calls[3]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
|
||||
id: 'main',
|
||||
resumeSessionId: 'persisted-session',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes an empty resume id and routes apply through the same composition', () => {
|
||||
const { ctx, calls } = recordingContext()
|
||||
tuiAgent.apply(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
resumeSessionId: '',
|
||||
workspaceContext: false,
|
||||
})
|
||||
|
||||
const tuiConfig = calls[2]?.config as { sessionId: string }
|
||||
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
|
||||
expect((calls[3]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
|
||||
.toMatchObject({ sessionId: tuiConfig.sessionId })
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape so the Loader keeps its schema', () => {
|
||||
expect(tuiAgent.name).toBe('tui-demo')
|
||||
expect(tuiAgent.Config).toBeDefined()
|
||||
expect('default' in tuiAgent).toBe(false)
|
||||
expect(typeof tuiAgent.apply).toBe('function')
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(tuiAgent) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(tuiAgent)
|
||||
expect(unwrapped.name).toBe('tui-demo')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -20,9 +20,6 @@
|
||||
{
|
||||
"path": "../../ui/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/logger-console"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
@@ -38,9 +35,6 @@
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/stdio"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/tui"
|
||||
},
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* stdio-agent ships TWO entries: the plugin (`index`) and the CLI `bin`
|
||||
* tui-demo ships two entries: the plugin (`index`) and the CLI `bin`
|
||||
* (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`.
|
||||
* The root tsdown builds only `lib/types/index.js`, so this override adds
|
||||
* `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false),
|
||||
@@ -61,7 +61,7 @@ function createProgram(): Command {
|
||||
.option('--base-url <url>')
|
||||
.option('--api-key <key>')
|
||||
.option('--model <name>')
|
||||
.addOption(new Option('--interface <name>').choices(['acp', 'stdio', 'embed']))
|
||||
.addOption(new Option('--interface <name>').choices(['acp', 'tui', 'embed']))
|
||||
.addOption(new Option('--pm <name>').choices(['npm', 'pnpm', 'yarn']))
|
||||
.addOption(new Option('--install').default(undefined))
|
||||
.addOption(new Option('--no-install').default(undefined))
|
||||
|
||||
@@ -169,10 +169,10 @@ const PROJECT_QUESTION_STEPS: readonly WizardStep<ProjectAnswerState>[] = [
|
||||
message: 'Run interface',
|
||||
options: [
|
||||
{ value: 'acp', label: 'ACP server' },
|
||||
{ value: 'stdio', label: 'Terminal REPL' },
|
||||
{ value: 'tui', label: 'Terminal TUI' },
|
||||
{ value: 'embed', label: 'Embedded context' },
|
||||
],
|
||||
initialValue: 'stdio',
|
||||
initialValue: 'tui',
|
||||
}),
|
||||
prefilled: state => state.args.runInterface,
|
||||
apply: (state, value) => { state.runInterface = value },
|
||||
|
||||
@@ -6,7 +6,7 @@ Options:
|
||||
--base-url <url>
|
||||
--api-key <key>
|
||||
--model <name>
|
||||
--interface <acp|stdio|embed>
|
||||
--interface <acp|tui|embed>
|
||||
--pm <npm|pnpm|yarn>
|
||||
--install / --no-install
|
||||
--config <path>
|
||||
|
||||
@@ -179,12 +179,12 @@ describe('create-sdk terminal contract', () => {
|
||||
"message": "DeepSeek API key",
|
||||
},
|
||||
{
|
||||
"initialValue": "stdio",
|
||||
"initialValue": "tui",
|
||||
"kind": "select",
|
||||
"message": "Run interface",
|
||||
"options": [
|
||||
"ACP server",
|
||||
"Terminal REPL",
|
||||
"Terminal TUI",
|
||||
"Embedded context",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('create arguments', () => {
|
||||
expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'")
|
||||
expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom')
|
||||
expect(parseCreateArgs(['--help']).help).toBe(true)
|
||||
expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, stdio, embed')
|
||||
expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, tui, embed')
|
||||
expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'")
|
||||
expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments')
|
||||
})
|
||||
@@ -208,7 +208,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
'--provider=deepseek',
|
||||
'--api-key=deepseek-key',
|
||||
'--model=deepseek-v4-flash',
|
||||
'--interface=stdio',
|
||||
'--interface=tui',
|
||||
'--pm=npm',
|
||||
'--no-install',
|
||||
'--link-workspace',
|
||||
@@ -247,7 +247,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'my-agent', '--description=demo', '--provider=deepseek', '--api-key=deepseek-key',
|
||||
'--model=deepseek-v4-flash', '--interface=stdio', '--pm=npm', '--no-install',
|
||||
'--model=deepseek-v4-flash', '--interface=tui', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port: new HeadlessPromptPort(),
|
||||
cwd,
|
||||
@@ -275,7 +275,7 @@ describe('CreateWizard and scaffolder', () => {
|
||||
await expect(new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'my-agent', '--description=demo', '--provider=deepseek', '--api-key=k',
|
||||
'--model=m', '--interface=stdio', '--pm=npm', '--no-install',
|
||||
'--model=m', '--interface=tui', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port: new HeadlessPromptPort(),
|
||||
cwd,
|
||||
|
||||
@@ -29,7 +29,7 @@ const ID = featureId('app')
|
||||
|
||||
function appProjectResources(
|
||||
profile: ProjectProfile,
|
||||
runInterface: 'acp' | 'stdio' | 'embed',
|
||||
runInterface: 'acp' | 'tui' | 'embed',
|
||||
): readonly ProjectResource[] {
|
||||
const context = createProjectTemplateContext(profile, runInterface)
|
||||
const scripts = createAppPackageScripts(context)
|
||||
@@ -43,10 +43,10 @@ function appProjectResources(
|
||||
}
|
||||
|
||||
class AppOption extends FeatureOption {
|
||||
override readonly id: 'acp' | 'stdio' | 'embed'
|
||||
override readonly id: 'acp' | 'tui' | 'embed'
|
||||
override readonly label: string
|
||||
|
||||
constructor(id: 'acp' | 'stdio' | 'embed', label: string) {
|
||||
constructor(id: 'acp' | 'tui' | 'embed', label: string) {
|
||||
super()
|
||||
this.id = id
|
||||
this.label = label
|
||||
@@ -56,7 +56,7 @@ class AppOption extends FeatureOption {
|
||||
override markerConfigEntries(): readonly { id: string; name: string }[] {
|
||||
switch (this.id) {
|
||||
case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }]
|
||||
case 'stdio': return [{ id: 'stdio', name: '@deepseek-ai/dsh-stdio' }]
|
||||
case 'tui': return [{ id: 'tui', name: '@deepseek-ai/dsh-tui' }]
|
||||
case 'embed': return []
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ class AppOption extends FeatureOption {
|
||||
override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean {
|
||||
if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile)
|
||||
return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop')
|
||||
&& !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp' || entry.name === '@deepseek-ai/dsh-stdio')
|
||||
&& !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp' || entry.name === '@deepseek-ai/dsh-tui')
|
||||
}
|
||||
|
||||
override contribution(profile: ProjectProfile): ProjectContribution {
|
||||
@@ -83,7 +83,7 @@ class AppOption extends FeatureOption {
|
||||
config: { model: profile.runtime.model },
|
||||
}, ['model'], config => requiredString(config, 'model')),
|
||||
])
|
||||
case 'stdio':
|
||||
case 'tui':
|
||||
return new ProjectContribution([
|
||||
...appProjectResources(profile, this.id),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
@@ -91,10 +91,10 @@ class AppOption extends FeatureOption {
|
||||
name: '@deepseek-ai/dsh-user-interaction',
|
||||
}),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'stdio',
|
||||
name: '@deepseek-ai/dsh-stdio',
|
||||
id: 'tui',
|
||||
name: '@deepseek-ai/dsh-tui',
|
||||
config: {
|
||||
welcome: 'agent REPL ready. Give it a coding task.',
|
||||
welcome: 'TUI agent ready. Give it a coding task.',
|
||||
sessionId: new JsExpression('process.env.DSH_SDK_SESSION_ID'),
|
||||
},
|
||||
}, ['welcome', 'sessionId'], config => [
|
||||
@@ -108,7 +108,7 @@ class AppOption extends FeatureOption {
|
||||
}
|
||||
}
|
||||
|
||||
/** Required app selection represented by acp, stdio, or embed options. */
|
||||
/** Required app selection represented by ACP, TUI, or embed options. */
|
||||
export class AppFeature extends ExclusiveOptionFeature {
|
||||
override readonly id = ID
|
||||
override readonly summary = 'Run interface'
|
||||
@@ -116,7 +116,7 @@ export class AppFeature extends ExclusiveOptionFeature {
|
||||
override readonly requires = [featureId('spine')]
|
||||
override readonly options = [
|
||||
new AppOption('acp', 'ACP server'),
|
||||
new AppOption('stdio', 'Terminal REPL'),
|
||||
new AppOption('tui', 'Terminal TUI'),
|
||||
new AppOption('embed', 'Embedded context'),
|
||||
]
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@ config:
|
||||
id: 'ask-user',
|
||||
summary: 'Ask the user from the model loop',
|
||||
mode: 'single',
|
||||
supportedInterfaces: ['acp', 'stdio'],
|
||||
supportedInterfaces: ['acp', 'tui'],
|
||||
options: [{
|
||||
id: 'default',
|
||||
label: 'ask_user_question tool',
|
||||
|
||||
@@ -250,7 +250,7 @@ class DefinedFeature extends Feature {
|
||||
this.required = spec.required ?? false
|
||||
this.requires = (spec.requires ?? []).map(requirement => featureId(requirement.id))
|
||||
this.suggests = (spec.suggests ?? []).map(featureId)
|
||||
this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'stdio', 'embed']
|
||||
this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'tui', 'embed']
|
||||
}
|
||||
|
||||
override defaultOptions(): readonly string[] {
|
||||
|
||||
@@ -113,7 +113,7 @@ export abstract class Feature {
|
||||
/** Features recommended during creation. */
|
||||
readonly suggests: readonly FeatureId[] = []
|
||||
/** Front doors under which this feature is meaningful. */
|
||||
readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'stdio', 'embed']
|
||||
readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'tui', 'embed']
|
||||
|
||||
/**
|
||||
* Options selected when installation has no override.
|
||||
|
||||
@@ -549,7 +549,7 @@ export class ProjectEditSession implements FeatureProjectView {
|
||||
|
||||
private finalProfile(): ProjectProfile {
|
||||
const runInterface = this.states.get(featureId('app'))?.selection?.options[0]
|
||||
if (runInterface !== 'acp' && runInterface !== 'stdio' && runInterface !== 'embed') return this.profile
|
||||
if (runInterface !== 'acp' && runInterface !== 'tui' && runInterface !== 'embed') return this.profile
|
||||
return { ...this.profile, runInterface }
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const OPTIONAL_DOCUMENTS = [
|
||||
|
||||
function runInterface(entries: readonly CordisConfigEntry[]): RunInterface {
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')) return 'acp'
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-stdio')) return 'stdio'
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui')) return 'tui'
|
||||
return 'embed'
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export class SdkProject {
|
||||
static create(root: string, request: ProjectCreationRequest): SdkProject {
|
||||
const app = request.features.find(selection => selection.id === 'app')
|
||||
const selectedInterface = app?.options[0]
|
||||
if (selectedInterface !== 'acp' && selectedInterface !== 'stdio' && selectedInterface !== 'embed') {
|
||||
if (selectedInterface !== 'acp' && selectedInterface !== 'tui' && selectedInterface !== 'embed') {
|
||||
throw new Error('project creation requires one app feature option')
|
||||
}
|
||||
const profile: ProjectProfile = {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts'
|
||||
import type { FeatureId } from '../ids.ts'
|
||||
|
||||
/** Runtime front door selected for a generated project. */
|
||||
export type RunInterface = 'acp' | 'stdio' | 'embed'
|
||||
export type RunInterface = 'acp' | 'tui' | 'embed'
|
||||
|
||||
/** Values shared by the required provider and app features. */
|
||||
interface ProjectRuntimeOptions {
|
||||
|
||||
@@ -9,7 +9,7 @@ Built with the DeepSeek Harness SDK using the {{model}} model.
|
||||
|
||||
Run `{{packageManager}} start` and configure your ACP client to launch this project. Standard output is reserved for ACP JSON-RPC.
|
||||
{{else}}
|
||||
{{#if isStdio}}
|
||||
{{#if isTui}}
|
||||
## Run in a terminal
|
||||
|
||||
Run `{{packageManager}} start` to start the interactive agent.
|
||||
|
||||
@@ -8,18 +8,18 @@ import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts'
|
||||
|
||||
/** Boot this project's cordis.yml when invoked by dsh-scripts. */
|
||||
export async function main(boot: SdkBootContext) {
|
||||
{{#if isStdio}}
|
||||
{{#if isTui}}
|
||||
const model = boot.args.model
|
||||
if (typeof model !== 'string' || model.length === 0) throw new Error('stdio startup requires --model=<name>')
|
||||
if (typeof model !== 'string' || model.length === 0) throw new Error('TUI startup requires --model=<name>')
|
||||
const resume = boot.args.resume
|
||||
if (resume !== undefined && (typeof resume !== 'string' || resume.length === 0)) {
|
||||
throw new Error('stdio startup requires --resume=<session-id>')
|
||||
throw new Error('TUI startup requires --resume=<session-id>')
|
||||
}
|
||||
const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)
|
||||
process.env.DSH_SDK_SESSION_ID = sessionId
|
||||
{{/if}}
|
||||
const ctx = await startSDK(new URL('./cordis.yml', import.meta.url))
|
||||
{{#if isStdio}}
|
||||
{{#if isTui}}
|
||||
try {
|
||||
if (resume === undefined) {
|
||||
await ctx.agents.create({
|
||||
@@ -37,7 +37,7 @@ export async function main(boot: SdkBootContext) {
|
||||
try {
|
||||
await ctx.fiber.dispose()
|
||||
} catch (disposeError) {
|
||||
throw new AggregateError([error, disposeError], 'stdio startup and cleanup failed')
|
||||
throw new AggregateError([error, disposeError], 'TUI startup and cleanup failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface ProjectTemplateContext {
|
||||
model: string
|
||||
modelLiteral: string
|
||||
isAcp: boolean
|
||||
isStdio: boolean
|
||||
isTui: boolean
|
||||
isEmbed: boolean
|
||||
packageManager: PackageManagerName
|
||||
installArgs: string
|
||||
@@ -60,7 +60,7 @@ export function createProjectTemplateContext(
|
||||
model: profile.runtime.model,
|
||||
modelLiteral: JSON.stringify(profile.runtime.model),
|
||||
isAcp: runInterface === 'acp',
|
||||
isStdio: runInterface === 'stdio',
|
||||
isTui: runInterface === 'tui',
|
||||
isEmbed: runInterface === 'embed',
|
||||
packageManager: profile.packageManager.name,
|
||||
installArgs: profile.packageManager.installCommand().join(' '),
|
||||
@@ -105,7 +105,7 @@ export function createAppProjectArtifacts(
|
||||
|
||||
/** Build package scripts owned by the selected app feature option. */
|
||||
export function createAppPackageScripts(context: ProjectTemplateContext): Readonly<Record<'dev' | 'start', string>> {
|
||||
const modelArg = context.isStdio ? ` -- --model=${JSON.stringify(context.model)}` : ''
|
||||
const modelArg = context.isTui ? ` -- --model=${JSON.stringify(context.model)}` : ''
|
||||
return {
|
||||
dev: `dsh-sdk dev index.ts${modelArg}`,
|
||||
start: `dsh-sdk start index.js${modelArg}`,
|
||||
|
||||
@@ -243,7 +243,7 @@ overrides:
|
||||
expect(() => loadHelperTemplate('../bad.tpl')).toThrow('must not contain a directory')
|
||||
expect(createBaselineProjectArtifacts({
|
||||
name: 'demo', description: 'demo', releaseVersion: '0.0.1', model: 'model', modelLiteral: '"model"', packageManager: 'yarn',
|
||||
isAcp: false, isStdio: false, isEmbed: true,
|
||||
isAcp: false, isTui: false, isEmbed: true,
|
||||
installArgs: 'install', buildArgs: 'build',
|
||||
}).map(document => document.relativePath)).toContain('.yarnrc.yml')
|
||||
expect(() => new LocalPluginBlueprint('---', 'plugin')).toThrow('invalid local plugin name')
|
||||
|
||||
@@ -51,7 +51,7 @@ function selection(id: string, options: readonly string[], secrets?: Record<stri
|
||||
function request(
|
||||
extra: readonly FeatureSelection[] = [],
|
||||
plugins: readonly LocalPluginBlueprint[] = [],
|
||||
app: 'acp' | 'stdio' | 'embed' = 'stdio',
|
||||
app: 'acp' | 'tui' | 'embed' = 'tui',
|
||||
bash: 'local' | 'sandbox' = 'local',
|
||||
): ProjectCreationRequest {
|
||||
return {
|
||||
@@ -115,16 +115,16 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(acp.readEnvironment('.env', 'KEY')).toBe('value')
|
||||
expect(() => acp.readEnvironment('.env.example', 'KEY')).not.toThrow()
|
||||
expect(acp.document('tsconfig.json')).toBeInstanceOf(TextProjectFile)
|
||||
const stdio = await make('dsh-open-stdio', {}, `- id: provider
|
||||
const tui = await make('dsh-open-tui', {}, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [provider-model] }
|
||||
- id: stdio
|
||||
name: '@deepseek-ai/dsh-stdio'
|
||||
- id: tui
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
`, { 'yarn.lock': '' })
|
||||
expect(stdio.profile.runInterface).toBe('stdio')
|
||||
expect(stdio.profile.runtime.model).toBe('provider-model')
|
||||
expect(stdio.profile.packageManager.name).toBe('yarn')
|
||||
expect(stdio.profile.name).toBe(stdio.root.split('/').at(-1))
|
||||
expect(tui.profile.runInterface).toBe('tui')
|
||||
expect(tui.profile.runtime.model).toBe('provider-model')
|
||||
expect(tui.profile.packageManager.name).toBe('yarn')
|
||||
expect(tui.profile.name).toBe(tui.root.split('/').at(-1))
|
||||
const pnpm = await make('dsh-open-pnpm', { name: 'pnpm' }, '[]\n', { 'pnpm-lock.yaml': '' })
|
||||
expect(pnpm.profile.packageManager.name).toBe('pnpm')
|
||||
const defaults = await make('dsh-open-default', { name: 'default', packageManager: 'npm@10.0.0' }, '[]\n')
|
||||
@@ -134,8 +134,8 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(() => SdkProject.create(defaults.root, { ...request(), features: [] })).toThrow('requires one app')
|
||||
await expect(make('dsh-open-invalid-manager', { name: 'bad', packageManager: 'bad' }, '[]\n'))
|
||||
.rejects.toThrow('invalid packageManager field')
|
||||
const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: stdio
|
||||
name: '@deepseek-ai/dsh-stdio'
|
||||
const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: tui
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
config: { model: '' }
|
||||
- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
@@ -172,7 +172,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(index).toContain('process.env.DSH_SDK_SESSION_ID = sessionId')
|
||||
expect(index).toContain('resumeSessionId: sessionId')
|
||||
expect(index).toContain('await ctx.fiber.dispose()')
|
||||
expect(index).toContain("new AggregateError([error, disposeError], 'stdio startup and cleanup failed')")
|
||||
expect(index).toContain("new AggregateError([error, disposeError], 'TUI startup and cleanup failed')")
|
||||
expect(project.packageManifest().scripts).toEqual({
|
||||
dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"',
|
||||
build: 'dsh-sdk build',
|
||||
@@ -181,12 +181,12 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
config: 'dsh-sdk config',
|
||||
})
|
||||
expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=')
|
||||
expect(project.cordis.entry('stdio')?.config?.sessionId).toMatchObject({
|
||||
expect(project.cordis.entry('tui')?.config?.sessionId).toMatchObject({
|
||||
source: 'process.env.DSH_SDK_SESSION_ID',
|
||||
})
|
||||
expect(await readFile(join(project.root, 'cordis.yml'), 'utf8'))
|
||||
.toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID')
|
||||
expect(project.cordis.entry('stdio')?.config).not.toHaveProperty('model')
|
||||
expect(project.cordis.entry('tui')?.config).not.toHaveProperty('model')
|
||||
expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
|
||||
expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant')
|
||||
expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant')
|
||||
@@ -217,13 +217,13 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
expect(app.selection).toEqual(selection('app', ['embed']))
|
||||
expect(committed.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
|
||||
expect(committed.cordis.entry('acp')).toBeUndefined()
|
||||
expect(committed.cordis.entry('stdio')).toBeUndefined()
|
||||
expect(committed.cordis.entry('tui')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits the sandbox workspace-write example as inactive Cordis config', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-sandbox-bash-'))
|
||||
temporary.push(root)
|
||||
const creation = request([], [], 'stdio', 'sandbox')
|
||||
const creation = request([], [], 'tui', 'sandbox')
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
@@ -312,7 +312,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
const modifiedRegistry = createBuiltinRegistry(modified.profile)
|
||||
expect(() => { modified.edit(modifiedRegistry).configureFeature(
|
||||
modifiedRegistry.get(featureId('app')),
|
||||
selection('app', ['stdio']),
|
||||
selection('app', ['tui']),
|
||||
) }).toThrow('feature-owned file was modified: README.md')
|
||||
|
||||
const manifest = PackageJsonFile.parse(await readFile(join(embed.root, 'package.json'), 'utf8'))
|
||||
@@ -355,7 +355,7 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
const edit = project.edit(registry)
|
||||
edit.setCustomPluginDisabled('sample', true)
|
||||
expect(edit.cordisConfigEntries().find(entry => entry.id === 'sample')?.disabled).toBe(true)
|
||||
expect(() => { edit.setCustomPluginDisabled('stdio', true) }).toThrow('builtin feature')
|
||||
expect(() => { edit.setCustomPluginDisabled('tui', true) }).toThrow('builtin feature')
|
||||
const next = (await edit.commit()).project
|
||||
const enable = next.edit(createBuiltinRegistry(next.profile))
|
||||
enable.setCustomPluginDisabled('sample', false)
|
||||
@@ -450,8 +450,8 @@ describe('SdkProject and ProjectEditSession', () => {
|
||||
}
|
||||
const internals = edit as unknown as Internals
|
||||
const collidingEntry: ProjectResource = {
|
||||
kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:stdio'),
|
||||
entry: { id: 'stdio', name: 'other-package' }, ownedConfigKeys: [],
|
||||
kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:tui'),
|
||||
entry: { id: 'tui', name: 'other-package' }, ownedConfigKeys: [],
|
||||
}
|
||||
expect(() => { internals.applyResource(collidingEntry, undefined) }).toThrow('is owned by')
|
||||
const existingFile: ProjectResource = {
|
||||
@@ -803,7 +803,7 @@ describe('extension points', () => {
|
||||
})
|
||||
expect(exclusive.defaultOptions(profile)).toEqual(['one'])
|
||||
expect(exclusive.isApplicable(profile)).toBe(true)
|
||||
expect(exclusive.isApplicable({ ...profile, runInterface: 'stdio' })).toBe(false)
|
||||
expect(exclusive.isApplicable({ ...profile, runInterface: 'tui' })).toBe(false)
|
||||
expect(exclusive.requirements(selection('defined', ['one']))).toEqual([
|
||||
{ id: 'base' }, { id: 'option', options: ['required'] },
|
||||
])
|
||||
@@ -817,7 +817,7 @@ describe('extension points', () => {
|
||||
expect(entry?.validateConfig?.({ nested: { value: 2 }, list: ['a', 'b'], nullable: null })).toEqual([])
|
||||
expect(entry?.validateConfig?.({ nested: [], list: 'bad' })).toHaveLength(3)
|
||||
expect(() => exclusive.normalizeSelection(selection('other', ['one']), profile)).toThrow('does not belong')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'stdio' }))
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'tui' }))
|
||||
.toThrow('not available')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['missing']), profile)).toThrow('unknown')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one', 'two']), profile)).toThrow('exactly one')
|
||||
@@ -825,7 +825,7 @@ describe('extension points', () => {
|
||||
id: 'fixed', summary: 'Fixed', mode: 'single', options: [option],
|
||||
}])).toHaveLength(2)
|
||||
expect(() => new FeatureRegistry([], profile).get(featureId('missing'))).toThrow('unknown feature')
|
||||
expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'stdio' }))
|
||||
expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'tui' }))
|
||||
.toBeUndefined()
|
||||
class Unsupported extends FixedFeature {
|
||||
override readonly id = featureId('unsupported')
|
||||
@@ -908,10 +908,10 @@ describe('extension points', () => {
|
||||
resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp')
|
||||
expect(acpEntry?.entry.id).toBe('acp')
|
||||
expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1)
|
||||
const stdioEntry = builtins.get(featureId('app')).contribution(selection('app', ['stdio']), profile).resources
|
||||
const tuiEntry = builtins.get(featureId('app')).contribution(selection('app', ['tui']), profile).resources
|
||||
.find((resource): resource is CordisConfigEntryResource =>
|
||||
resource.kind === 'cordis-config-entry' && resource.entry.id === 'stdio')
|
||||
expect(stdioEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([
|
||||
resource.kind === 'cordis-config-entry' && resource.entry.id === 'tui')
|
||||
expect(tuiEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([
|
||||
'sessionId must be a non-empty string',
|
||||
])
|
||||
const embedOption = app.options.find(option => option.id === 'embed')
|
||||
@@ -921,7 +921,7 @@ describe('extension points', () => {
|
||||
])
|
||||
expect(embedOption?.matchesConfigEntries([
|
||||
{ id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' },
|
||||
{ id: 'stdio', name: '@deepseek-ai/dsh-stdio' },
|
||||
{ id: 'tui', name: '@deepseek-ai/dsh-tui' },
|
||||
], profile)).toBe(false)
|
||||
const spineAgentLoop = builtins.get(featureId('spine')).contribution(selection('spine', ['default']), profile).resources
|
||||
.find((resource): resource is CordisConfigEntryResource =>
|
||||
|
||||
@@ -376,7 +376,7 @@ describe('feature configurator', () => {
|
||||
name: 'demo',
|
||||
description: 'demo',
|
||||
runtime: { model: 'deepseek-v4-flash' },
|
||||
runInterface: 'stdio',
|
||||
runInterface: 'tui',
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ function targetRunInterface(
|
||||
desired: ReadonlyMap<string, NestedMultiSelectValue<string, string>>,
|
||||
): RunInterface {
|
||||
const selected = desired.get('feature:app')?.choices[0]
|
||||
return selected === 'acp' || selected === 'stdio' || selected === 'embed' ? selected : current
|
||||
return selected === 'acp' || selected === 'tui' || selected === 'embed' ? selected : current
|
||||
}
|
||||
|
||||
/** Reconcile one tree selection into domain commands, then review and commit once. */
|
||||
|
||||
@@ -90,8 +90,8 @@ Change file: package.json
|
||||
},
|
||||
{
|
||||
"default": true,
|
||||
"label": "Terminal REPL",
|
||||
"value": "stdio",
|
||||
"label": "Terminal TUI",
|
||||
"value": "tui",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
|
||||
@@ -94,7 +94,7 @@ async function baseProject(): Promise<SdkProject> {
|
||||
features: [
|
||||
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } },
|
||||
{ id: featureId('bash'), options: ['local'] },
|
||||
{ id: featureId('app'), options: ['stdio'] },
|
||||
{ id: featureId('app'), options: ['tui'] },
|
||||
{ id: featureId('persistence'), options: ['jsonl'] },
|
||||
],
|
||||
localPlugins: [],
|
||||
|
||||
@@ -85,7 +85,7 @@ function commandContext(cwd: string): DshSdkCommandContext & { readStdout: () =>
|
||||
function creation(
|
||||
extra: ProjectCreationRequest['features'] = [],
|
||||
localPlugins: readonly LocalPluginBlueprint[] = [],
|
||||
app: 'acp' | 'stdio' | 'embed' = 'embed',
|
||||
app: 'acp' | 'tui' | 'embed' = 'embed',
|
||||
): ProjectCreationRequest {
|
||||
return {
|
||||
name: 'config-agent',
|
||||
@@ -107,7 +107,7 @@ function creation(
|
||||
async function committedProject(
|
||||
extra: ProjectCreationRequest['features'] = [],
|
||||
localPlugins: readonly LocalPluginBlueprint[] = [],
|
||||
app: 'acp' | 'stdio' | 'embed' = 'embed',
|
||||
app: 'acp' | 'tui' | 'embed' = 'embed',
|
||||
): Promise<SdkProject> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-config-workflow-'))
|
||||
temporary.push(root)
|
||||
@@ -525,7 +525,7 @@ describe('ConfigWorkflow', () => {
|
||||
const workflow = new ConfigWorkflow(new QueuePort([
|
||||
[
|
||||
{ value: 'feature:provider', choices: ['custom'] },
|
||||
{ value: 'feature:app', choices: ['stdio'] },
|
||||
{ value: 'feature:app', choices: ['tui'] },
|
||||
{ value: 'feature:persistence', choices: ['jsonl'] },
|
||||
],
|
||||
'https://provider.example/v1',
|
||||
@@ -536,7 +536,7 @@ describe('ConfigWorkflow', () => {
|
||||
const provider = result.commit?.project.cordis.entry('llm-pi-ai')
|
||||
expect(provider?.config?.apiKey).toBeDefined()
|
||||
expect(provider?.config?.baseURL).toBe('https://provider.example/v1')
|
||||
expect(result.commit?.project.cordis.entry('stdio')).toBeDefined()
|
||||
expect(result.commit?.project.cordis.entry('tui')).toBeDefined()
|
||||
expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined()
|
||||
expect(result.commit?.project.cordis.entry('agent-core')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -10,4 +10,4 @@ Packages that exist to serve development, testing, and the examples rather than
|
||||
| `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) |
|
||||
| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
|
||||
|
||||
`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
|
||||
`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel real-Loader launch boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
resolveExampleMode,
|
||||
} from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const SRC_BIN = '/repo/packages/examples/stdio-demo/src/bin.ts'
|
||||
const SRC_BIN = '/repo/packages/examples/tui-demo/src/bin.ts'
|
||||
const TSCONFIG = '/repo/tsconfig.json'
|
||||
|
||||
const originalMode = process.env[EXAMPLE_MODE_ENV]
|
||||
@@ -66,7 +66,7 @@ describe('resolveExampleLaunch', () => {
|
||||
env: { DSH_HOME: '/tmp/home' },
|
||||
})
|
||||
expect(args).not.toContain('--import')
|
||||
expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js')
|
||||
expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js')
|
||||
expect(args.slice(-2)).toEqual(['--config', './cordis.yml'])
|
||||
expect(env.TSX_TSCONFIG_PATH).toBeUndefined()
|
||||
expect(env.DSH_HOME).toBe('/tmp/home')
|
||||
@@ -106,6 +106,6 @@ describe('resolveExampleLaunch', () => {
|
||||
it('defaults the mode from the environment', () => {
|
||||
process.env[EXAMPLE_MODE_ENV] = 'lib'
|
||||
const { args } = resolveExampleLaunch({ srcBin: SRC_BIN })
|
||||
expect(args).toContain('/repo/packages/examples/stdio-demo/lib/bin.js')
|
||||
expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa
|
||||
|---|---|---|
|
||||
| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
|
||||
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [terminal app](../examples/stdio-demo) shows a persistent TUI plan or readline checklist, while the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [TUI app](../examples/tui-demo) shows a persistent plan, while the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
|
||||
|
||||
@@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
|
||||
|
||||
## Rendering
|
||||
|
||||
The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [terminal app](../../examples/stdio-demo) shows a persistent TUI plan or readline checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
|
||||
The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [TUI app](../../examples/tui-demo) shows a persistent plan, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
|
||||
|
||||
## Export shape
|
||||
|
||||
|
||||
@@ -9,13 +9,12 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
|
||||
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `stdio/` | Line-oriented terminal channel for pipes and automation; drives `ctx.agents`, renders `session/event`, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
|
||||
| `tui/` | Interactive pi-tui terminal channel for TTY sessions; renders `session/event`, tool presentation intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) |
|
||||
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) and [`tui`](tui/README.md) plugins are the two terminal front doors: one is line-oriented for pipes, the other is interactive for TTYs. App bundles and SDK projects compose the appropriate channel explicitly with the services and tools their product profile selects.
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door; non-interactive tasks use the headless `cli-demo` app instead of a UI channel.
|
||||
|
||||
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
|
||||
|
||||
The runnable app bundles that bake these bridges into boot bins — the terminal chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
|
||||
The runnable app bundles that bake these bridges into boot bins — the TUI app, ACP server app, and JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target.
|
||||
|
||||
It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui`/`dsh-stdio` channels — NOT a loop change and NOT a [capability seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
|
||||
It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui` channel — NOT a loop change and NOT a [capability seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
|
||||
|
||||
## Service / plugin
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# `@deepseek-ai/dsh-app-boot`
|
||||
|
||||
Shared boot glue for the app bins ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts.
|
||||
Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts.
|
||||
|
||||
| Export | Role |
|
||||
|---|---|
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Shared boot glue for the app bins (`dsh-stdio-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), and
|
||||
* drive the cordis Loader against a leaf `cordis.yml` until the whole tree has settled.
|
||||
* @module @deepseek-ai/dsh-app-boot
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# @deepseek-ai/dsh-stdio
|
||||
|
||||
The terminal readline front door for DeepSeek Harness agents. It reads prompts from stdin, sends or steers them through `ctx.agents`, renders the durable `session/event` transcript to stdout, and answers `ctx.userInteraction` requests in the same terminal. Failed turns render their durable `turn/end` reason — `[turn failed <code>]`, `[turn aborted]`, `[turn rejected]`, `[turn interrupted …]`, or the output-token-limit notice — so a provider or network failure is never silent; unknown merge-extended reason kinds fall through as ordinary turn ends.
|
||||
|
||||
This package owns the terminal channel only. It injects `agents` and `userInteraction`, then drives an agent created or resumed by app or developer code. The agent spine, agent lifecycle, console logger, and model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `welcome` | `ready.` | Banner printed before the first prompt |
|
||||
| `sessionId` | `main` | Exact agent/session identity driven by stdin and observed for EOF shutdown |
|
||||
|
||||
The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While an initial exact identity is pending, it buffers nonblank input until `agent/session-start` and observes live `agent-loop/config-start-failed`; a matching failure drops queued lines, reports the loss, and lets piped EOF finish instead of hanging. The composing app must mount this front door before its config-created agent. Disposal closes readline and unregisters every listener/provider through Cordis effects.
|
||||
|
||||
```yaml
|
||||
- id: stdio
|
||||
name: '@deepseek-ai/dsh-stdio'
|
||||
config:
|
||||
welcome: 'agent REPL ready. Give it a coding task.'
|
||||
sessionId: main
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Readline prompt input
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. A replacement `tool/result` remains model-visible through the session surface but is not rendered as a second execution; stdio keeps the original full-fidelity result line.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Terminal user-interaction answers
|
||||
|
||||
#### What the model sees
|
||||
|
||||
When a consumer calls `ctx.userInteraction.ask()`, this provider renders the question in the terminal and returns selected option labels or `custom` text. Through `dsh-tool-ask-user`, closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`; disposal or abort becomes `Error: ask_user_question was interrupted before the user answered`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Waiting and terminal prompts add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One configured session receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `sessionId` rather than routing by the visible label.
|
||||
- **Terminal questions are text-only and sequential** — the provider queues asks, supports option labels plus custom text, and has no richer UI shapes such as file pickers or diff previews.
|
||||
- **Closed stdin ends the terminal channel** — EOF rejects active or queued questions and exits after submitted work reaches idle; there is no reconnect path for a long-lived process.
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-stdio",
|
||||
"description": "Terminal readline front door for driving and rendering DeepSeek Harness agents over stdio",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@deepseek-ai/dsh-agent-loop": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
/**
|
||||
* The stdio app's readline UI: reads lines from stdin into `agent.send()` or
|
||||
* `steer()`, renders the durable event stream to stdout, buffers startup input
|
||||
* for one exact agent/session identity, and exits piped input only after
|
||||
* submitted work reaches idle.
|
||||
*
|
||||
* This package is the independently composable stdio front door. It establishes
|
||||
* the terminal channel and drives an agent created or resumed by app or
|
||||
* developer code.
|
||||
* @module @deepseek-ai/dsh-stdio
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-agent-loop'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionAnswer,
|
||||
type AskUserQuestionAnswerItem,
|
||||
type AskUserQuestionItem,
|
||||
type AskUserQuestionOption,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'ui-stdio'
|
||||
export const inject = ['agents', 'userInteraction']
|
||||
|
||||
/** Serializable plugin configuration (cordis-native, schemastery). */
|
||||
export interface Config {
|
||||
/** Banner printed once on start, before the first `> ` prompt. */
|
||||
welcome?: string
|
||||
/** Exact shared agent/session identity stdin drives. Defaults to `'main'`. */
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
welcome: z.string().default('ready.'),
|
||||
sessionId: z.string().default('main'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Process-I/O seam — the side-effecting handles the plugin would otherwise
|
||||
* reach for as globals. Defaulted to the real `process` streams in
|
||||
* {@link apply}; injected by tests so the EOF, render, and disposal branches
|
||||
* are exercised without hijacking globals. Deliberately NOT part of the
|
||||
* serializable {@link Config} (streams/functions don't belong in YAML config).
|
||||
*/
|
||||
export interface StdioRuntime {
|
||||
/** Line source (default `process.stdin`). */
|
||||
input: Readable
|
||||
/** Render sink (default `process.stdout`). */
|
||||
output: Writable
|
||||
/** Process-exit hook (default `process.exit`); called once on stdin EOF. */
|
||||
exit: (code: number) => void
|
||||
}
|
||||
|
||||
function isTTYPair(input: Readable, output: Writable): boolean {
|
||||
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
|
||||
}
|
||||
|
||||
interface PendingQuestion {
|
||||
request: AskUserQuestionRequest
|
||||
questionIndex: number
|
||||
answers: AskUserQuestionAnswerItem[]
|
||||
resolve(answer: AskUserQuestionAnswer): void
|
||||
reject(error: unknown): void
|
||||
onAbort: () => void
|
||||
}
|
||||
|
||||
type OptionSelection =
|
||||
| { kind: 'selected'; options: AskUserQuestionOption[] }
|
||||
| { kind: 'custom' }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
/**
|
||||
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
|
||||
* production wrapper that binds the real `process` streams; tests call this
|
||||
* directly with fakes. Returns nothing — all registration is via `ctx.on`/
|
||||
* `ctx.effect`, so fiber disposal tears every listener and the readline
|
||||
* interface down.
|
||||
* @param ctx - the context supplying the `agents` service and the event feeds.
|
||||
* @param config - the plugin config; defaults are re-applied here for direct
|
||||
* callers that bypass Loader validation.
|
||||
* @param runtime - the process-I/O seam (line source, render sink, exit hook).
|
||||
*/
|
||||
export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void {
|
||||
// Default here too (not just via schemastery's `.default()`): this helper is
|
||||
// exported and called directly by tests / programmatic consumers that bypass
|
||||
// Loader validation, so it must be self-contained rather than trusting the
|
||||
// cast — `config.welcome as string` would otherwise be `undefined` on `{}`.
|
||||
const welcome = config.welcome ?? 'ready.'
|
||||
const sessionId = SessionId(config.sessionId ?? 'main')
|
||||
const { input, output, exit } = runtime
|
||||
|
||||
// Bind only to the exact identity this app passed to its config-created
|
||||
// agent. Session ids are opaque: neither a prefix nor registry order can
|
||||
// identify ownership. The root check rejects a child that somehow preempts
|
||||
// the configured id; later recreation under the same id supports loop HMR.
|
||||
const matchesConfiguredIdentity = (agent: Agent): boolean =>
|
||||
agent.id === sessionId && ctx.agents.roots().includes(agent)
|
||||
let target: Agent | undefined = ctx.agents.roots().find(agent => agent.id === sessionId)
|
||||
|
||||
// Transcript rendering off the durable `session/event` feed — the assistant
|
||||
// token stream, turn/step boundaries, tool activity, and todos all come from
|
||||
// the one canonical stream (no agent/* mirrors). A single listener over the
|
||||
// append order keeps `inReasoning` transitions deterministic across chunk and
|
||||
// boundary events.
|
||||
let inReasoning = false
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const { chunk } = event.data
|
||||
if (chunk.type === 'reasoning-delta') {
|
||||
// Dim the chain-of-thought so the final answer stands out.
|
||||
if (!inReasoning) output.write('\x1B[2m')
|
||||
inReasoning = true
|
||||
output.write(chunk.text)
|
||||
} else if (chunk.type === 'text-delta') {
|
||||
if (inReasoning) output.write('\x1B[0m\n')
|
||||
inReasoning = false
|
||||
output.write(chunk.text)
|
||||
}
|
||||
} else if (event.type === 'turn/start') {
|
||||
const label = target?.session === session ? 'main' : session.id
|
||||
output.write(`\n[${label} turn ${event.data.turn}] `)
|
||||
} else if (event.type === 'turn/end') {
|
||||
if (inReasoning) output.write('\x1B[0m')
|
||||
inReasoning = false
|
||||
// Failure reasons must reach the terminal: turn/end is the durable record
|
||||
// of an in-turn failure, and without this line a failed turn renders as
|
||||
// silence. Merge-extensible unknown kinds fall through as ordinary ends.
|
||||
const { reason } = event.data
|
||||
if (reason.kind === 'error') {
|
||||
output.write(`\n[turn failed${reason.code === undefined ? '' : ` ${reason.code}`}] ${reason.message}`)
|
||||
} else if (reason.kind === 'aborted') {
|
||||
output.write(`\n[turn aborted]${reason.reason === undefined ? '' : ` ${reason.reason}`}`)
|
||||
} else if (reason.kind === 'rejected') {
|
||||
output.write(`\n[turn rejected] ${reason.reason}`)
|
||||
} else if (reason.kind === 'max-tokens') {
|
||||
output.write('\n[turn hit the output-token limit]')
|
||||
} else if (reason.kind === 'interrupted') {
|
||||
output.write('\n[turn interrupted by a previous process exit]')
|
||||
}
|
||||
output.write('\n> ')
|
||||
} else if (event.type === 'tool/call') {
|
||||
const { name: toolName, arguments: args } = event.data
|
||||
if (inReasoning) output.write('\x1B[0m')
|
||||
inReasoning = false
|
||||
output.write(`\n [tool call] ${toolName}(${args})`)
|
||||
} else if (event.type === 'tool/result') {
|
||||
// A surface replacement changes future model context; it is not another
|
||||
// execution. Keep the original full-fidelity terminal presentation and
|
||||
// suppress duplicate output during live delivery or log replay.
|
||||
if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return
|
||||
const { content } = event.data
|
||||
const text = content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
output.write(`\n [tool result] ${text}\n `)
|
||||
} else if (event.type === 'todo/write') {
|
||||
if (inReasoning) output.write('\x1B[0m')
|
||||
inReasoning = false
|
||||
const glyph = (status: string): string =>
|
||||
status === 'completed' ? '[x]' : status === 'in_progress' ? '[~]' : '[ ]'
|
||||
const lines = event.data.todos.map(todo => ` ${glyph(todo.status)} ${todo.content}`).join('\n')
|
||||
output.write(`\n [todos]\n${lines}\n `)
|
||||
}
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
// Piped-input exit, once stdin reaches EOF:
|
||||
// - If no line ever submitted work (empty stdin, blank-only lines), exit
|
||||
// immediately — no turn will ever start, so there is nothing to wait
|
||||
// for. (Gating on an observed 'running' here would hang forever.)
|
||||
// - If work WAS submitted, exit the next time the agent settles to idle
|
||||
// AFTER having run. Later lines may steer the active turn, and consecutive
|
||||
// queued turns can share one running interval, so we don't count inputs;
|
||||
// agent.send() also does NOT synchronously flip status to
|
||||
// 'running', so requiring an observed 'running' first (`sawRunning`)
|
||||
// avoids exiting in the gap before the turn starts and dropping work.
|
||||
let stdinClosed = false
|
||||
let disposed = false
|
||||
let submittedWork = false
|
||||
let sawRunning = false
|
||||
let exitTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let activeQuestion: PendingQuestion | undefined
|
||||
const questionQueue: PendingQuestion[] = []
|
||||
const queuedInput: string[] = []
|
||||
let targetReady = target !== undefined
|
||||
let hadReadyTarget = targetReady
|
||||
let failedStartup: { error: unknown } | undefined
|
||||
|
||||
const submit = (agent: Agent, text: string): void => {
|
||||
submittedWork = true
|
||||
if (agent.status === 'running') {
|
||||
agent.steer([{ type: 'text', text }])
|
||||
} else {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
}
|
||||
|
||||
const disposeCreatedListener = ctx.on('agent/created', (agent) => {
|
||||
if (!matchesConfiguredIdentity(agent)) return
|
||||
target = agent
|
||||
targetReady = false
|
||||
failedStartup = undefined
|
||||
})
|
||||
const disposeSessionStartListener = ctx.on('agent/session-start', (agent) => {
|
||||
if (agent !== target) return
|
||||
targetReady = true
|
||||
hadReadyTarget = true
|
||||
for (const text of queuedInput.splice(0)) submit(agent, text)
|
||||
})
|
||||
const disposeDisposedListener = ctx.on('agent/disposed', (agent) => {
|
||||
if (target !== agent) return
|
||||
target = undefined
|
||||
targetReady = false
|
||||
})
|
||||
const reader = createInterface({ input, output, terminal: isTTYPair(input, output) })
|
||||
|
||||
const maybeExit = (): void => {
|
||||
if (disposed || !stdinClosed) return
|
||||
// No work submitted: nothing will ever run, exit straight away.
|
||||
// Work submitted: wait until a turn has run and the agent is idle.
|
||||
if (submittedWork) {
|
||||
if (!sawRunning) return
|
||||
const agent = target
|
||||
if (agent && agent.status !== 'idle') return // a turn is still running
|
||||
}
|
||||
// Let any final output flush, then exit. The handle is tracked so the
|
||||
// disposer can cancel it — a dispose within the flush window must not let
|
||||
// the process exit out from under HMR. Re-entrant `maybeExit` calls (e.g.
|
||||
// repeated idle signals) coalesce onto the one pending timer.
|
||||
if (exitTimer !== undefined) {
|
||||
return // exit already scheduled — coalesce re-entrant calls
|
||||
}
|
||||
exitTimer = setTimeout(() => { exit(0) }, 200)
|
||||
}
|
||||
|
||||
const disposeStartupFailedListener = ctx.on('agent-loop/config-start-failed', (failedSessionId, error) => {
|
||||
if (failedSessionId !== sessionId || targetReady) return
|
||||
failedStartup = { error }
|
||||
const dropped = queuedInput.length
|
||||
queuedInput.length = 0
|
||||
submittedWork = sawRunning
|
||||
if (dropped > 0) {
|
||||
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${errorChain(error)}`)
|
||||
}
|
||||
maybeExit()
|
||||
})
|
||||
|
||||
const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject !== target) return
|
||||
if (status === 'running') sawRunning = true
|
||||
if (status === 'idle') maybeExit()
|
||||
})
|
||||
|
||||
const activeQuestionItem = (pending: PendingQuestion): AskUserQuestionItem =>
|
||||
pending.request.questions[pending.questionIndex] as AskUserQuestionItem
|
||||
|
||||
const renderQuestion = (pending: PendingQuestion): void => {
|
||||
const question = activeQuestionItem(pending)
|
||||
const options = question.options ?? []
|
||||
output.write('\n')
|
||||
output.write(question.header ? `[${question.header}] ${question.question}\n` : `${question.question}\n`)
|
||||
options.forEach((option, index) => {
|
||||
output.write(` ${index + 1}. ${option.label}\n`)
|
||||
if (option.description) output.write(` ${option.description}\n`)
|
||||
})
|
||||
output.write('> ')
|
||||
}
|
||||
|
||||
const removeAbortListener = (pending: PendingQuestion): void => {
|
||||
pending.request.signal?.removeEventListener('abort', pending.onAbort)
|
||||
}
|
||||
|
||||
const startNextQuestion = (): void => {
|
||||
if (activeQuestion !== undefined) return
|
||||
const pending = questionQueue.shift()
|
||||
if (pending === undefined) return
|
||||
// The queue never contains an aborted pending ask: the seam rejects an
|
||||
// already-aborted request synchronously, and queued asks attach their
|
||||
// abort listener before enqueueing.
|
||||
activeQuestion = pending
|
||||
renderQuestion(pending)
|
||||
}
|
||||
|
||||
const disposeQuestion = (pending: PendingQuestion): void => {
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'))
|
||||
}
|
||||
|
||||
const disposePendingQuestions = (): void => {
|
||||
if (activeQuestion !== undefined) {
|
||||
disposeQuestion(activeQuestion)
|
||||
activeQuestion = undefined
|
||||
}
|
||||
for (const pending of questionQueue.splice(0)) {
|
||||
disposeQuestion(pending)
|
||||
}
|
||||
}
|
||||
|
||||
const finishQuestion = (pending: PendingQuestion): void => {
|
||||
activeQuestion = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.resolve({ answers: pending.answers })
|
||||
output.write('\n')
|
||||
startNextQuestion()
|
||||
}
|
||||
|
||||
const answerCurrentQuestion = (pending: PendingQuestion, answer: AskUserQuestionAnswerItem): void => {
|
||||
pending.answers.push(answer)
|
||||
pending.questionIndex += 1
|
||||
if (pending.questionIndex >= pending.request.questions.length) {
|
||||
finishQuestion(pending)
|
||||
return
|
||||
}
|
||||
renderQuestion(pending)
|
||||
}
|
||||
|
||||
const selectedOptions = (text: string, options: AskUserQuestionOption[], multiSelect: boolean): OptionSelection => {
|
||||
if (text === '') return { kind: 'invalid' }
|
||||
if (!multiSelect) {
|
||||
if (!/^\d+$/.test(text)) return { kind: 'custom' }
|
||||
const selected = options[Number(text) - 1]
|
||||
return selected === undefined ? { kind: 'invalid' } : { kind: 'selected', options: [selected] }
|
||||
}
|
||||
const indices = text.split(/[,\s]+/).filter(Boolean)
|
||||
if (indices.length === 0) return { kind: 'invalid' }
|
||||
if (indices.some(part => !/^\d+$/.test(part))) return { kind: 'custom' }
|
||||
const uniqueIndices = [...new Set(indices)]
|
||||
const selected = uniqueIndices.map(part => options[Number(part) - 1])
|
||||
return selected.some(option => option === undefined)
|
||||
? { kind: 'invalid' }
|
||||
: { kind: 'selected', options: selected as AskUserQuestionOption[] }
|
||||
}
|
||||
|
||||
const answerQuestion = (line: string): void => {
|
||||
const pending = activeQuestion as PendingQuestion
|
||||
const question = activeQuestionItem(pending)
|
||||
|
||||
const text = line.trim()
|
||||
const options = question.options ?? []
|
||||
const selection = options.length > 0
|
||||
? selectedOptions(text, options, question.multiSelect ?? false)
|
||||
: { kind: text === '' ? 'invalid' : 'custom' } as OptionSelection
|
||||
if (selection.kind === 'selected') {
|
||||
answerCurrentQuestion(pending, { id: question.id, selected: selection.options.map(option => option.label) })
|
||||
return
|
||||
}
|
||||
|
||||
if (selection.kind === 'custom' && text !== '') {
|
||||
answerCurrentQuestion(pending, { id: question.id, selected: [], custom: text })
|
||||
return
|
||||
}
|
||||
|
||||
output.write(options.length > 0
|
||||
? 'Please enter one of the option numbers'
|
||||
+ (question.multiSelect ? ' (comma or space separated)' : '')
|
||||
+ ' or a custom answer'
|
||||
+ '.\n> '
|
||||
: 'Please enter an answer.\n> ')
|
||||
}
|
||||
|
||||
const disposeUserInteractionProvider = ctx.userInteraction.registerProvider({
|
||||
ask(request) {
|
||||
if (disposed || stdinClosed) {
|
||||
return Promise.reject(
|
||||
new UserInteractionError('ask_user_question cannot be answered because stdin is closed', 'ASK_ABORTED'),
|
||||
)
|
||||
}
|
||||
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
|
||||
const pending: PendingQuestion = {
|
||||
request,
|
||||
questionIndex: 0,
|
||||
answers: [],
|
||||
resolve,
|
||||
reject,
|
||||
onAbort: () => {
|
||||
if (activeQuestion === pending) {
|
||||
activeQuestion = undefined
|
||||
disposeQuestion(pending)
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
// If it is not active, this listener can only fire while the ask
|
||||
// remains queued; settled asks remove the listener first.
|
||||
questionQueue.splice(questionQueue.indexOf(pending), 1)
|
||||
disposeQuestion(pending)
|
||||
},
|
||||
}
|
||||
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
|
||||
questionQueue.push(pending)
|
||||
startNextQuestion()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
reader.on('line', (line) => {
|
||||
if (activeQuestion !== undefined) {
|
||||
answerQuestion(line)
|
||||
return
|
||||
}
|
||||
const text = line.trim()
|
||||
if (!text) return
|
||||
if (failedStartup !== undefined) {
|
||||
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${errorChain(failedStartup.error)}`)
|
||||
return
|
||||
}
|
||||
const agent = target
|
||||
if (agent === undefined || !targetReady) {
|
||||
// Initial exact-id restoration is asynchronous. Preserve input until
|
||||
// session-start, the first supported point for queueing agent work.
|
||||
// After a previously ready target disappears, a line in the HMR gap
|
||||
// still fails loud unless its exact replacement is already publishing.
|
||||
if (!hadReadyTarget || agent !== undefined) {
|
||||
submittedWork = true
|
||||
queuedInput.push(text)
|
||||
return
|
||||
}
|
||||
ctx.logger.error('ui-stdio: main agent is not running')
|
||||
return
|
||||
}
|
||||
submit(agent, text)
|
||||
})
|
||||
reader.on('close', () => {
|
||||
// Fires for BOTH stdin EOF and plugin disposal (reader.close() below);
|
||||
// `disposed` guards teardown so HMR/dispose never exits the process.
|
||||
stdinClosed = true
|
||||
if (!disposed) disposePendingQuestions()
|
||||
maybeExit()
|
||||
})
|
||||
output.write(`${welcome}\n> `)
|
||||
return () => {
|
||||
disposed = true
|
||||
if (exitTimer !== undefined) clearTimeout(exitTimer)
|
||||
disposePendingQuestions()
|
||||
disposeUserInteractionProvider()
|
||||
disposeStatusListener()
|
||||
disposeCreatedListener()
|
||||
disposeSessionStartListener()
|
||||
disposeDisposedListener()
|
||||
disposeStartupFailedListener()
|
||||
reader.close()
|
||||
}
|
||||
}, 'ui-stdio')
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the terminal channel for one exact identity. The chat registers before
|
||||
* that agent necessarily exists so it can buffer startup input and observe a
|
||||
* config-start failure instead of leaving piped stdin hanging.
|
||||
* @param ctx - the context supplying the agent registry and event stream.
|
||||
* @param config - presentation and target-agent configuration.
|
||||
* @param runtime - process-I/O seam.
|
||||
*/
|
||||
export function mountStdio(ctx: Context, config: Config, runtime: StdioRuntime): void {
|
||||
createStdioChat(ctx, config, runtime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cordis entry point. Binds the real `process` streams and delegates to
|
||||
* {@link mountStdio}; the indirection keeps the side-effecting handles out
|
||||
* of the testable core, which is why the unit suite drives `createStdioChat`
|
||||
* directly. This thin wrapper is exercised end-to-end by the keyless
|
||||
* Loader-path e2e smoke in `examples/echo-agent` (the real product entry).
|
||||
*/
|
||||
/* v8 ignore start -- production stdio wiring; testable core is createStdioChat() (covered), exercised e2e by echo-agent keyless smoke */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
mountStdio(ctx, config, {
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
exit: code => process.exit(code),
|
||||
})
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import * as stdio from '../src/index.ts'
|
||||
|
||||
/** Real Loader export-path guard for the namespace stdio plugin. */
|
||||
describe('dsh-stdio plugin export shape', () => {
|
||||
it('preserves name, inject, Config, and apply through Loader unwrapping', () => {
|
||||
expect('default' in stdio).toBe(false)
|
||||
expect(typeof stdio.apply).toBe('function')
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(stdio) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(stdio)
|
||||
expect(unwrapped.name).toBe('ui-stdio')
|
||||
expect(unwrapped.inject).toEqual(['agents', 'userInteraction'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { StdioRuntime } from '../src/index.ts'
|
||||
|
||||
const createInterface = vi.hoisted(() => vi.fn(() => {
|
||||
const reader = new EventEmitter() as EventEmitter & { close(): void }
|
||||
reader.close = vi.fn()
|
||||
return reader
|
||||
}))
|
||||
|
||||
vi.mock('node:readline', () => ({ createInterface }))
|
||||
|
||||
function fakeContext(): Context {
|
||||
return {
|
||||
on: vi.fn(() => vi.fn()),
|
||||
effect: vi.fn((callback: () => () => void) => callback()),
|
||||
// The UI seeds its root target from the registry at install; this suite only
|
||||
// exercises readline terminal-mode selection, so an empty roster suffices.
|
||||
agents: { roots: vi.fn(() => []) },
|
||||
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime {
|
||||
return {
|
||||
input: { isTTY: inputIsTTY } as Readable & { isTTY: boolean },
|
||||
output: { isTTY: outputIsTTY, write: vi.fn(() => true) } as unknown as Writable & { isTTY: boolean },
|
||||
exit: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('createStdioChat readline mode', () => {
|
||||
it('enables terminal editing only when both stdio streams are TTYs', async () => {
|
||||
const { createStdioChat } = await import('../src/index.ts')
|
||||
|
||||
const tty = fakeRuntime(true, true)
|
||||
createStdioChat(fakeContext(), {}, tty)
|
||||
expect(createInterface).toHaveBeenLastCalledWith({
|
||||
input: tty.input,
|
||||
output: tty.output,
|
||||
terminal: true,
|
||||
})
|
||||
|
||||
const piped = fakeRuntime(true, false)
|
||||
createStdioChat(fakeContext(), {}, piped)
|
||||
expect(createInterface).toHaveBeenLastCalledWith({
|
||||
input: piped.input,
|
||||
output: piped.output,
|
||||
terminal: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-loop"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-tui
|
||||
|
||||
The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should compose [`@deepseek-ai/dsh-stdio`](../stdio/README.md) instead.
|
||||
The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the headless [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead.
|
||||
|
||||
The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy.
|
||||
|
||||
@@ -77,4 +77,4 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`.
|
||||
- **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering.
|
||||
- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must select `dsh-stdio` before mounting this plugin rather than expecting an internal fallback.
|
||||
- **Non-TTY operation is intentionally unsupported** — automation must use the headless app rather than expecting an internal fallback.
|
||||
|
||||
@@ -1336,10 +1336,10 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi
|
||||
|
||||
/** 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 repl-agent PTY smoke covers the real entry */
|
||||
and the tui-agent PTY smoke covers 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 @deepseek-ai/dsh-stdio for pipes')
|
||||
throw new Error('ui-tui: both stdin and stdout must be TTYs; use @deepseek-ai/dsh-cli-demo for non-interactive runs')
|
||||
}
|
||||
mountTui(ctx, config, {
|
||||
terminal: new ProcessTerminal(),
|
||||
|
||||
@@ -21,7 +21,7 @@ When an answer includes `custom`, `selected` is empty; custom text is an overrid
|
||||
|
||||
## Role
|
||||
|
||||
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the interactive `dsh-tui`, line-oriented `dsh-stdio`, and structured `dsh-acp` channels provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.
|
||||
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; the interactive `dsh-tui` and structured `dsh-acp` front doors provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
Reference in New Issue
Block a user