Merge branch 'codex/goal-commands' into codex/ralph-tool

# Conflicts:
#	examples/repl-agent/README.md
#	examples/repl-agent/composition.md
#	examples/repl-agent/cordis.yml
This commit is contained in:
Tianyi Cui
2026-07-20 22:25:16 +08:00
249 changed files with 2921 additions and 4563 deletions

View File

@@ -32,7 +32,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 |

View File

@@ -23,7 +23,9 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
if (sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot })
if (sessionRoot !== undefined) {
await ctx.plugin(SessionPersistenceJsonl, { root: sessionRoot, compression: 'none' })
}
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)

View File

@@ -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)
})

View File

@@ -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}]`

View File

@@ -10,7 +10,7 @@ import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
import { Inbox, type InboxMessage } from './inbox.ts'
@@ -80,7 +80,6 @@ export function prepareReactLoopAgent(
},
}
}
/**
* Install the concrete agent's scope context exactly once. Construction and
* scope minting are mutually referential (the scope key is the agent), so the
@@ -290,7 +289,7 @@ export class ReactLoopAgent implements Agent {
if (turnRecorded) {
// Through the store's flush (the carrier owner), never a raw parallel.
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
const rendered = renderThrown(error)
const rendered = errorChain(error)
const err = error instanceof Error ? error : new Error(rendered)
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
@@ -449,8 +448,3 @@ export class ReactLoopAgent implements Agent {
}
}
}
/** Render an ordinary thrown value for the error event and log. */
function renderThrown(value: unknown): string {
return value instanceof Error ? value.message : String(value)
}

View File

@@ -20,7 +20,7 @@ import type {
ResumeAgentOptions,
SessionStartSource,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -41,15 +41,6 @@ const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
FiberState.FAILED,
])
/** Render an arbitrary thrown value without letting coercion escape containment. */
function renderThrown(value: unknown): string {
try {
return String(value)
} catch {
return '<unrenderable thrown value>'
}
}
/** Factory-level ownership of every preparing or live transaction. */
class FactoryOwnership {
private accepting = true
@@ -475,16 +466,16 @@ export class AgentLoop extends Service implements AgentFactory {
error: unknown,
): void {
if (!this.ownership.isActive()) return
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${renderThrown(error)}`)
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`)
const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error]
for (const callback of this.ctx.events.dispatch('emit', args)) {
try {
const returned: unknown = callback(...args)
void Promise.resolve(returned).catch((listenerError: unknown) => {
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${renderThrown(listenerError)}`)
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`)
})
} catch (listenerError: unknown) {
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${renderThrown(listenerError)}`)
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`)
}
}
}

View File

@@ -8,7 +8,7 @@
import type { Context } from 'cordis'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
import { isDeepStrictEqual } from 'node:util'
import { BlockAssembler, HarnessError, assertNever, deepFreeze, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm'
import { BlockAssembler, HarnessError, assertNever, deepFreeze, errorChain, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm'
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
@@ -56,9 +56,12 @@ function finishError(finish: FinishReason): RequestError | undefined {
/**
* Build the `{ message, code? }` part of an error payload, omitting the
* `code` key entirely when absent (exactOptionalPropertyTypes-correct).
* The durable message renders the full cause chain: `turn/end` is the single
* durable record of an in-turn failure, so a wrapper message alone (e.g.
* `fetch failed`) would lose the diagnosis the session log exists to keep.
*/
function errorData(err: RequestError): { message: string; code?: string } {
return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }
}
/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
@@ -166,7 +169,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
} catch (error: unknown) {
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${errorChain(err)}`)
try {
events.emit('agent/error', turn, 0, err)
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
@@ -382,7 +385,7 @@ async function runTurn(
)
} catch (recoveryError: unknown) {
ctx.logger.warn(
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${toError(recoveryError).message}`,
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
)
}
handle.setAbort(undefined)
@@ -546,7 +549,7 @@ async function runTurn(
} catch (error: unknown) {
// The turn is closed, so report the failed flush live rather than append outside a turn.
const err = toError(error)
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${errorChain(err)}`)
try {
events.emit('agent/error', turn, step, err)
} catch {

View File

@@ -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: Error: 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: Error: failure observer failed',
'agent "main": config-start-failed listener threw: failure observer failed',
)
await expect.poll(() => warn).toHaveBeenCalledWith(
'agent "main": config-start-failed listener rejected: Error: async failure observer failed',
'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,18 +251,18 @@ 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 thrown value>',
'agent "main": config-driven restore of "config-exact-unrenderable" failed: <unrenderable value>',
)
expect(warn).toHaveBeenCalledWith(
'agent "main": config-start-failed listener threw: <unrenderable thrown value>',
'agent "main": config-start-failed listener threw: <unrenderable value>',
)
await expect.poll(() => warn).toHaveBeenCalledWith(
'agent "main": config-start-failed listener rejected: <unrenderable thrown value>',
'agent "main": config-start-failed listener rejected: <unrenderable value>',
)
await ctx.fiber.dispose()
})
@@ -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()

View File

@@ -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, with an opt-in persisted-goal stack |
| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + persisted goals + `/goal` command + 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 + persisted goals + `/goal` command + 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 + persisted goals + `/goal` command + 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.

View File

@@ -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
@@ -21,7 +21,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
@@ -40,6 +40,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay), a bash executor, and optionally a `ctx.fs` provider. Workspace context becomes a no-op without `ctx.fs`; the shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) selects `dsh-sandbox-policy`, `dsh-fs-sandbox`, `dsh-fs-policy`, and `dsh-tool-fs` so baseline instructions and model-facing `read`/`write`/`edit` share one provider, sandbox mode, workspace root, and observed-version policy.

View File

@@ -17,7 +17,10 @@ import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionPersistenceJsonl, {
JsonlCompressionSchema,
type JsonlCompression,
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
export const name = 'acp-demo'
@@ -49,6 +52,8 @@ export interface Config {
dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
@@ -76,6 +81,7 @@ export const Config: z<Config> = z.object({
tools: ToolRegistry.Config,
dshHome: z.string(),
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
persistenceCompression: JsonlCompressionSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
@@ -97,6 +103,9 @@ export function apply(ctx: Context, config: Config): void {
if (goals !== false) ctx.plugin(commandGoal)
ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals })
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
})
ctx.plugin(acp, { provider: config.provider, model: config.model })
}

View File

@@ -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`;
@@ -70,10 +70,19 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
describe('dsh-acp-demo composition', () => {
it('brings up the spine + persistence + the ACP bridge', async () => {
const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-demo-test', skills: await isolatedSkillsConfig(), workspaceContext: false })
const ctx = await mount({
provider: 'mock',
model: 'mock',
persona: 'hi',
persistenceRoot: '/tmp/dsh-acp-demo-test',
persistenceCompression: 'none',
skills: await isolatedSkillsConfig(),
workspaceContext: false,
})
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none')
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()

View File

@@ -1,5 +1,5 @@
import { spawn } from 'node:child_process'
import { mkdtemp, mkdir, rm, symlink, writeFile, readFile } from 'node:fs/promises'
import { 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'
@@ -15,21 +15,24 @@ import {
type SessionNotification,
} from '@agentclientprotocol/sdk'
import { Readable, Writable } from 'node:stream'
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 a valid initialize response. This catches built-only settle races and stdout protocol
* leaks that the tsx source-path smoke cannot. It skips before build; initialize is keyless, with a
* dummy key used only to boot the adapter. `--expose-internals` enables Cordis bare-plugin loading.
* complete a mock-backed turn. This catches built-only settle races, stdout protocol leaks, and
* published persistence behavior that the tsx source-path smoke cannot. It skips before build;
* `--expose-internals` enables Cordis bare-plugin loading.
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const acpBin = join(repoRoot, 'packages/examples/acp-demo/lib/bin.js')
const decompress = promisify(zstdDecompress)
const dshPackages = [
'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', 'util/paths',
@@ -73,18 +76,31 @@ async function makeConsumer(): Promise<string> {
const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp))
await link(dirname(resolved), dep, nm)
}
await writeFile(join(dir, 'mock-llm.mjs'), [
"import { LlmAdapter } from '@deepseek-ai/dsh-llm'",
'class Mock extends LlmAdapter {',
' async * stream() {',
" yield { type: 'block-start', index: 0, blockType: 'text' }",
" yield { type: 'text-delta', index: 0, text: 'ACP BUILT OK' }",
" yield { type: 'block-end', index: 0, block: { type: 'text', text: 'ACP BUILT OK' } }",
" yield { type: 'finish', reason: { kind: 'stop' } }",
' }',
'}',
"export const name = 'built-acp-mock'",
"export const inject = ['llm']",
"export function apply(ctx) { ctx.llm.registerAdapter(['built-acp-mock'], new Mock()) }",
'',
].join('\n'))
await writeFile(join(dir, 'cordis.yml'), [
'- id: llm-deepseek',
' name: \'@deepseek-ai/dsh-llm-deepseek\'',
' config:',
' apiKey: !!js process.env.DEEPSEEK_API_KEY',
'- id: mock-llm',
' name: \'./mock-llm.mjs\'',
'- id: bash',
' name: \'@deepseek-ai/dsh-bash-local\'',
'- id: acp-agent',
' name: \'@deepseek-ai/dsh-acp-demo\'',
' config:',
' provider: deepseek',
' model: deepseek-v4-flash',
' provider: built-acp-mock',
' model: built-acp-mock',
' persona: \'test agent\'',
' workspaceContext: false',
'',
@@ -113,14 +129,12 @@ afterEach(async () => {
})
describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, no tsx)', () => {
it('boots the published bin and answers an initialize JSON-RPC frame on stdout', async () => {
it('boots the published bin, completes a turn, and writes default Zstandard persistence', async () => {
consumer = await makeConsumer()
child = spawn(process.execPath, ['--expose-internals', acpBin, '--config', './cordis.yml'], {
cwd: consumer,
// Dummy key: initialize never reaches the model, so it is never used.
env: {
...process.env,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
DSH_HOME: join(consumer, '.dsh'),
DSH_AGENTS_HOME: join(consumer, '.agents'),
},
@@ -151,6 +165,18 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
// regression would exit before answering); loadSession proves the real app
// mounted, not a collapsed export shape.
expect(init.agentCapabilities?.loadSession).toBe(true)
const { sessionId } = await client.newSession({ cwd: consumer, mcpServers: [] })
const result = await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'reply' }] })
expect(result.stopReason).toBe('end_turn')
const sessionsRoot = join(consumer, '.sessions')
let log: string | undefined
await expect.poll(async () => {
log = (await readdir(sessionsRoot, { recursive: true })).find(file => file.endsWith('.jsonl.zstd'))
return log
}).toBeTypeOf('string')
const compressed = await readFile(join(sessionsRoot, log!))
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: sessionId })
expect(stderr.join('')).not.toContain('without inject')
// stdout purity: every emitted line is a JSON-RPC frame, no logger leak.
for (const line of rawOut.join('').split('\n').filter(l => l.trim().length > 0)) {
@@ -182,7 +208,6 @@ function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise
cwd,
env: {
...process.env,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
},

View File

@@ -37,7 +37,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.
@@ -49,7 +49,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); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. 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.
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); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. 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.
## Why a code bundle, not a shared YAML include

View File

@@ -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
@@ -19,6 +19,7 @@ The package mounts no console logger, readline UI, user-interaction service, or
| `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in |
| `toolTasks` | owner defaults | generic `task_output` wait bounds |
| `persistenceRoot` | `./.sessions` | JSONL session root |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
| `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading |
## CLI contract
@@ -32,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.

View File

@@ -11,7 +11,10 @@ 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 SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionPersistenceJsonl, {
JsonlCompressionSchema,
type JsonlCompression,
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
@@ -36,6 +39,8 @@ export interface Config {
dshHome?: string
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
/** Skill registry, local-provider, and model-facing consumer config. */
skills?: agentCore.SkillConfig
/** Model-facing bash tool config forwarded through agent-spine-demo. */
@@ -54,6 +59,7 @@ export const Config: z<Config> = z.object({
model: z.string().required(),
maxParallelToolCalls: z.number().step(1).min(1),
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
persistenceCompression: JsonlCompressionSchema,
persona: z.string(),
dshHome: z.string(),
skills: agentCore.SkillConfigSchema,
@@ -78,5 +84,8 @@ export function apply(ctx: Context, config: Config): void {
...agentCore.pickSpineConfig(config),
agents: [{ id: SessionId('main'), provider: config.provider, model: config.model, cwd: process.cwd() }],
})
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
ctx.plugin(SessionPersistenceJsonl, {
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
})
}

View File

@@ -3,11 +3,14 @@ import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
import { zstdDecompress } from 'node:zlib'
import { afterEach, describe, expect, it } from 'vitest'
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js')
const decompress = promisify(zstdDecompress)
const dshPackages = [
'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session',
'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
@@ -140,8 +143,13 @@ describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => {
const lines = stream.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
expect(lines[0]).toMatchObject({ type: 'session_event', event: { type: 'turn/start' } })
expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, result: 'BUILT: stream task' })
const files = await readdir(join(consumer, '.sessions'), { recursive: true })
expect(files.filter(file => file.endsWith('.jsonl'))).toHaveLength(3)
const sessionsRoot = join(consumer, '.sessions')
const files = await readdir(sessionsRoot, { recursive: true })
const logs = files.filter(file => file.endsWith('.jsonl.zstd'))
expect(logs).toHaveLength(3)
const compressed = await readFile(join(sessionsRoot, logs[0]!))
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session' })
}, 30_000)
it('keeps stdout empty for invalid argv and missing config', async () => {

View File

@@ -51,12 +51,14 @@ describe('dsh-cli-demo app composition', () => {
persona: 'Headless.',
tools: { mode: 'native' },
persistenceRoot: root,
persistenceCompression: 'none',
skills: await skillConfig(),
workspaceContext: false,
})
const [agent] = ctx.get('agents')?.roots() ?? []
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none')
expect(agent?.session.header.cwd).toBe(process.cwd())
expect(ctx.get('userInteraction')).toBeUndefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()

View File

@@ -303,7 +303,7 @@ describe('runOneShot and executeCli', () => {
expect(output).toEqual({ code: 0, stdout: 'final answer\n', stderr: '' })
expect(agent.status).toBe('disposed')
const files = await readdir(persistenceRoot, { recursive: true })
expect(files.some(file => file.endsWith('.jsonl'))).toBe(true)
expect(files.some(file => file.endsWith('.jsonl.zstd'))).toBe(true)
})
it('sums usage across tool steps and selects the last text-bearing assistant message', async () => {

View File

@@ -1,116 +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-commands` | the human-command registry consumed by the TUI front door and optional command plugins |
| `@deepseek-ai/dsh-command-goal` | the direct `/goal` producer mounted only for the TUI front door; readline retains the model-mediated goal path |
| `@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` |
| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and the TUI `/goal` producer |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `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, visible tools, and the enabled goal policy/tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each ordinary terminal submission becomes a user message; submissions made while the agent runs steer the active turn. TUI commands and their direct results remain outside model context, while accepted `/goal` mutations append the goal domain's model-visible snapshot.
#### 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.
- **Direct commands require TUI mode** — the line-oriented fallback does not consume `ctx.commands`; an ordinary `/goal` prompt there may instead be interpreted through the model-facing goal tools.
- **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.

View File

@@ -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 — the command registry,
* 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 identity the selected UI drives as `main`.
* 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 CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import SessionPersistenceJsonl 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
/** 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']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and TUI command. */
goals?: agentCore.GoalConfig | false
/**
* 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),
welcome: z.string().default(DEFAULT_WELCOME),
ui: UiConfigSchema,
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
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)
const goals = config.goals ?? {}
if (mode === 'readline') ctx.plugin(ConsoleExporter)
ctx.plugin(CommandService)
if (mode === 'tui' && goals !== false) ctx.plugin(commandGoal)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT })
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),
goals,
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 */

View File

@@ -1,218 +0,0 @@
import { spawn } from 'node:child_process'
import { cp, mkdtemp, mkdir, 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 { 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')
// 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/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)
}, 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)
})

View File

@@ -1,319 +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,
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)).toContain('command-goal')
expect(calls.map(call => call.name)).not.toContain('ui-stdio')
expect(calls.map(call => call.name)).not.toContain('ConsoleExporter')
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 })
expect(spineConfig).toMatchObject({ goals: {} })
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, goals: false, ui: { mode: 'tui' },
}, true)
expect(calls.map(call => call.name)).toContain('ui-tui')
expect(calls.map(call => call.name)).not.toContain('command-goal')
expect(calls.find(call => call.name === 'agent-spine-demo')?.config).toMatchObject({ goals: false })
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')
expect(calls.map(call => call.name)).not.toContain('command-goal')
expect(calls.find(call => call.name === 'agent-spine-demo')?.config).toMatchObject({ goals: {} })
})
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, ui: { mode: 'readline' } })
// 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()
expect(ctx.get('goals')).toBeDefined()
expect(ctx.get('tools')?.get('get_goal')).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())
expect(ctx.commands.find(agent!, 'goal')).toBeUndefined()
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',
'create_goal',
'get_goal',
'skill',
'task_kill',
'task_list',
'task_output',
'update_goal',
])
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')
})
})

View File

@@ -0,0 +1,105 @@
# @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), persisted same-session goals, the human-command registry and `/goal` producer, 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-commands` | Human-only discovery and dispatch consumed by the TUI and command plugins |
| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack |
| `@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` |
| `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `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 non-command editor submission becomes a user message; a submission during a running turn becomes steering. Slash-command input and output remain human-only, while accepted `/goal` mutations append domain-owned model-visible state. The shared spine contributes the configured persona, workspace instructions, skill catalog, goal controls, 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.

View File

@@ -1,13 +1,13 @@
{
"name": "@deepseek-ai/dsh-stdio-demo",
"description": "Terminal chat app: agent spine + human commands + 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 + persisted goals + human commands + 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",
@@ -43,7 +42,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",
@@ -54,7 +52,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:^",
@@ -66,7 +63,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:^",

View File

@@ -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

View File

@@ -0,0 +1,139 @@
/**
* Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo})
* plus persisted goals, human commands, 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 CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
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 same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
/** 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]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
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()}`)
const goals = config.goals ?? {}
ctx.plugin(CommandService)
if (goals !== false) ctx.plugin(commandGoal)
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),
goals,
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)
}

View File

@@ -0,0 +1,128 @@
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([
'CommandService',
'command-goal',
'SessionPersistenceJsonl',
'UserInteractionService',
'ui-tui',
'agent-spine-demo',
'tool-ask-user',
])
expect(calls[0]?.config).toBeUndefined()
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
const tuiConfig = calls[4]?.config as { sessionId: string }
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
const spineConfig = calls[5]?.config as {
readonly agents: Array<Record<string, unknown>>
readonly goals: Record<string, never>
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' },
goals: {},
})
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[2]?.config).toEqual({ root: './.sessions' })
expect(calls[4]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
expect((calls[5]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
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: '',
goals: false,
workspaceContext: false,
})
const tuiConfig = calls[3]?.config as { sessionId: string }
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
expect((calls[4]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
.toMatchObject({ sessionId: tuiConfig.sessionId })
expect(calls.map(call => call.name)).not.toContain('command-goal')
expect(calls[4]?.config).toMatchObject({ goals: false })
})
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()
})
})

View File

@@ -20,9 +20,6 @@
{
"path": "../../ui/app-boot"
},
{
"path": "../../../vendor/logger-console"
},
{
"path": "../../core/agent"
},
@@ -44,9 +41,6 @@
{
"path": "../../ui/user-interaction"
},
{
"path": "../../ui/stdio"
},
{
"path": "../../ui/tui"
},

View File

@@ -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),

View File

@@ -30,7 +30,7 @@ The producer injects `commands` and `goals`. A custom app mounts their owners pl
name: '@deepseek-ai/dsh-command-goal'
```
The TUI and ACP demo apps enable the complete persisted-goal stack and this command by default; `goals: false` removes both. The terminal app's readline mode keeps the model-mediated goal stack but does not mount this producer because that front door does not consume commands. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation.
The TUI and ACP demo apps enable the complete persisted-goal stack and this command by default; `goals: false` removes both. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation.
## Model Experience
@@ -53,4 +53,4 @@ Command discovery and direct output do not affect the cache. A mutation appends
- **Plain-text interaction only** — the generic command registry has no modal edit form or replacement-confirmation callback; inline edit and explicit clear keep destructive intent deterministic on both TUI and ACP.
- **No per-command round-cap argument** — `defaultMaxGoalRounds` remains deployment config, while a direct human request may ask the model to edit `max_goal_rounds` through the separately authorized goal tool.
- **No continuous status widget** — bare `/goal` is the portable observation surface; adapter-specific badges and reconnectable command output remain future UI work.
- **TUI and ACP only** — the line-oriented stdio and JSON-RPC adapters do not consume `ctx.commands`. Their ordinary human prompts can still authorize the model-facing goal tools when those are composed.
- **TUI and ACP only** — the headless CLI and JSON-RPC adapters do not consume `ctx.commands`. Ordinary human prompts can still authorize the model-facing goal tools when those are composed.

View File

@@ -1,32 +1,17 @@
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 { decodeGoalChange, renderGoalChange } from '@deepseek-ai/dsh-goal'
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url))
const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url))
const configPath = fileURLToPath(new URL(
'../../../../examples/echo-agent/tests/fixtures/goal/goal/cordis.yml',
'../../../../examples/headless-agent/tests/fixtures/goal-domain/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 REPLY = 'You said: "hello". Try "echo <something>" to see a tool call.'
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 })
@@ -38,67 +23,32 @@ async function jsonlFiles(dir: string): Promise<string[]> {
return paths.flat()
}
async function runOneTurn(): Promise<{ stdout: string; stderr: string }> {
workdir = await mkdtemp(join(tmpdir(), 'goal-domain-e2e-'))
const cwd = workdir
return new Promise((resolve, reject) => {
const launch = resolveExampleLaunch({
srcBin: binScript,
configArgs: [configPath],
describe('goal domain through a real cordis.yml and headless process', () => {
it('persists the Loader-mounted snapshot without starting a goal round', async () => {
let events: SessionEvent[] = []
const { stdout, stderr } = await runLoaderSmoke({
label: 'goal-domain',
tempDirPrefix: 'goal-domain-e2e-',
binScript,
configPath,
binArgs: ['--config', configPath, '--output-format', 'json', 'prove the persisted goal domain'],
tsconfigPath: repoTsconfig,
exposeInternals: true,
env: {
DSH_HOME: join(cwd, '.dsh'),
DSH_AGENTS_HOME: join(cwd, '.agents'),
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'],
expect(stderr).toBe('')
const result = JSON.parse(stdout) as Record<string, unknown>
expect(result).toMatchObject({
type: 'result',
success: true,
})
child = proc
let stdout = ''
let stderr = ''
let inputClosed = false
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', (chunk: string) => {
stdout += chunk
if (!inputClosed && stdout.includes(REPLY)) {
inputClosed = true
proc.stdin.end()
}
})
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (chunk: string) => { stderr += chunk })
const timer = setTimeout(() => {
proc.kill('SIGKILL')
reject(new Error(`goal-domain 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(`goal-domain e2e exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`))
})
proc.on('error', (error) => { clearTimeout(timer); reject(error) })
proc.stdin.write('hello\n')
})
}
describe('goal domain through a real cordis.yml and stdio process', () => {
it('persists the Loader-created snapshot without starting a goal round', async () => {
const { stdout, stderr } = await runOneTurn()
expect(stderr).not.toContain('UNHANDLED')
expect(stdout).toContain('goal-domain e2e ready.')
expect(stdout).toContain(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)
expect(result['result']).toBeTypeOf('string')
expect(result['result']).toContain('CLI tool round trip complete')
expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1)
const contexts = events.filter(event => event.type === 'context/message'
&& event.data.source.kind === 'goal')
@@ -121,5 +71,5 @@ describe('goal domain through a real cordis.yml and stdio process', () => {
expect(JSON.stringify(context)).not.toContain('activation')
expect(events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'goal')).toHaveLength(0)
}, TEST_TIMEOUT_MS)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -42,7 +42,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
## Errors
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: <REASON>}` chunks.
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. A transport failure before any response (DNS, refused connection, TLS, proxy) throws `NETWORK` naming the configured endpoint and chaining fetch's `TypeError: fetch failed` as `cause`, so `errorChain` renders the underlying diagnosis; an abort keeps its `DOMException` so the loop classifies it as cancellation. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: <REASON>}` chunks.
## Testing

View File

@@ -81,23 +81,43 @@ export class DeepSeekAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const body = serializeRequest(options, this.options.defaults ?? {})
// Prepared outside the try so the NETWORK label below covers exactly the
// transport boundary, never a serialization failure.
const payload = JSON.stringify(body)
const headers = {
'authorization': `Bearer ${this.options.apiKey}`,
'content-type': 'application/json',
'accept': 'text/event-stream',
...attributionHeaders(),
...options.sessionId !== undefined
? { 'x-deepseek-harness-session-id': String(options.sessionId) }
: {},
}
// TODO(http): adopt the Cordis HTTP service when shared transport configuration
// outweighs its additional runtime dependencies.
const response = await fetch(`${this.options.baseURL}/chat/completions`, {
method: 'POST',
headers: {
'authorization': `Bearer ${this.options.apiKey}`,
'content-type': 'application/json',
'accept': 'text/event-stream',
...attributionHeaders(),
...options.sessionId !== undefined
? { 'x-deepseek-harness-session-id': String(options.sessionId) }
: {},
},
body: JSON.stringify(body),
...options.signal ? { signal: options.signal } : {},
})
let response: Response
try {
response = await fetch(`${this.options.baseURL}/chat/completions`, {
method: 'POST',
headers,
body: payload,
...options.signal ? { signal: options.signal } : {},
})
} catch (error: unknown) {
// An aborted request rethrows its original rejection (the signal's abort
// reason) so the loop classifies it as cancellation, not a provider failure.
if (options.signal?.aborted) throw error
// fetch wraps every transport failure (DNS, refused connection, TLS,
// proxy) in a bare `TypeError: fetch failed` whose actionable detail
// lives on `cause`. Wrapping with the endpoint and chaining the cause
// lets `errorChain` render the full diagnosis at every reporting seam.
throw new LlmError(
`DeepSeek API request to ${this.options.baseURL} failed`,
'NETWORK',
{ cause: error },
)
}
if (!response.ok) {
let message = `DeepSeek API error (HTTP ${response.status})`

View File

@@ -2,7 +2,7 @@ import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, errorChain, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek'
@@ -228,6 +228,39 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(httpErrorCode(418)).toBe('HTTP_418')
})
it('wraps a transport failure in NETWORK with the fetch cause chain in the message', async () => {
// Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed`
// whose actionable detail (ECONNREFUSED) lives on `cause`.
const ctx = await harness('http://127.0.0.1:1')
let caught: unknown
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
caught = error
}
expect(caught).toBeInstanceOf(LlmError)
const llmError = caught as LlmError
expect(llmError.code).toBe('NETWORK')
expect(llmError.message).toContain('http://127.0.0.1:1')
expect(llmError.cause).toBeInstanceOf(TypeError)
// The chain renderer reaches the transport diagnosis through the cause.
expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/)
})
it('keeps an abort rejection unwrapped so the loop classifies it as cancellation', async () => {
const controller = new AbortController()
controller.abort()
const ctx = await harness('http://127.0.0.1:1')
let caught: unknown
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal })
} catch (error: unknown) {
caught = error
}
expect(caught).not.toBeInstanceOf(LlmError)
expect((caught as Error).name).toBe('AbortError')
})
it('throws EMPTY_RESPONSE when the response has no body', async () => {
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(

View File

@@ -48,6 +48,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history.
- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams.
- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract.
- `errorChain(value)` — renders a thrown value with its full `cause` chain and AggregateError members for diagnostic surfaces (UI notices, logger lines, durable `turn/end` messages), so transport wrappers like undici's `TypeError: fetch failed` surface the underlying `ECONNREFUSED`/DNS/TLS detail instead of masking it. Rendering only — route on `code`, never by parsing the result.
- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail.
### Real adapters

View File

@@ -62,6 +62,51 @@ export function isContextWindowExceededError(detail: string): boolean {
|| EXCEEDS_MODEL_CONTEXT.test(detail)
}
/**
* Render a thrown value with its full `cause` chain and AggregateError
* members, so transport wrappers like undici's `TypeError: fetch failed`
* surface the underlying failure instead of masking it. Diagnostic-surface
* rendering only (messages, notices, logs) — never parse the result; route on
* {@link HarnessError.code}.
* @param value - the caught value (`unknown` in catch clauses).
* @returns the outermost message first, each cause appended with `: ` (skipped
* when it repeats the wrapper message verbatim), and AggregateError members
* bracketed and `; `-joined.
*/
export function errorChain(value: unknown): string {
// Tracks the active recursion path (entries removed on exit), so only true
// cycles are flagged and a diamond-shared cause still renders in full.
const path = new Set<unknown>()
const render = (current: unknown): string => {
if (path.has(current)) return '<circular cause>'
path.add(current)
try {
if (!(current instanceof Error)) return String(current)
const message = current.message === '' ? current.name : current.message
const members = current instanceof AggregateError && current.errors.length > 0
? ` [${current.errors.map(render).join('; ')}]`
: ''
const causeText = current.cause === undefined || current.cause === null
? ''
: render(current.cause)
// Wrappers like `new HarnessError(String(value), code, { cause: value })`
// repeat their cause verbatim; rendering it again would only add noise.
const cause = causeText === '' || causeText === message ? '' : `: ${causeText}`
return `${message}${members}${cause}`
} catch {
// Only hostile coercion or hostile accessors (a throwing toString /
// Symbol.toPrimitive on a non-Error, or a throwing message/name/cause/
// errors getter on an Error subclass): this renderer feeds UI notices
// and logs, so nothing may escape. Inner frames catch their own throws,
// so only the hostile node collapses, not the whole chain.
return '<unrenderable value>'
} finally {
path.delete(current)
}
}
return render(value)
}
/**
* Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams).
* @param value - the caught value (`unknown` in catch clauses).

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, {
errorChain,
GenerateOptions,
HarnessError,
isContextWindowExceededError,
@@ -80,6 +81,50 @@ describe('LlmService', () => {
expect(isContextWindowExceededError('context window size must be positive')).toBe(false)
})
it('errorChain renders the full cause chain of a wrapped transport failure', () => {
const chain = new TypeError('fetch failed', { cause: new Error('connect ECONNREFUSED 127.0.0.1:443') })
expect(errorChain(chain)).toBe('fetch failed: connect ECONNREFUSED 127.0.0.1:443')
})
it('errorChain renders AggregateError members (Happy Eyeballs multi-address failures)', () => {
const aggregate = new AggregateError(
[new Error('connect ECONNREFUSED ::1:443'), new Error('connect ECONNREFUSED 127.0.0.1:443')],
'',
)
const wrapped = new TypeError('fetch failed', { cause: aggregate })
expect(errorChain(wrapped)).toBe(
'fetch failed: AggregateError [connect ECONNREFUSED ::1:443; connect ECONNREFUSED 127.0.0.1:443]',
)
})
it('errorChain survives non-Error values, hostile coercion, and circular causes', () => {
expect(errorChain('plain string')).toBe('plain string')
expect(errorChain({ toString: () => { throw new Error('hostile') } })).toBe('<unrenderable value>')
const circular = new Error('outer')
circular.cause = circular
expect(errorChain(circular)).toBe('outer: <circular cause>')
// A hostile accessor collapses only its own node, not the whole chain.
const hostileNode = new Error('node')
Object.defineProperty(hostileNode, 'message', { get() { throw new Error('hostile getter') } })
expect(errorChain(new Error('outer', { cause: hostileNode }))).toBe('outer: <unrenderable value>')
// A diamond-shared (non-cyclic) cause renders in full on both paths.
const shared = new Error('shared')
const diamond = new AggregateError([new Error('a', { cause: shared }), new Error('b', { cause: shared })], 'agg')
expect(errorChain(diamond)).toBe('agg [a: shared; b: shared]')
})
it('errorChain falls back to the error name, skips empty aggregates, and stops at null causes', () => {
expect(errorChain(new TypeError('', { cause: null }))).toBe('TypeError')
expect(errorChain(new AggregateError([], 'all failed'))).toBe('all failed')
})
it('errorChain collapses a cause that repeats the wrapper message verbatim', () => {
// The `new HarnessError(String(value), code, { cause: value })` normalization
// pattern repeats its cause; rendering it twice would only add noise.
const wrapped = new HarnessError('boom', 'UNKNOWN', { cause: 'boom' })
expect(errorChain(wrapped)).toBe('boom')
})
it('routes stream() to the registered adapter', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)

View File

@@ -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))

View File

@@ -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 },

View File

@@ -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>

View File

@@ -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",
],
},

View File

@@ -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,

View File

@@ -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 {
@@ -87,7 +87,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, {
@@ -95,10 +95,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 => [
@@ -112,7 +112,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'
@@ -120,7 +120,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'),
]

View File

@@ -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',

View File

@@ -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[] {

View File

@@ -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.

View File

@@ -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 }
}

View File

@@ -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 = {

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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}`,

View File

@@ -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')

View File

@@ -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('system-prompt')?.config?.persona).toContain('{{cwd}}')
expect(project.packageManifest().dependencies?.['@cordisjs/plugin-timer']).toBe('^1.1.2')
@@ -211,13 +211,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)
@@ -307,7 +307,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'))
@@ -350,7 +350,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)
@@ -445,8 +445,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 = {
@@ -798,7 +798,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'] },
])
@@ -812,7 +812,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')
@@ -820,7 +820,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')
@@ -898,10 +898,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')
@@ -911,7 +911,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 =>

View File

@@ -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',
}

View File

@@ -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. */

View File

@@ -90,8 +90,8 @@ Change file: package.json
},
{
"default": true,
"label": "Terminal REPL",
"value": "stdio",
"label": "Terminal TUI",
"value": "tui",
},
{
"default": false,

View File

@@ -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: [],

View File

@@ -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()
})

View File

@@ -1,31 +1,39 @@
# @deepseek-ai/dsh-session-persistence-jsonl
The JSONL durable session-persistence backend — a concrete `SessionPersistence` (the `dsh-session-persistence` seam). One append-only `.jsonl` event log per session.
The JSONL durable session-persistence backend — a concrete `SessionPersistence` (the `dsh-session-persistence` seam). Each session has one append-only logical JSONL log, stored as `.jsonl.zstd` by default or raw `.jsonl` when compression is disabled.
## On-disk layout
```
<root>/
cwd-<sha256(cwd)[:12]>/ # per-project bucket (or _no-cwd/ when no cwd)
<encoded-id>.jsonl # header line + one SessionEvent per line (verbatim)
<encoded-id>.jsonl.zstd # default: checksummed header frame + append frames
<encoded-id>.jsonl # only with compression: 'none'
```
- The first `.jsonl` line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent line is one `SessionEvent` JSON, **verbatim including `assistant/chunk`** so `seq` stays contiguous (`events[i].seq === i`).
- Session ids are unvalidated branded strings, so they are percent-encoded to a single safe path segment before use (no traversal, no collision).
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent line is one `SessionEvent` JSON, **verbatim including `assistant/chunk`** so `seq` stays contiguous (`events[i].seq === i`).
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision).
## Config
| Key | Type | Notes |
|---|---|---|
| `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). |
| `compression` | `'zstd' \| 'none'` | Defaults to `'zstd'`; `'none'` retains newline-delimited UTF-8 text. |
`locate(meta)` returns `{ kind: 'jsonl', path }` using the resolved absolute root and the same cwd-bucket/id encoding as materialization. It performs no filesystem I/O: the target can be returned before the file exists, and an existing file contains only the last flushed prefix.
## Physical encoding
The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, followed by one checksummed frame per durable append batch. The backend uses Node's built-in Zstandard API with its default compression level and exposes no level knob. Listing reads and validates only the header frame. `compression: 'none'` keeps the same logical lines in the original raw representation.
A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. There is no migration, mixed-root fallback, or dual write.
## Durability and crash semantics
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory when the host supports it. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`.
- **Crash recovery — preserve valid tail work.** `load` keeps the contiguous valid prefix of an interrupted final turn. It truncates from the first unparsable or sequence-gapped uncommitted record, then appends the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md); the same defect at or before the last committed `turn/end` rejects.
- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length.
- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects.
- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
## Write path
@@ -50,7 +58,8 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr
## Known Limitations and Deferred Work
- **Only the current `SESSION_FORMAT_VERSION` (v0) loads** — the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 and non-current logs are rejected; there is no migration.
- **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration.
- **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required.
- **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface).
- **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated.
- **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend.

View File

@@ -12,8 +12,20 @@ import { createHash } from 'node:crypto'
import { join } from 'node:path'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
/** Physical encoding selected for JSONL session artifacts. */
export type JsonlCompression = 'zstd' | 'none'
/**
* The first line of a session's `.jsonl` file: the immutable
* Return the artifact suffix for one physical encoding.
* @param compression - configured JSONL artifact encoding.
* @returns `.jsonl.zstd` for Zstandard or `.jsonl` for plaintext.
*/
export function logSuffix(compression: JsonlCompression): '.jsonl.zstd' | '.jsonl' {
return compression === 'zstd' ? '.jsonl.zstd' : '.jsonl'
}
/**
* The first JSONL record of a session artifact: the immutable
* {@link SessionHeader} tagged as a `session` record so a reader can tell it
* apart from an event line.
*/
@@ -126,10 +138,16 @@ export function sessionDir(root: string, cwd: string | undefined): string {
* @param root - the backend's session root directory.
* @param cwd - the session's project directory (picks the per-cwd bucket; `undefined` → `_no-cwd`).
* @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
* @returns the session's `.jsonl` log file path.
* @param compression - physical artifact encoding and filename suffix.
* @returns the session's configured JSONL artifact path.
*/
export function logPath(root: string, cwd: string | undefined, id: SessionId): string {
return join(sessionDir(root, cwd), `${encodeSegment(id)}.jsonl`)
export function logPath(
root: string,
cwd: string | undefined,
id: SessionId,
compression: JsonlCompression,
): string {
return join(sessionDir(root, cwd), `${encodeSegment(id)}${logSuffix(compression)}`)
}
/**

View File

@@ -17,8 +17,20 @@ import {
} from '@deepseek-ai/dsh-session-persistence'
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import {
encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
encodeSegment, eventLine, logPath, logSuffix, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
type JsonlCompression,
} from './format.ts'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts'
export type { JsonlCompression } from './format.ts'
const DEFAULT_COMPRESSION: JsonlCompression = 'zstd'
/** Loader schema for the JSONL artifact's physical encoding. */
export const JsonlCompressionSchema: z<JsonlCompression> = z.union([
z.const('zstd'),
z.const('none'),
]).default(DEFAULT_COMPRESSION)
/** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */
export interface Config {
@@ -28,6 +40,14 @@ export interface Config {
* (bash calls, subprocesses). Sessions group under per-cwd subdirectories.
*/
root: string
/** Physical encoding; defaults to checksummed Zstandard frames. */
compression?: JsonlCompression
}
/** Opaque coordinator token for replacing bytes recovered from a torn frame. */
interface JsonlTornMarker {
truncateTo: number
recoveredEvents: SessionEvent[]
}
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
@@ -38,13 +58,15 @@ function isENOENT(error: unknown): boolean {
/**
* The JSONL persistence backend. Load as a plugin; it registers as
* `ctx.sessionPersistence` and (via the coordinator) installs the write-path
* listeners. Its torn-tail marker is the byte offset to truncate the log to.
* listeners. Its torn-tail marker carries the byte offset and any events
* recovered from an incomplete final Zstandard frame.
*/
export class SessionPersistenceJsonl extends SessionPersistence implements PersistenceBackend<number> {
export class SessionPersistenceJsonl extends SessionPersistence implements PersistenceBackend<JsonlTornMarker> {
static inject = ['sessions']
static Config: z<Config> = z.object({
root: z.string().required(),
compression: JsonlCompressionSchema,
})
/**
@@ -55,7 +77,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
override readonly name = 'session-persistence-jsonl'
private root: string
private coordinator: PersistenceCoordinator<number>
private compression: JsonlCompression
private coordinator: PersistenceCoordinator<JsonlTornMarker>
private rootEncodingCheck: Promise<void> | undefined
/** Runtime host platform used to decide whether directory sync is supported. */
readonly internals: { platform: NodeJS.Platform } = { platform: process.platform }
@@ -64,7 +88,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
super(ctx)
// Resolve once so later process.cwd() changes cannot split one backend across roots.
this.root = resolve(config.root)
this.coordinator = new PersistenceCoordinator<number>(this.ctx, this)
this.compression = config.compression ?? DEFAULT_COMPRESSION
this.coordinator = new PersistenceCoordinator<JsonlTornMarker>(this.ctx, this)
}
// Each backend keeps the typed service surface beside its storage hooks;
@@ -74,7 +99,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
/** Resolve the absolute target path without touching the filesystem. */
locate(meta: SessionHeader): SessionLocation {
return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id) }
return { kind: 'jsonl', path: logPath(this.root, meta.cwd, meta.id, this.compression) }
}
create(meta: SessionHeader): Promise<void> {
@@ -96,7 +121,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
// --- PersistenceBackend hooks (the file-bytes storage primitives) ---
/** Read a stored prefix by id across all cwd buckets when cwd is unknown. */
async loadStored(id: SessionId): Promise<StoredPrefix<number> | undefined> {
async loadStored(id: SessionId): Promise<StoredPrefix<JsonlTornMarker> | undefined> {
await this.ensureRootEncoding()
const file = await this.findLog(id)
if (file === undefined) return undefined
return this.readPrefix(file.path)
@@ -106,28 +132,85 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
* Read a stored prefix within one cwd for HMR adoption. `undefined` names the
* no-cwd bucket rather than an unknown cwd, so this never scans other buckets.
*/
async loadLive(id: SessionId, cwd: string | undefined): Promise<StoredPrefix<number> | undefined> {
const path = logPath(this.root, cwd, id)
if (!await this.exists(path)) return undefined
async loadLive(id: SessionId, cwd: string | undefined): Promise<StoredPrefix<JsonlTornMarker> | undefined> {
await this.ensureRootEncoding()
const path = logPath(this.root, cwd, id, this.compression)
if (!await this.exists(path)) {
await this.rejectOppositeArtifact(cwd, id)
return undefined
}
return this.readPrefix(path)
}
/**
* Read a stored prefix and convert torn-tail state to the byte offset the
* coordinator can round-trip without knowing the file format.
* Read a stored prefix and convert torn-tail state to the opaque marker the
* coordinator can round-trip without knowing the physical encoding.
*/
private async readPrefix(path: string): Promise<StoredPrefix<number>> {
private async readPrefix(path: string): Promise<StoredPrefix<JsonlTornMarker>> {
const buffer = await readFile(path)
if (this.compression === 'zstd') return this.readZstdPrefix(buffer)
const { meta, events, committedBytes } = scanLog(buffer)
return {
meta,
events,
...committedBytes < buffer.byteLength ? { tornMarker: committedBytes } : {},
...committedBytes < buffer.byteLength
? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } }
: {},
}
}
/** Decode complete frames and retain complete JSONL records from a torn final frame. */
private async readZstdPrefix(buffer: Buffer): Promise<StoredPrefix<JsonlTornMarker>> {
const { frames, tornStart } = scanZstdFrames(buffer)
if (frames.length === 0) throw new Error('empty or header-less Zstandard session log')
const plaintextFrames: Buffer[] = []
for (const frame of frames) {
try {
plaintextFrames.push(await decompressZstdFrame(buffer.subarray(frame.start, frame.end)))
} catch (error) {
throw new Error(`corrupt Zstandard session log: frame at byte ${frame.start} failed validation`, { cause: error })
}
}
const headerFrame = plaintextFrames[0]
if (headerFrame === undefined || headerFrame.length === 0 || headerFrame.indexOf(0x0A) !== headerFrame.length - 1) {
throw new Error('corrupt Zstandard session log: first frame is not exactly one header line')
}
const completePlaintext = Buffer.concat(plaintextFrames)
const completePrefix = scanLog(completePlaintext)
if (completePrefix.committedBytes !== completePlaintext.length) {
throw new Error('corrupt Zstandard session log: complete frame contains a torn JSONL record')
}
if (tornStart === undefined) {
return { meta: completePrefix.meta, events: completePrefix.events }
}
let recoveredPlaintext: Buffer = Buffer.alloc(0)
try {
recoveredPlaintext = await decompressZstdFrame(buffer.subarray(tornStart))
} catch {
// A structurally incomplete final frame may end before Node's decoder can
// emit any plaintext; the complete prior frames remain recoverable.
}
const recoveredPrefix = scanLog(Buffer.concat([completePlaintext, recoveredPlaintext]))
/* v8 ignore next 3 -- appending plaintext cannot shorten the already-scanned complete prefix */
if (recoveredPrefix.events.length < completePrefix.events.length) {
throw new Error('corrupt Zstandard session log: recovered prefix does not extend complete frames')
}
return {
meta: recoveredPrefix.meta,
events: recoveredPrefix.events,
tornMarker: {
truncateTo: tornStart,
recoveredEvents: recoveredPrefix.events.slice(completePrefix.events.length),
},
}
}
/** Durably append a batch, lazily materializing the file when not yet present. */
async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void> {
await this.ensureRootEncoding()
if (isMaterialized) {
await this.appendLines(meta, events)
} else {
@@ -136,22 +219,30 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
/**
* Make a crash repair durable: truncate the torn tail to `tornMarker` bytes (if
* any), then append the synthetic `closers` (if any). Two fsync'd steps — the
* seam does not require this to be atomic.
* Make a crash repair durable: truncate a torn tail, restore complete events
* decoded from it, then append synthetic closers. Two fsync'd steps — the seam
* does not require this to be atomic.
*/
async commitRepair(meta: SessionHeader, tornMarker: number | undefined, closers: readonly SessionEvent[]): Promise<void> {
if (tornMarker !== undefined) await this.repair(meta, tornMarker)
if (closers.length > 0) await this.appendLines(meta, closers)
async commitRepair(
meta: SessionHeader,
tornMarker: JsonlTornMarker | undefined,
closers: readonly SessionEvent[],
): Promise<void> {
if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo)
const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers]
if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents)
}
/** List all stored sessions' metadata (header line only — no full-log parse). */
async list(): Promise<SessionHeader[]> {
await this.ensureRootEncoding()
const metas: SessionHeader[] = []
for (const dir of await this.listCwdDirs()) {
for (const name of await this.listJsonl(dir)) {
for (const name of await this.listArtifacts(dir)) {
// Read only headers so listing scales with session count, not log size.
const first = await this.readFirstLine(`${dir}/${name}`)
const first = this.compression === 'zstd'
? await this.readFirstZstdLine(`${dir}/${name}`)
: await this.readFirstLine(`${dir}/${name}`)
if (first === undefined) continue // empty/half-written file
const meta = parseHeaderMeta(first)
if (meta === undefined) continue // not a session header
@@ -170,15 +261,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
await this.syncDir(dirname(this.root))
await mkdir(dir, { recursive: true, mode: 0o700 })
await this.syncDir(this.root)
const finalPath = logPath(this.root, meta.cwd, meta.id)
const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression)
// Materialization is the first write; an existing log is an id collision.
/* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
if (await this.exists(finalPath)) {
throw new Error(`refusing to materialize "${meta.id}": a log already exists on disk (load/resume it instead)`)
}
const header = JSON.stringify(toHeaderLine(meta))
const body = events.map(eventLine).join('\n')
const content = header + '\n' + body + '\n'
await this.rejectOppositeArtifact(meta.cwd, meta.id)
const content = await this.encodeMaterialization(meta, events)
const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp`
const handle = await open(tmp, 'wx', 0o600)
@@ -211,6 +301,22 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
}
/** Encode the header and first batch without combining their frame boundaries. */
private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise<Buffer | string> {
const header = JSON.stringify(toHeaderLine(meta)) + '\n'
const body = events.map(eventLine).join('\n') + '\n'
if (this.compression === 'none') return header + body
const headerFrame = await compressZstdFrame(header)
const eventFrame = await compressZstdFrame(body)
return Buffer.concat([headerFrame, eventFrame])
}
/** Encode one durable append batch in the configured physical representation. */
private async encodeEventBatch(events: readonly SessionEvent[]): Promise<Buffer | string> {
const body = events.map(eventLine).join('\n') + '\n'
return this.compression === 'zstd' ? compressZstdFrame(body) : body
}
/** fsync a directory when the host exposes that durability primitive. */
private async syncDir(dir: string): Promise<void> {
const handle = await open(dir, 'r')
@@ -234,12 +340,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
* batch; leaving partial bytes would create duplicate sequence numbers.
*/
private async appendLines(meta: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
const path = logPath(this.root, meta.cwd, meta.id)
const content = await this.encodeEventBatch(events)
const path = logPath(this.root, meta.cwd, meta.id, this.compression)
const handle = await open(path, 'a')
try {
const { size: before } = await handle.stat()
try {
await handle.writeFile(events.map(eventLine).join('\n') + '\n')
await handle.writeFile(content)
await handle.sync()
} catch (error) {
// Roll back whatever bytes landed so a retry starts from a clean EOF.
@@ -254,7 +361,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
/** Truncate the log file to `offset` bytes and fsync (discard the crash tail). */
private async repair(meta: SessionHeader, offset: number): Promise<void> {
const path = logPath(this.root, meta.cwd, meta.id)
const path = logPath(this.root, meta.cwd, meta.id, this.compression)
await truncate(path, offset)
const handle = await open(path, 'r+')
try {
@@ -292,17 +399,47 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
}
/** Read and validate only the independently compressed header frame. */
private async readFirstZstdLine(path: string): Promise<string | undefined> {
const handle = await open(path, 'r')
try {
let content = Buffer.alloc(0)
const chunk = Buffer.alloc(8192)
for (;;) {
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null)
if (bytesRead === 0) return undefined
content = Buffer.concat([content, chunk.subarray(0, bytesRead)])
const first = scanZstdFrames(content, 1).frames[0]
if (first === undefined) continue
let plaintext: Buffer
try {
plaintext = await decompressZstdFrame(content.subarray(first.start, first.end))
} catch (error) {
throw new Error('corrupt Zstandard session log: header frame failed validation', { cause: error })
}
if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) {
throw new Error('corrupt Zstandard session log: first frame is not exactly one header line')
}
return plaintext.subarray(0, -1).toString('utf8')
}
} finally {
await handle.close()
}
}
/**
* Find a session by id across cwd buckets for resume. Cwd-scoped HMR adoption
* bypasses this scan so a no-cwd session cannot claim another bucket.
*/
private async findLog(id: SessionId): Promise<{ path: string; cwd: string | undefined } | undefined> {
const target = encodeSegment(id) + '.jsonl'
const target = encodeSegment(id) + logSuffix(this.compression)
for (const dir of await this.listCwdDirs()) {
const path = `${dir}/${target}`
const opposite = `${dir}/${encodeSegment(id)}${logSuffix(this.oppositeCompression())}`
if (await this.exists(opposite)) throw this.encodingMismatch(opposite)
if (await this.exists(path)) {
// Recover the cwd from the header so the caller has the session's bucket.
const { meta } = scanLog(await readFile(path))
const { meta } = await this.readPrefix(path)
return { path, cwd: meta.cwd }
}
}
@@ -321,9 +458,45 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
}
private async listJsonl(dir: string): Promise<string[]> {
private async listArtifacts(dir: string): Promise<string[]> {
const entries = await readdir(dir)
return entries.filter(n => n.endsWith('.jsonl'))
const oppositeSuffix = logSuffix(this.oppositeCompression())
const incompatible = entries.find(name => name.endsWith(oppositeSuffix))
if (incompatible !== undefined) throw this.encodingMismatch(`${dir}/${incompatible}`)
const suffix = logSuffix(this.compression)
return entries.filter(name => name.endsWith(suffix))
}
/** Reject a root that already belongs to the other physical encoding. */
private ensureRootEncoding(): Promise<void> {
this.rootEncodingCheck ??= this.checkRootEncoding()
return this.rootEncodingCheck
}
private async checkRootEncoding(): Promise<void> {
const oppositeSuffix = logSuffix(this.oppositeCompression())
for (const dir of await this.listCwdDirs()) {
const entries = await readdir(dir)
const incompatible = entries.find(name => name.endsWith(oppositeSuffix))
if (incompatible !== undefined) throw this.encodingMismatch(`${dir}/${incompatible}`)
}
}
private async rejectOppositeArtifact(cwd: string | undefined, id: SessionId): Promise<void> {
const path = logPath(this.root, cwd, id, this.oppositeCompression())
if (await this.exists(path)) throw this.encodingMismatch(path)
}
private oppositeCompression(): JsonlCompression {
return this.compression === 'zstd' ? 'none' : 'zstd'
}
private encodingMismatch(path: string): Error {
return new Error(
`session artifact ${JSON.stringify(path)} uses ${logSuffix(this.oppositeCompression())}, `
+ `but this backend is configured for compression ${JSON.stringify(this.compression)}; `
+ 'use a separate root or select the matching compression mode',
)
}
private async exists(path: string): Promise<boolean> {

View File

@@ -0,0 +1,116 @@
/**
* Zstandard frame primitives for the JSONL persistence backend. The backend
* owns a concatenated-frame container so it can append and recover batches
* without exposing compression mechanics through the persistence seam.
* @module dsh-session-persistence-jsonl/zstd
*/
import { constants, zstdCompress, zstdDecompress, type ZstdOptions } from 'node:zlib'
import { promisify } from 'node:util'
const ZSTD_MAGIC = 0xFD2FB528
const zstdCompressAsync = promisify(zstdCompress)
const zstdDecompressAsync = promisify(zstdDecompress)
const CHECKSUM_OPTIONS: ZstdOptions = {
params: { [constants.ZSTD_c_checksumFlag]: 1 },
}
/** Byte range occupied by one structurally complete Zstandard frame. */
export interface ZstdFrameRange {
/** Inclusive frame start. */
start: number
/** Exclusive frame end. */
end: number
}
/** Structural scan result for a concatenated Zstandard stream. */
export interface ZstdFrameScan {
/** Complete frames in file order. */
frames: ZstdFrameRange[]
/** Start of an incomplete final frame, when EOF interrupts one. */
tornStart?: number
}
/**
* Locate complete frames without decompressing their blocks. Invalid complete
* structure rejects; EOF inside the final frame returns its start for repair.
* @param buffer - complete bytes currently present in the session artifact.
* @param maxFrames - optional complete-frame limit for metadata-only readers.
* @returns complete frame ranges and an optional incomplete-final-frame start.
*/
export function scanZstdFrames(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): ZstdFrameScan {
const frames: ZstdFrameRange[] = []
let offset = 0
while (offset < buffer.length) {
const start = offset
if (buffer.length - offset < 4) return { frames, tornStart: start }
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${offset}`)
}
offset += 4
if (offset === buffer.length) return { frames, tornStart: start }
const descriptor = buffer.readUInt8(offset)
offset += 1
if ((descriptor & 0x18) !== 0) {
throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${offset - 1}`)
}
const contentSizeFlag = descriptor >>> 6
const singleSegment = (descriptor & 0x20) !== 0
const checksum = (descriptor & 0x04) !== 0
const dictionaryFlag = descriptor & 0x03
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
const contentSizeBytes = contentSizeFlag === 0
? (singleSegment ? 1 : 0)
: 1 << contentSizeFlag
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
offset += remainingHeaderBytes
for (;;) {
if (buffer.length - offset < 3) return { frames, tornStart: start }
const blockHeader = buffer.readUIntLE(offset, 3)
offset += 3
const lastBlock = (blockHeader & 1) !== 0
const blockType = (blockHeader >>> 1) & 0x03
const blockSize = blockHeader >>> 3
if (blockType === 0x03) {
throw new Error(`corrupt Zstandard session log: reserved block type at byte ${offset - 3}`)
}
const payloadBytes = blockType === 0x01 ? 1 : blockSize
if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
offset += payloadBytes
if (lastBlock) break
}
if (checksum) {
if (buffer.length - offset < 4) return { frames, tornStart: start }
offset += 4
}
frames.push({ start, end: offset })
if (frames.length === maxFrames) return { frames }
}
return { frames }
}
/**
* Compress one independently decodable, checksummed Zstandard frame.
* @param input - JSONL bytes for a header or durable event batch.
* @returns the complete encoded frame.
*/
export async function compressZstdFrame(input: Buffer | string): Promise<Buffer> {
return zstdCompressAsync(input, CHECKSUM_OPTIONS)
}
/**
* Decompress one complete frame or the available prefix of a torn final frame.
* Complete-frame checksums are validated by Node's decoder.
* @param input - bytes beginning at a Zstandard frame boundary.
* @returns plaintext produced from the available input.
*/
export async function decompressZstdFrame(input: Buffer): Promise<Buffer> {
return zstdDecompressAsync(input)
}

View File

@@ -40,6 +40,10 @@ async function freshRoot(): Promise<string> {
return dir
}
function rawLogPath(root: string, cwd: string | undefined, id: SessionId): string {
return logPath(root, cwd, id, 'none')
}
afterEach(async () => {
vi.restoreAllMocks()
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
@@ -70,11 +74,11 @@ function appendClosedTurn(session: Session): void {
}
// Run the shared backend contract against the real JSONL backend.
runPersistenceContract('jsonl', async () => {
runPersistenceContract('jsonl-none', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir })
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
return {
persistence: ctx.sessionPersistence,
dispose: async () => {
@@ -86,18 +90,18 @@ runPersistenceContract('jsonl', async () => {
// Two mounts share this temp root to exercise reload. `corruptTail` appends a partial,
// newline-less fragment past the committed region so coordinator repair runs on real file bytes.
runCoordinatorContract('jsonl', async (): Promise<CoordinatorFixture> => {
runCoordinatorContract('jsonl-none', async (): Promise<CoordinatorFixture> => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-'))
return {
mount: async (ctx) => {
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir })
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
return fiber
},
corruptTail: async (id, cwd) => {
// A half-written record with no trailing newline: scanLog treats it as an
// uncommitted crash fragment and reports committedBytes < byteLength, so
// the coordinator sees a tornMarker to truncate.
await appendFile(logPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti')
await appendFile(rawLogPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti')
},
cleanup: async () => { await rm(dir, { recursive: true, force: true }) },
}
@@ -134,11 +138,14 @@ describe('SessionPersistenceJsonl: format helpers', () => {
const absoluteRoot = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: relative(process.cwd(), absoluteRoot) })
const fiber = await ctx.plugin(SessionPersistenceJsonl, {
root: relative(process.cwd(), absoluteRoot),
compression: 'none',
})
const m = meta('relative-location', '/work')
expect(ctx.sessionPersistence.locate(m)).toEqual({
kind: 'jsonl',
path: logPath(resolve(absoluteRoot), '/work', m.id),
path: rawLogPath(resolve(absoluteRoot), '/work', m.id),
})
await fiber.dispose()
})
@@ -150,26 +157,26 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
root = await freshRoot()
ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root })
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
})
afterEach(async () => { await ctx.fiber.dispose() })
it('lazy materialization: create() writes no file until the first append', async () => {
const m = meta('lazy', '/work')
const location = ctx.sessionPersistence.locate(m)
expect(location).toEqual({ kind: 'jsonl', path: logPath(root, '/work', m.id) })
expect(location).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', m.id) })
expect(isAbsolute(location!.path)).toBe(true)
await ctx.sessionPersistence.create(m)
// locate() is a pure target-path calculation: neither it nor create()
// materializes a file before the first append.
const dir = sessionDir(root, '/work')
await expect(stat(logPath(root, '/work', m.id))).rejects.toThrow()
await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow()
expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
// now materialized
expect((await stat(logPath(root, '/work', m.id))).isFile()).toBe(true)
expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true)
expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
void dir
})
@@ -191,7 +198,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
}
const childLocation = ctx.sessionPersistence.locate(child)
expect(childLocation?.path).not.toBe(parentLocation?.path)
expect(childLocation).toEqual({ kind: 'jsonl', path: logPath(root, '/work', child.id) })
expect(childLocation).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', child.id) })
})
it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => {
@@ -213,7 +220,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
const m = meta('legacy-header-delta', '/legacy')
const path = logPath(root, m.cwd, m.id)
const path = rawLogPath(root, m.cwd, m.id)
await mkdir(sessionDir(root, m.cwd), { recursive: true })
await writeFile(path, [
JSON.stringify(toHeaderLine(m)),
@@ -228,7 +235,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
const m = meta('legacy-header-fallback', '/legacy')
const path = logPath(root, m.cwd, m.id)
const path = rawLogPath(root, m.cwd, m.id)
await mkdir(sessionDir(root, m.cwd), { recursive: true })
await writeFile(path, [
JSON.stringify(toHeaderLine(m)),
@@ -270,7 +277,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
// Simulate a crash mid-second-turn: append raw lines that are NOT closed by
// a turn/end (turn/start + step/start are fully written), plus a final
// partial line with no newline (a torn fragment never fully flushed).
const path = logPath(root, '/proj', m.id)
const path = rawLogPath(root, '/proj', m.id)
await writeFile(path, [
JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }),
JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }),
@@ -303,17 +310,17 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
const m = meta('append-only')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const before = await readFile(logPath(root, undefined, m.id), 'utf8')
const before = await readFile(rawLogPath(root, undefined, m.id), 'utf8')
const committedPrefix = before // the whole committed log
// A crash tail then a repair-append.
await writeFile(logPath(root, undefined, m.id), '\n{"partial', { flag: 'a' })
await writeFile(rawLogPath(root, undefined, m.id), '\n{"partial', { flag: 'a' })
await ctx.sessionPersistence.load(m.id)
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
] as SessionEvent[])
const after = await readFile(logPath(root, undefined, m.id), 'utf8')
const after = await readFile(rawLogPath(root, undefined, m.id), 'utf8')
// the committed prefix is byte-for-byte intact at the head of the file
expect(after.startsWith(committedPrefix)).toBe(true)
})
@@ -322,12 +329,12 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
const m = meta('truncate-retry')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog()) // materialized, seqs 0..5
const sizeBefore = (await stat(logPath(root, undefined, m.id))).size
const sizeBefore = (await stat(rawLogPath(root, undefined, m.id))).size
// Force the NEXT fsync (inside appendLines) to fail once, AFTER writeFile
// has already put bytes on disk — simulating an ENOSPC/fsync error
// mid-append. The recovery truncate() also fsyncs, so allow that one.
const handle = await (await import('node:fs/promises')).open(logPath(root, undefined, m.id), 'r')
const handle = await (await import('node:fs/promises')).open(rawLogPath(root, undefined, m.id), 'r')
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
await handle.close()
const realSync = proto.sync
@@ -344,7 +351,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
// The append rejects, but the partial bytes are truncated back: the file is
// its pre-append size and the cursor is unchanged.
await expect(ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/ENOSPC/)
expect((await stat(logPath(root, undefined, m.id))).size).toBe(sizeBefore)
expect((await stat(rawLogPath(root, undefined, m.id))).size).toBe(sizeBefore)
spy.mockRestore()
// The retry now succeeds with NO seq gap — the log is contiguous 0..7.
@@ -425,7 +432,7 @@ describe('SessionPersistenceJsonl: write path (session/event → flush)', () =>
root = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root })
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
const a = ctx.sessions.create(SessionId('sa'))
const b = ctx.sessions.create(SessionId('sb'))
@@ -552,7 +559,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
root = await freshRoot()
ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root })
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
})
afterEach(async () => { await ctx.fiber.dispose() })
@@ -572,8 +579,8 @@ describe('SessionPersistenceJsonl: edge cases', () => {
await p
await ctx.sessionPersistence.append(SessionId('create-snap'), oneTurnLog())
// The log materialized under the ORIGINAL cwd, not the mutated one.
expect((await stat(logPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true)
await expect(stat(logPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow()
expect((await stat(rawLogPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true)
await expect(stat(rawLogPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow()
})
it('list discovers sessions across multiple cwd buckets', async () => {
@@ -654,7 +661,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
// of grafting no-cwd events onto a log with mismatched cwd.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
let b!: Session
await ctx2.plugin(Object.assign((inner: Context) => {
b = inner.sessions.create(SessionId('x')) // no cwd
@@ -663,10 +670,10 @@ describe('SessionPersistenceJsonl: edge cases', () => {
// The "/w" log is untouched — no no-cwd events were grafted onto it, and no
// `_no-cwd` log for "x" was created.
const inW = scanLog(await readFile(logPath(root, '/w', SessionId('x'))))
const inW = scanLog(await readFile(rawLogPath(root, '/w', SessionId('x'))))
expect(inW.meta.cwd).toBe('/w')
expect(inW.events).toHaveLength(6)
await expect(stat(logPath(root, undefined, SessionId('x')))).rejects.toThrow()
await expect(stat(rawLogPath(root, undefined, SessionId('x')))).rejects.toThrow()
await ctx2.fiber.dispose()
})
@@ -710,7 +717,10 @@ describe('SessionPersistenceJsonl: edge cases', () => {
it('list returns nothing when the root directory does not exist', async () => {
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root: join(root, 'does-not-exist-yet') })
await ctx2.plugin(SessionPersistenceJsonl, {
root: join(root, 'does-not-exist-yet'),
compression: 'none',
})
expect(await ctx2.sessionPersistence.list()).toEqual([])
await ctx2.fiber.dispose()
})
@@ -722,7 +732,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
await writeFile(filePath, 'x')
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root: filePath })
await ctx2.plugin(SessionPersistenceJsonl, { root: filePath, compression: 'none' })
await expect(ctx2.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/)
await ctx2.fiber.dispose()
})
@@ -733,7 +743,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const cwd = '/x'
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE
let s!: Session
await ctx2.plugin(Object.assign((inner: Context) => {
@@ -748,14 +758,14 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const m = meta('disk-append', '/d')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
await writeFile(logPath(root, '/d', m.id), '\n{"partial crash', { flag: 'a' })
await writeFile(rawLogPath(root, '/d', m.id), '\n{"partial crash', { flag: 'a' })
// A FRESH backend with no in-memory state: append directly (no prior load)
// → append must adopt from disk, and the adopt's load schedules a repair
// that the same append then performs before writing.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx2.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
@@ -791,7 +801,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
// nondeterministic. create scans every bucket, not just meta.cwd's.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await expect(ctx2.sessionPersistence.create(meta('dup-id', '/projB')))
.rejects.toThrow(/already has a persisted log on disk/)
await ctx2.fiber.dispose()
@@ -801,7 +811,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
root = await freshRoot()
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
const session = ctx2.sessions.create(SessionId('flush-fail'))
// A full turn lands in the write-behind buffer.
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
describe('JSONL Zstandard compatibility', () => {
it('round-trips concatenated checksummed frames through the built-in Node API', async () => {
const encoded = Buffer.concat([
await compressZstdFrame('{"type":"session","version":0,"id":"compat","createdAt":1}\n'),
await compressZstdFrame('{"type":"turn/start","seq":0,"turn":1}\n'),
])
const { frames, tornStart } = scanZstdFrames(encoded)
expect(tornStart).toBeUndefined()
expect(frames).toHaveLength(2)
expect(frames.map(frame => encoded.subarray(frame.start, frame.start + 4).toString('hex')))
.toEqual(['28b52ffd', '28b52ffd'])
const decoded = await Promise.all(frames.map(frame => decompressZstdFrame(encoded.subarray(frame.start, frame.end))))
expect(Buffer.concat(decoded).toString()).toContain('"type":"turn/start"')
const eventFrame = encoded.subarray(frames[1]!.start, frames[1]!.end)
const missingChecksumByte = eventFrame.subarray(0, -1)
expect(scanZstdFrames(missingChecksumByte)).toEqual({ frames: [], tornStart: 0 })
expect((await decompressZstdFrame(missingChecksumByte)).toString()).toContain('"type":"turn/start"')
})
})

View File

@@ -0,0 +1,483 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { appendFile, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { eventLine, logPath, scanLog, sessionDir, toHeaderLine, type JsonlCompression } from '../src/format.ts'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
const MAGIC = Buffer.from([0x28, 0xB5, 0x2F, 0xFD])
const roots: string[] = []
const contexts: Context[] = []
async function freshRoot(prefix = 'dsh-jsonl-zstd-'): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix))
roots.push(root)
return root
}
async function mount(root: string, compression?: JsonlCompression): Promise<Context> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, {
root,
...(compression === undefined ? {} : { compression }),
})
return ctx
}
async function decodeCompleteFrames(buffer: Buffer): Promise<Buffer> {
const { frames, tornStart } = scanZstdFrames(buffer)
expect(tornStart).toBeUndefined()
const plaintext: Buffer[] = []
for (const frame of frames) {
plaintext.push(await decompressZstdFrame(buffer.subarray(frame.start, frame.end)))
}
return Buffer.concat(plaintext)
}
async function tornFrame(
plaintext: string,
accepts: (decoded: string) => boolean,
): Promise<Buffer> {
const frame = await compressZstdFrame(plaintext)
const candidateEnds = [
frame.length - 1,
frame.length - 4,
...[0.9, 0.75, 0.6, 0.5, 0.4, 0.25].map(ratio => Math.floor(frame.length * ratio)),
]
for (const end of candidateEnds) {
const candidate = frame.subarray(0, end)
if (scanZstdFrames(candidate).tornStart !== 0) continue
try {
const decoded = (await decompressZstdFrame(candidate)).toString('utf8')
if (accepts(decoded)) return candidate
} catch {
// Some early cuts precede the first decodable block; keep searching for
// a cut that exercises partial-plaintext recovery.
}
}
throw new Error('test fixture could not produce the requested torn Zstandard frame')
}
function deterministicNoise(length: number): string {
let state = 0x12345678
let output = ''
for (let index = 0; index < length; index++) {
state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0
output += String.fromCharCode(33 + (state % 90))
}
return output
}
function emptyStructuralFrame(descriptor: number): Buffer {
const contentSizeFlag = descriptor >>> 6
const singleSegment = (descriptor & 0x20) !== 0
const dictionaryBytes = [0, 1, 2, 4][descriptor & 0x03]!
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
const variableHeader = Buffer.alloc((singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes)
const lastEmptyRawBlock = Buffer.from([1, 0, 0])
const checksum = (descriptor & 0x04) === 0 ? Buffer.alloc(0) : Buffer.alloc(4)
return Buffer.concat([MAGIC, Buffer.from([descriptor]), variableHeader, lastEmptyRawBlock, checksum])
}
afterEach(async () => {
vi.restoreAllMocks()
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
runPersistenceContract('jsonl-zstd', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-zstd-contract-'))
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root })
return {
persistence: ctx.sessionPersistence,
dispose: async () => {
await fiber.dispose()
await rm(root, { recursive: true, force: true })
},
}
})
runCoordinatorContract('jsonl-zstd', async (): Promise<CoordinatorFixture> => {
const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-zstd-coordinator-'))
return {
mount: async ctx => ctx.plugin(SessionPersistenceJsonl, { root }),
corruptTail: async (id, cwd) => {
const line = JSON.stringify({
type: 'assistant/chunk',
seq: 8,
time: 9,
data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } },
}) + '\n'
const partial = await tornFrame(line, decoded => decoded.length > 0 && !decoded.endsWith('\n'))
await appendFile(logPath(root, cwd, id, 'zstd'), partial)
},
cleanup: async () => { await rm(root, { recursive: true, force: true }) },
}
})
describe('Zstandard frame structure', () => {
it('scans concatenated checksummed frames and honors a frame limit', async () => {
const first = await compressZstdFrame('header\n')
const second = await compressZstdFrame('event\n')
const stream = Buffer.concat([first, second])
expect(scanZstdFrames(Buffer.alloc(0))).toEqual({ frames: [] })
expect(scanZstdFrames(stream)).toEqual({
frames: [{ start: 0, end: first.length }, { start: first.length, end: stream.length }],
})
expect(scanZstdFrames(stream, 1)).toEqual({ frames: [{ start: 0, end: first.length }] })
expect(first[4]! & 0x04).toBe(0x04)
expect(second[4]! & 0x04).toBe(0x04)
expect((await decompressZstdFrame(first)).toString()).toBe('header\n')
})
it('distinguishes incomplete frame regions from invalid complete structure', () => {
expect(scanZstdFrames(MAGIC.subarray(0, 2))).toEqual({ frames: [], tornStart: 0 })
expect(scanZstdFrames(MAGIC)).toEqual({ frames: [], tornStart: 0 })
expect(() => scanZstdFrames(Buffer.alloc(4))).toThrow(/invalid frame magic/)
expect(() => scanZstdFrames(Buffer.concat([MAGIC, Buffer.from([0x08])]))).toThrow(/reserved frame-header bit/)
// Non-single-segment descriptor with no window descriptor.
expect(scanZstdFrames(Buffer.concat([MAGIC, Buffer.from([0x00])]))).toEqual({ frames: [], tornStart: 0 })
// Single-segment header followed by only two bytes of the three-byte block header.
expect(scanZstdFrames(Buffer.concat([MAGIC, Buffer.from([0x20, 0x00, 0x01, 0x00])]))).toEqual({
frames: [],
tornStart: 0,
})
const rawFiveBytes = Buffer.from([(5 << 3) | 1, 0, 0])
expect(scanZstdFrames(Buffer.concat([
MAGIC,
Buffer.from([0x20, 0x00]),
rawFiveBytes,
Buffer.from([0x01, 0x02]),
]))).toEqual({ frames: [], tornStart: 0 })
const reservedBlock = Buffer.concat([
MAGIC,
Buffer.from([0x20, 0x00, 0x07, 0x00, 0x00]),
])
expect(() => scanZstdFrames(reservedBlock)).toThrow(/reserved block type/)
})
it('covers standard header variants, RLE blocks, multiple blocks, and checksums', () => {
for (const descriptor of [0x00, 0x21, 0x42, 0x83, 0xE3]) {
const frame = emptyStructuralFrame(descriptor)
expect(scanZstdFrames(frame)).toEqual({ frames: [{ start: 0, end: frame.length }] })
}
const rle = Buffer.concat([
MAGIC,
Buffer.from([0x20, 0x01]),
Buffer.from([(1 << 3) | (1 << 1) | 1, 0, 0]),
Buffer.from([0x41]),
])
expect(scanZstdFrames(rle)).toEqual({ frames: [{ start: 0, end: rle.length }] })
const twoBlocks = Buffer.concat([
MAGIC,
Buffer.from([0x20, 0x00]),
Buffer.from([0, 0, 0]),
Buffer.from([1, 0, 0]),
])
expect(scanZstdFrames(twoBlocks)).toEqual({ frames: [{ start: 0, end: twoBlocks.length }] })
const checksummed = emptyStructuralFrame(0x24)
expect(scanZstdFrames(checksummed.subarray(0, -1))).toEqual({ frames: [], tornStart: 0 })
expect(scanZstdFrames(checksummed)).toEqual({ frames: [{ start: 0, end: checksummed.length }] })
})
})
describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
it('writes .jsonl.zstd by default with one header frame and one first-batch frame', async () => {
const root = await freshRoot()
const ctx = await mount(root)
const header = meta('default-zstd', '/work')
await ctx.sessionPersistence.create(header)
await ctx.sessionPersistence.append(header.id, oneTurnLog())
const path = logPath(root, header.cwd, header.id, 'zstd')
const buffer = await readFile(path)
expect(buffer.subarray(0, 4)).toEqual(MAGIC)
await expect(stat(logPath(root, header.cwd, header.id, 'none'))).rejects.toThrow()
expect(ctx.sessionPersistence.locate(header)).toEqual({ kind: 'jsonl', path })
const scan = scanZstdFrames(buffer)
expect(scan.frames).toHaveLength(2)
const plaintext = await decodeCompleteFrames(buffer)
expect(plaintext.toString()).toBe([
JSON.stringify(toHeaderLine(header)),
...oneTurnLog().map(eventLine),
'',
].join('\n'))
expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog())
})
it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => {
const root = await freshRoot()
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
let backend!: SessionPersistenceJsonl
await ctx.plugin(Object.assign((inner: Context) => {
backend = new SessionPersistenceJsonl(inner, { root })
}, { inject: ['sessions'] }))
const header = meta('direct-default')
expect(backend.locate(header)).toEqual({
kind: 'jsonl',
path: logPath(root, header.cwd, header.id, 'zstd'),
})
})
it('appends one frame per durable batch without rewriting prior bytes', async () => {
const root = await freshRoot()
const ctx = await mount(root)
const header = meta('append-frame')
await ctx.sessionPersistence.create(header)
await ctx.sessionPersistence.append(header.id, oneTurnLog())
const path = logPath(root, header.cwd, header.id, 'zstd')
const before = await readFile(path)
const secondTurn = [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
] as SessionEvent[]
await ctx.sessionPersistence.append(header.id, secondTurn)
const after = await readFile(path)
expect(after.subarray(0, before.length)).toEqual(before)
expect(scanZstdFrames(after).frames).toHaveLength(3)
expect((await ctx.sessionPersistence.load(header.id)).events).toEqual([...oneTurnLog(), ...secondTurn])
})
it('lists from a multi-chunk header frame without decoding a corrupt event frame', async () => {
const root = await freshRoot()
const ctx = await mount(root)
const header = meta('large-header', `/work/${'x'.repeat(24_000)}`)
await ctx.sessionPersistence.create(header)
await ctx.sessionPersistence.append(header.id, oneTurnLog())
const path = logPath(root, header.cwd, header.id, 'zstd')
const buffer = Buffer.from(await readFile(path))
const eventFrame = scanZstdFrames(buffer).frames[1]!
buffer[eventFrame.end - 1] = buffer[eventFrame.end - 1]! ^ 0xFF
await writeFile(path, buffer)
expect((await ctx.sessionPersistence.list()).map(item => item.id)).toEqual([header.id])
await expect(ctx.sessionPersistence.load(header.id)).rejects.toThrow(/frame at byte .* failed validation/)
})
it('preserves complete records from a torn frame and re-encodes them with crash closers', async () => {
const root = await freshRoot()
const ctx = await mount(root)
const header = meta('recover-torn', '/proj')
await ctx.sessionPersistence.create(header)
await ctx.sessionPersistence.append(header.id, oneTurnLog())
const path = logPath(root, header.cwd, header.id, 'zstd')
const committed = await readFile(path)
const openTurn = [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
{ type: 'assistant/chunk', seq: 8, time: 9, data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } } },
] as SessionEvent[]
const plaintext = openTurn.map(eventLine).join('\n') + '\n'
const partial = await tornFrame(plaintext, (decoded) => {
const newlines = decoded.match(/\n/g)?.length ?? 0
return newlines >= 2 && !decoded.endsWith('\n')
})
await appendFile(path, partial)
const loaded = await ctx.sessionPersistence.load(header.id)
expect(loaded.events.map(event => event.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
expect(loaded.events[6]).toEqual(openTurn[0])
expect(loaded.events[7]).toEqual(openTurn[1])
expect(loaded.events.some(event => event.type === 'assistant/chunk' && event.seq === 8)).toBe(false)
expect(loaded.events[8]?.type).toBe('step/end')
expect(loaded.events[9]?.type).toBe('turn/end')
const repaired = await readFile(path)
expect(repaired.subarray(0, committed.length)).toEqual(committed)
expect(scanZstdFrames(repaired).tornStart).toBeUndefined()
expect(scanLog(await decodeCompleteFrames(repaired)).events).toEqual(loaded.events)
})
it('drops a frame torn in its header before it has produced plaintext', async () => {
const root = await freshRoot()
const ctx = await mount(root)
const header = meta('partial-magic')
await ctx.sessionPersistence.create(header)
await ctx.sessionPersistence.append(header.id, oneTurnLog())
const path = logPath(root, header.cwd, header.id, 'zstd')
const committed = await readFile(path)
await appendFile(path, MAGIC.subarray(0, 2))
expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog())
expect(await readFile(path)).toEqual(committed)
})
it('recovers complete events when EOF tears only the final frame checksum', async () => {
const root = await freshRoot()
const ctx = await mount(root)
const header = meta('partial-checksum')
await ctx.sessionPersistence.create(header)
await ctx.sessionPersistence.append(header.id, oneTurnLog())
const path = logPath(root, header.cwd, header.id, 'zstd')
const secondTurn = [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
] as SessionEvent[]
const frame = await compressZstdFrame(secondTurn.map(eventLine).join('\n') + '\n')
await appendFile(path, frame.subarray(0, -1))
const loaded = await ctx.sessionPersistence.load(header.id)
expect(loaded.events).toEqual([...oneTurnLog(), ...secondTurn])
const repaired = await readFile(path)
expect(scanZstdFrames(repaired).tornStart).toBeUndefined()
expect(scanLog(await decodeCompleteFrames(repaired)).events).toEqual(loaded.events)
})
it('rejects a complete frame containing a torn JSONL record', async () => {
const root = await freshRoot()
const ctx = await mount(root)
const header = meta('complete-bad-jsonl')
await ctx.sessionPersistence.create(header)
await ctx.sessionPersistence.append(header.id, oneTurnLog())
await appendFile(
logPath(root, header.cwd, header.id, 'zstd'),
await compressZstdFrame('{"type":"turn/start"'),
)
await expect(ctx.sessionPersistence.load(header.id)).rejects.toThrow(/complete frame contains a torn JSONL record/)
})
it('rolls back a checksummed append frame when fsync fails', async () => {
const root = await freshRoot()
const ctx = await mount(root)
const header = meta('zstd-fsync-rollback')
await ctx.sessionPersistence.create(header)
await ctx.sessionPersistence.append(header.id, oneTurnLog())
const path = logPath(root, header.cwd, header.id, 'zstd')
const before = await readFile(path)
const handle = await open(path, 'r')
const prototype = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
await handle.close()
const realSync = prototype.sync
let failed = false
const spy = vi.spyOn(prototype, 'sync').mockImplementation(async function (this: FileHandle) {
if (!failed) {
failed = true
throw new Error('simulated Zstandard fsync failure')
}
return realSync.call(this)
})
const secondTurn = [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
] as SessionEvent[]
await expect(ctx.sessionPersistence.append(header.id, secondTurn)).rejects.toThrow(/simulated Zstandard fsync failure/)
expect(await readFile(path)).toEqual(before)
spy.mockRestore()
await ctx.sessionPersistence.append(header.id, secondTurn)
expect((await ctx.sessionPersistence.load(header.id)).events).toEqual([...oneTurnLog(), ...secondTurn])
})
it('skips empty, incomplete, and non-header compressed artifacts while rejecting malformed header frames', async () => {
const root = await freshRoot()
const bucket = sessionDir(root, undefined)
await mkdir(bucket, { recursive: true })
await writeFile(join(bucket, 'empty.jsonl.zstd'), '')
await writeFile(join(bucket, 'partial.jsonl.zstd'), MAGIC)
await writeFile(join(bucket, 'not-header.jsonl.zstd'), await compressZstdFrame('{"type":"turn/start"}\n'))
const ctx = await mount(root)
expect(await ctx.sessionPersistence.list()).toEqual([])
await writeFile(join(bucket, 'two-lines.jsonl.zstd'), await compressZstdFrame([
JSON.stringify(toHeaderLine(meta('two-lines'))),
JSON.stringify({ type: 'turn/start' }),
'',
].join('\n')))
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/first frame is not exactly one header line/)
await expect(ctx.sessionPersistence.load(SessionId('two-lines')))
.rejects.toThrow(/first frame is not exactly one header line/)
})
it('rejects missing, empty, and checksum-corrupt header frames on targeted reads', async () => {
const root = await freshRoot()
const bucket = sessionDir(root, undefined)
await mkdir(bucket, { recursive: true })
await writeFile(logPath(root, undefined, SessionId('partial-only'), 'zstd'), MAGIC)
await writeFile(logPath(root, undefined, SessionId('empty-header'), 'zstd'), await compressZstdFrame(''))
const corruptHeader = Buffer.from(await compressZstdFrame(`${JSON.stringify(toHeaderLine(meta('bad-checksum')))}\n`))
corruptHeader[corruptHeader.length - 1] = corruptHeader[corruptHeader.length - 1]! ^ 0xFF
await writeFile(logPath(root, undefined, SessionId('bad-checksum'), 'zstd'), corruptHeader)
const ctx = await mount(root)
await expect(ctx.sessionPersistence.load(SessionId('partial-only')))
.rejects.toThrow(/empty or header-less Zstandard session log/)
await expect(ctx.sessionPersistence.load(SessionId('empty-header')))
.rejects.toThrow(/first frame is not exactly one header line/)
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header frame failed validation/)
})
})
describe('SessionPersistenceJsonl: encoding selection', () => {
it('rejects roots owned by the opposite encoding in both directions', async () => {
const rawRoot = await freshRoot('dsh-jsonl-raw-mismatch-')
const raw = await mount(rawRoot, 'none')
const rawHeader = meta('raw-log')
await raw.sessionPersistence.create(rawHeader)
await raw.sessionPersistence.append(rawHeader.id, oneTurnLog())
const defaultBackend = await mount(rawRoot)
await expect(defaultBackend.sessionPersistence.list()).rejects.toThrow(/configured for compression "zstd"/)
const zstdRoot = await freshRoot('dsh-jsonl-zstd-mismatch-')
const zstd = await mount(zstdRoot)
const zstdHeader = meta('zstd-log')
await zstd.sessionPersistence.create(zstdHeader)
await zstd.sessionPersistence.append(zstdHeader.id, oneTurnLog())
const rawBackend = await mount(zstdRoot, 'none')
await expect(rawBackend.sessionPersistence.list()).rejects.toThrow(/configured for compression "none"/)
})
it('rechecks targeted artifacts and listing after an initially empty root', async () => {
const root = await freshRoot()
const ctx = await mount(root)
expect(await ctx.sessionPersistence.list()).toEqual([])
const loadHeader = meta('late-raw-load', '/late')
await mkdir(sessionDir(root, loadHeader.cwd), { recursive: true })
await writeFile(logPath(root, loadHeader.cwd, loadHeader.id, 'none'), [
JSON.stringify(toHeaderLine(loadHeader)),
...oneTurnLog().map(eventLine),
'',
].join('\n'))
await expect(ctx.sessionPersistence.load(loadHeader.id)).rejects.toThrow(/uses \.jsonl/)
await expect((ctx.sessionPersistence as SessionPersistenceJsonl).loadLive(loadHeader.id, loadHeader.cwd))
.rejects.toThrow(/uses \.jsonl/)
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/uses \.jsonl/)
})
it('refuses materialization when an opposite artifact appears after create', async () => {
const root = await freshRoot()
const ctx = await mount(root)
await ctx.sessionPersistence.list()
const header = meta('late-raw-materialize', '/late')
await ctx.sessionPersistence.create(header)
await mkdir(sessionDir(root, header.cwd), { recursive: true })
await writeFile(logPath(root, header.cwd, header.id, 'none'), [
JSON.stringify(toHeaderLine(header)),
...oneTurnLog().map(eventLine),
'',
].join('\n'))
await expect(ctx.sessionPersistence.append(header.id, oneTurnLog())).rejects.toThrow(/uses \.jsonl/)
expect((await readdir(sessionDir(root, header.cwd))).some(name => name.endsWith('.jsonl.zstd'))).toBe(false)
})
})

View File

@@ -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.

View File

@@ -52,5 +52,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Session harvest is JSONL-only** — `runScenario` collects persisted `.jsonl` logs, so an example composed over the SQLite persistence backend has no snapshot path.
- **Session harvest requires raw JSONL mode** — `runScenario` collects persisted `.jsonl` logs, so snapshot configs set `persistenceCompression: 'none'`; compressed JSONL and SQLite compositions have no snapshot-harvest path.
- **The subprocess boots the unbuilt tsx/Loader path only** — the built-bin artifact is guarded by the separate `built-bin` e2e smokes, never by this tier.

View File

@@ -417,11 +417,11 @@ async function runStep(
* header line, and return them ordered primary-first: the top-level session (no
* `parentSession`) leads, then each subagent child by ascending `createdAt`.
*
* The JSONL backend lays sessions out as `<root>/<cwd-bucket>/<encoded-id>.jsonl`
* (one bucket per cwd), so a parent and its same-cwd in-process child land in
* the SAME bucket — collecting all files across all buckets catches both (a
* first-match short-circuit would silently drop the child). Returns `[]` if no
* log was produced (a no-session scenario).
* Snapshot configs select the JSONL backend's raw mode, which lays sessions
* out as `<root>/<cwd-bucket>/<encoded-id>.jsonl` (one bucket per cwd). A
* parent and its same-cwd in-process child land in the SAME bucket, so
* collecting all files across all buckets catches both. Returns `[]` if no log
* was produced (a no-session scenario).
*/
async function harvestSessionLogs(root: string): Promise<HarvestedLog[]> {
let cwdDirs: string[]

View File

@@ -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')
})
})

View File

@@ -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.

View File

@@ -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

View File

@@ -10,13 +10,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. [`commands`](commands/README.md) is their human-only discovery and dispatch plane; command input and output do not become model messages. 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. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages.
`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.

View File

@@ -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

View File

@@ -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 |
|---|---|

View File

@@ -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

View File

@@ -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.
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.

View File

@@ -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"
}
}

View File

@@ -1,471 +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 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)
}
/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */
function renderThrown(value: unknown): string {
try {
return String(value)
} catch {
return '<unrenderable thrown value>'
}
}
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
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)): ${renderThrown(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)): ${renderThrown(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 */

View File

@@ -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')
})
})

View File

@@ -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

View File

@@ -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"
}
]
}

View File

@@ -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.

View File

@@ -36,6 +36,7 @@ import z from 'schemastery'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
import type {} from '@deepseek-ai/dsh-commands'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
import type {
@@ -192,15 +193,6 @@ function displayText(text: string): string {
`\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`)
}
/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */
function renderThrown(value: unknown): string {
try {
return String(value)
} catch {
return '<unrenderable thrown value>'
}
}
/**
* Theme-agnostic palette built from the standard 16-color ANSI set plus SGR
* attributes, which every terminal remaps to its active color scheme. Body
@@ -1231,7 +1223,7 @@ export function createTuiChat(
},
(error: unknown) => {
if (!disposed) {
appendNotice(`Command failed: ${renderThrown(error)}`, 'error')
appendNotice(`Command failed: ${errorChain(error)}`, 'error')
}
},
).finally(() => { commandControllers.delete(controller) })
@@ -1312,7 +1304,9 @@ export function createTuiChat(
const disposeError = ctx.on('agent/error', (subject, turn, step, error) => {
if (subject !== agent) return
liveErrors.add(`${turn}:${step}`)
appendNotice(error.message, 'error')
// Full cause chain: wrapper messages like `fetch failed` carry the
// actionable transport detail on `cause`.
appendNotice(errorChain(error), 'error')
})
const disposeAgent = ctx.on('agent/disposed', (subject) => {
if (subject !== agent) return
@@ -1339,7 +1333,7 @@ export function createTuiChat(
void commandFiber.dispose().catch(
/* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */
(cleanupError: unknown) => {
ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${renderThrown(cleanupError)}`)
ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${errorChain(cleanupError)}`)
},
)
clearStatus()
@@ -1387,7 +1381,7 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi
if (settled || failedSessionId !== sessionId) return
settled = true
stopWaiting()
runtime.terminal.write(displayText(`ui-tui: session "${sessionId}" failed to start: ${renderThrown(error)}\n`))
runtime.terminal.write(displayText(`ui-tui: session "${sessionId}" failed to start: ${errorChain(error)}\n`))
runtime.exit(1)
}
@@ -1399,10 +1393,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(),

View File

@@ -464,7 +464,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('/plugin-fail')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Command failed: Error: plugin command exploded')
expect(result.terminal.output).toContain('Command failed: plugin command exploded')
result.terminal.send('/help')
result.terminal.send('\r')
await tick()
@@ -970,7 +970,7 @@ describe('terminal mounting', () => {
expect(terminal.output).toBe('')
expect(exit).not.toHaveBeenCalled()
ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), new Error('resume \u001b]2;failure-controlled\u0007'))
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: Error: resume \\x1b]2;failure-controlled\\x07\n')
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: resume \\x1b]2;failure-controlled\\x07\n')
expect(exit).toHaveBeenCalledWith(1)
const session = ctx.sessions.create(SessionId('main-session'))
@@ -999,7 +999,7 @@ describe('terminal mounting', () => {
})
expect(terminal.started).toBe(0)
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: <unrenderable thrown value>\n')
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: <unrenderable value>\n')
expect(exit).toHaveBeenCalledWith(1)
await ctx.fiber.dispose()
})

View File

@@ -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