Merge branch 'codex/goal-session' into codex/commands
# Conflicts: # docs/architecture.md # docs/capability-seams.md # docs/config-catalog.md # docs/module-graph.md # knip.json # packages/examples/README.md # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/tui-demo/package.json # packages/ui/README.md # packages/ui/tui/src/index.ts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -20,7 +20,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
|
||||
|
||||
@@ -38,6 +38,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
|
||||
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
|
||||
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ import CommandService from '@deepseek-ai/dsh-commands'
|
||||
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'
|
||||
@@ -48,6 +51,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. */
|
||||
@@ -73,6 +78,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,
|
||||
@@ -91,6 +97,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(CommandService)
|
||||
ctx.plugin(agentCore, agentCore.pickSpineConfig(config))
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user