Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md
#	docs/rfc/implemented/architecture/2026-06-20-branded-ids.md
#	docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md
#	docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md
#	docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md
#	packages/bash/tool-bash/README.md
#	packages/bash/tool-bash/src/index.ts
#	packages/bash/tool-bash/tests/tools.spec.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/tests/properties.spec.ts
#	packages/core/agent/README.md
#	packages/core/agent/src/index.ts
#	packages/guard/repeat-tool-guard/src/index.ts
#	packages/hooks/hooks-claude/tests/bridge.spec.ts
#	packages/subagent/subagent-acp/tests/mock-acp-server.ts
#	packages/ui/acp/tests/dispose.spec.ts
#	packages/ui/stdio-agent/src/index.ts
#	packages/ui/stdio-agent/src/stdio-chat.ts
#	packages/ui/stdio-agent/tests/stdio-chat.spec.ts
#	packages/util/brand/src/index.ts
This commit is contained in:
Tianyi Cui
2026-07-14 18:51:24 +08:00
513 changed files with 3607 additions and 9725 deletions

View File

@@ -1,14 +1,8 @@
#!/usr/bin/env node
/**
* The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that
* loads the {@link @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM
* adapter and a bash executor). The boot glue — `.env` loading, the fail-loud
* Loader guards, the settle-the-tree boot sequence — lives in
* {@link @deepseek-ai/dsh-app-boot}, shared with the ACP bin.
*
* Usage: `dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`). The
* `demo:echo` / `demo:repl` scripts invoke it with the example's config.
*
* Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-agent [config]`, defaulting to the
* cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in
* dsh-app-boot. The echo and REPL demos invoke this bin with their own leaf configs.
* @module @deepseek-ai/dsh-stdio-agent/bin
*/

View File

@@ -7,31 +7,17 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* BUILT-ARTIFACT smoke for the published `dsh-stdio-agent` bin. The other smokes
* boot `src/bin.ts` under tsx — but the package's `bin` field points at
* `lib/bin.js`, run under plain `node` by a real consumer. tsx masks two failure
* modes the built bin had: (1) `boot()` returned before the loader tree settled,
* so the process exited 0 with no output and load errors surfaced as unhandled
* rejections AFTER boot; (2) config-path resolution could fall back to the cwd.
* This test runs the REAL `lib/bin.js` under `node` (NOT tsx) and asserts the
* banner + echo round-trip, so a regression in the published entry fails here.
*
* It build-gates: if `lib/bin.js` is absent (suite run without `pnpm run build`)
* the test SKIPS with a note. CI runs it after the build step. Setup mirrors a
* real install: a temp dir whose `node_modules/@deepseek-ai/*` (and the vendored
* `cordis`/`@cordisjs/*`) are symlinked to the built packages, a `cordis.yml`
* that loads the app + the example's mock backend, and `node --expose-internals`
* (the cordis Loader resolves bare plugin specifiers via its internal module
* loader, active only under that flag — the same flag `demo:echo` passes).
* 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/ui/stdio-agent/lib/bin.js')
// Workspace packages the stdio app's tree needs, by repo-relative path. Each is
// symlinked into the temp consumer's node_modules under its package name, so
// plain `node` resolves the bare `@deepseek-ai/dsh-*` specifiers in cordis.yml
// to the built `lib/` (package.json `main`), exactly as an installed dep would.
// 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 = [
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
@@ -50,14 +36,9 @@ async function pkgName(absDir: string): Promise<string> {
}
/**
* Build a temp consumer dir: `node_modules` with the workspace + vendor packages
* symlinked in, a `src/` carrying the example mock backend, and a `cordis.yml`
* that wires them onto the stdio app. Returns the dir (caller removes it).
*
* `disabledBrokenEntry` appends an entry that points at a non-existent plugin but
* is marked `disabled: true`. The Loader leaves a disabled entry fiber-less by
* design, so it exercises that the fail-loud entry-load guard does NOT mistake a
* valid disabled entry for a failed import.
* 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): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-'))
@@ -153,9 +134,9 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.
}, 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. Even though its
// plugin path does not exist, the app boots and the round-trip works.
// 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')
@@ -165,10 +146,8 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.
}, 30_000)
it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => {
// A consumer who typos the config path must get a clear failure, not silent
// success. This dir does not exist, so the include PLUGIN itself fails to
// import; the cordis Loader logs that and leaves the entry with no fiber (no
// rejection), which `boot()`'s entry-load check turns into a thrown error.
// A nonexistent directory prevents even the include plugin import. Loader leaves no fiber, and
// boot's settled-entry guard must turn that state into a clear non-zero failure.
consumer = await makeConsumer('unused')
const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '')
expect(code).not.toBe(0)
@@ -176,9 +155,7 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin.
}, 30_000)
it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {
// The config DIRECTORY exists (the include plugin imports), but the file does
// not — the include's init throws "config file not found", which surfaces as
// an unhandled rejection the fail-loud guard turns into a non-zero exit.
// 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)

View File

@@ -11,20 +11,10 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import * as stdioAgent from '../src/index.ts'
/**
* Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it
* composes the console logger, the agent-core spine (pre-creating the `main`
* agent from the app config), the JSONL backend, and the readline UI in one
* `ctx.plugin`. The forwarded `model` reaches the pre-created agent and
* `persona` the system-prompt plugin; `persistenceRoot`/`welcome`/
* `resumeSessionId` route to their backends.
*
* `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev
* plugin the in-process tier cannot import); the keyless echo smoke in
* `examples/echo-agent` proves the whole subprocess tree (incl. `hmr`) boots
* through the real Loader, while the export SHAPE is pinned by this suite's
* explicit `unwrapExports` assertion (an inject-less app would boot past a
* stray default rather than crash). Here we assert the composition + config
* forwarding the unit tier can reach.
* Unit coverage for app composition and config forwarding: console logger, pre-created main agent,
* agent-core spine, JSONL backend, and readline 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): Promise<Context> {
const ctx = new Context()
@@ -181,14 +171,8 @@ describe('dsh-stdio-agent app', () => {
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
// Postmortem 0001 guard: a stray `export default apply` makes the Loader's
// `unwrapExports` (`exports.default ?? exports`) collapse the module to the
// bare `apply` function, DROPPING the named `name`/`Config`. This package has
// no `inject` export, so that collapse would NOT crash at load (the keyless
// echo smoke would still boot the tree) — it would silently lose its config
// schema. So guard the shape directly here: assert no `default` export, and
// that the real `unwrapExports` leaves `name`/`Config`/`apply` intact. Adding
// `export default` to src/index.ts fails this test.
// 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')