Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml # .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml # .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml # .agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/compaction.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/request-inspection.ts # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/README.i18n.yaml # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/fs/tool-str-replace-editor/tests/tools.spec.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts # packages/pty/tool-bash-persistent/tests/tools.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/tui.spec.ts # packages/ui/user-approval/src/index.ts # packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||
README.md: e56b726029c5bba9ba769c6dd3493d913f0129d7
|
||||
README.zh.md: 24ff9a6e8d48016d213e877e23768332d86cccde
|
||||
README.md: 7ef24dc6290af5aafe3eb53609b090fcbfb83ce8
|
||||
README.zh.md: b19d4979351940e925db3b7a616a5c87689770a1
|
||||
|
||||
@@ -3,29 +3,59 @@
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
|
||||
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
|
||||
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`, `--dump-config`, `--dump-default-config`), whose `experimental-meta` subcommand is the same TUI over this checkout, whose `experimental-upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `experimental-upgrade`, `web`, `experimental-meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
|
||||
|
||||
The TUI surface:
|
||||
|
||||
- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config <path>` applies a patch-list overlay instead of the personal overlay, while `--config-replace <path>` boots that file as the complete tree;
|
||||
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session;
|
||||
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below);
|
||||
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh experimental-meta` is the sole exception, below);
|
||||
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
|
||||
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`.
|
||||
- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context.
|
||||
- registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible.
|
||||
|
||||
`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session.
|
||||
`dsh experimental-meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Experimental meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session.
|
||||
|
||||
`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:<name>`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
|
||||
`dsh experimental-upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:<name>`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. The command takes no options — `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
|
||||
|
||||
`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`.
|
||||
|
||||
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
|
||||
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
|
||||
|
||||
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
|
||||
Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md).
|
||||
|
||||
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it and pins `native`.
|
||||
|
||||
[`core-web.cordis.yml`](config/core-web.cordis.yml) is an opt-in `dsh web --config` overlay that keeps the shipped Web host, browser, Workspace, persistence, and permission composition while reducing the default native model surface to owner-scoped persistent `bash` and `str_replace_editor`. The PTY backend and editor consume the existing Web sandbox and filesystem providers. An open persistent shell prevents changing that session's permission mode until the shell closes, so a shell created under wider access cannot survive a downgrade. `DSH_TOOLS_MODE` still controls native/Code Mode presentation for the resulting two-tool registry.
|
||||
|
||||
From a source checkout, start this minimal Web profile with:
|
||||
|
||||
```sh
|
||||
pnpm run dsh web --config apps/cli/config/core-web.cordis.yml
|
||||
```
|
||||
|
||||
Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md).
|
||||
|
||||
MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from `$DSH_HOME/config.yaml` or a `--config` overlay without a source checkout:
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: mcp-github
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: github
|
||||
transport: stdio
|
||||
command: npx
|
||||
args: ['-y', '@modelcontextprotocol/server-github']
|
||||
env:
|
||||
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
|
||||
```
|
||||
|
||||
The model then sees `mcp__github__*`. See the [mcp-client README](../../packages/mcp/mcp-client/README.md) for the Streamable HTTP transport and the full field table.
|
||||
|
||||
## Install (developer machine)
|
||||
|
||||
Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step:
|
||||
|
||||
@@ -3,29 +3,59 @@
|
||||
[English](README.md) | 中文
|
||||
|
||||
|
||||
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
|
||||
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`、`--dump-config`、`--dump-default-config`),`experimental-meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`experimental-upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`experimental-upgrade`、`web`、`experimental-meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
|
||||
|
||||
TUI 界面:
|
||||
|
||||
- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config <path>` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace <path>` 将指定文件作为完整配置树启动;
|
||||
- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话;
|
||||
- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文);
|
||||
- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh experimental-meta` 是唯一例外,见下文);
|
||||
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
|
||||
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。
|
||||
- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。
|
||||
- 注册裸 `/compact`:agent 空闲时,即使未达到自动压力,也会摘要有效的较早历史;该命令拒绝参数,并只在独立替换标记对持久化后报告成功。压缩(compaction)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。
|
||||
|
||||
`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`。
|
||||
`dsh experimental-meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Experimental meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`。
|
||||
|
||||
`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
|
||||
`dsh experimental-upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。该命令不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
|
||||
|
||||
`dsh --dump-config` 和 `dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include` 的 `applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、CLI 标志补丁)是每次调用的事实,位于配置树之外,不会出现。dump 标志会拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`。
|
||||
|
||||
Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
|
||||
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
|
||||
|
||||
`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
|
||||
每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。
|
||||
|
||||
`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:`native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 线路)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam——Loader 组合是静态的,因此该设置作用于整个进程——待 Web UI 负责逐会话工具模式选择后便会移除;TUI 界面会忽略该变量并固定为 `native`。
|
||||
|
||||
[`core-web.cordis.yml`](config/core-web.cordis.yml) 是一个可选启用的 `dsh web --config` 覆盖层:它保留已交付的 Web 宿主、浏览器、Workspace、持久化与权限组合,同时将默认的原生模型界面精简为以所有者为作用域的持久 `bash` 以及 `str_replace_editor`。PTY 后端和编辑器分别消费现有的 Web 沙箱与文件系统提供方。持久 shell 处于打开状态时,会阻止所属会话更改权限模式;因此,在较宽权限下创建的 shell 无法在降权后继续存活。`DSH_TOOLS_MODE` 仍控制由此得到的双工具注册表采用原生/Code Mode 呈现。
|
||||
|
||||
在源码 checkout 中,用以下命令启动这个精简 Web profile:
|
||||
|
||||
```sh
|
||||
pnpm run dsh web --config apps/cli/config/core-web.cordis.yml
|
||||
```
|
||||
|
||||
每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。
|
||||
|
||||
MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `$DSH_HOME/config.yaml` 或 `--config` 覆盖层挂载你自己的服务器:
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: mcp-github
|
||||
name: '@deepseek-ai/dsh-mcp-client'
|
||||
config:
|
||||
serverName: github
|
||||
transport: stdio
|
||||
command: npx
|
||||
args: ['-y', '@modelcontextprotocol/server-github']
|
||||
env:
|
||||
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
|
||||
```
|
||||
|
||||
模型随后会看到 `mcp__github__*`。Streamable HTTP 传输与完整字段表见 [mcp-client README](../../packages/mcp/mcp-client/README.md)。
|
||||
|
||||
## 安装(开发机)
|
||||
|
||||
将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout,因此代码更改会在下次启动时生效,无需构建:
|
||||
|
||||
1
apps/cli/assets/deepseek-color.svg
Normal file
1
apps/cli/assets/deepseek-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -76,6 +76,8 @@ flowchart LR
|
||||
cfg --> plugin_tui_token_meter
|
||||
plugin_tui_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"]
|
||||
cfg --> plugin_tui_compact_basic
|
||||
plugin_tui_command_compact["command-compact<br/>@deepseek-ai/dsh-command-compact"]
|
||||
cfg --> plugin_tui_command_compact
|
||||
plugin_tui_subagent["subagent<br/>@deepseek-ai/dsh-subagent"]
|
||||
cfg --> plugin_tui_subagent
|
||||
plugin_tui_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"]
|
||||
@@ -96,6 +98,28 @@ flowchart LR
|
||||
cfg --> plugin_tui_spill_local
|
||||
plugin_tui_spill_policy["spill-policy<br/>@deepseek-ai/dsh-spill-policy"]
|
||||
cfg --> plugin_tui_spill_policy
|
||||
plugin_tui_session_checkpoint_policy["session-checkpoint-policy<br/>@deepseek-ai/dsh-session-checkpoint-policy"]
|
||||
cfg --> plugin_tui_session_checkpoint_policy
|
||||
plugin_tui_tool_result_prune["tool-result-prune<br/>@deepseek-ai/dsh-compact-tool-result-prune"]
|
||||
cfg --> plugin_tui_tool_result_prune
|
||||
plugin_tui_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
|
||||
cfg --> plugin_tui_tool_todo
|
||||
plugin_tui_tool_goal["tool-goal<br/>@deepseek-ai/dsh-tool-goal"]
|
||||
cfg --> plugin_tui_tool_goal
|
||||
plugin_tui_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
|
||||
cfg --> plugin_tui_tool_ralph
|
||||
plugin_tui_tool_session_query["tool-session-query<br/>@deepseek-ai/dsh-tool-session-query"]
|
||||
cfg --> plugin_tui_tool_session_query
|
||||
plugin_tui_tool_str_replace_editor["tool-str-replace-editor<br/>@deepseek-ai/dsh-tool-str-replace-editor"]
|
||||
cfg --> plugin_tui_tool_str_replace_editor
|
||||
plugin_tui_repeat_tool_guard["repeat-tool-guard<br/>@deepseek-ai/dsh-repeat-tool-guard"]
|
||||
cfg --> plugin_tui_repeat_tool_guard
|
||||
plugin_tui_web["web<br/>@deepseek-ai/dsh-web"]
|
||||
cfg --> plugin_tui_web
|
||||
plugin_tui_web_search_deepseek["web-search-deepseek<br/>@deepseek-ai/dsh-web-search-deepseek"]
|
||||
cfg --> plugin_tui_web_search_deepseek
|
||||
plugin_tui_tool_web["tool-web<br/>@deepseek-ai/dsh-tool-web"]
|
||||
cfg --> plugin_tui_tool_web
|
||||
plugin_tui_tools["tools<br/>@deepseek-ai/dsh-tools"]
|
||||
cfg --> plugin_tui_tools
|
||||
plugin_tui_system_prompt["system-prompt<br/>@deepseek-ai/dsh-system-prompt"]
|
||||
@@ -144,6 +168,7 @@ flowchart LR
|
||||
| `plan-mode` | `@deepseek-ai/dsh-plan-mode` |
|
||||
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
|
||||
| `compact-basic` | `@deepseek-ai/dsh-compact-basic` |
|
||||
| `command-compact` | `@deepseek-ai/dsh-command-compact` |
|
||||
| `subagent` | `@deepseek-ai/dsh-subagent` |
|
||||
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
|
||||
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
|
||||
@@ -154,6 +179,17 @@ flowchart LR
|
||||
| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
|
||||
| `spill-local` | `@deepseek-ai/dsh-spill-local` |
|
||||
| `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
|
||||
| `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` |
|
||||
| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` |
|
||||
| `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
|
||||
| `tool-goal` | `@deepseek-ai/dsh-tool-goal` |
|
||||
| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
|
||||
| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` |
|
||||
| `tool-str-replace-editor` | `@deepseek-ai/dsh-tool-str-replace-editor` |
|
||||
| `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` |
|
||||
| `web` | `@deepseek-ai/dsh-web` |
|
||||
| `web-search-deepseek` | `@deepseek-ai/dsh-web-search-deepseek` |
|
||||
| `tool-web` | `@deepseek-ai/dsh-tool-web` |
|
||||
| `tools` | `@deepseek-ai/dsh-tools` |
|
||||
| `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
|
||||
| `agent-loop` | `@deepseek-ai/dsh-agent-loop` |
|
||||
|
||||
@@ -84,11 +84,11 @@
|
||||
- id: session-persistence-jsonl
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js >-
|
||||
(() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })()
|
||||
root: !!js dshHomePath('sessions')
|
||||
|
||||
# TUI consumes this shared session capability. Its launcher supplies a unique
|
||||
# process-local path; non-TUI surfaces disable the row in their overlay.
|
||||
# process-local path; other surfaces repoint or disable the row in their
|
||||
# overlay (web patches it to an ephemeral in-memory index).
|
||||
- id: session-query-sqlite
|
||||
name: '@deepseek-ai/dsh-session-query-sqlite'
|
||||
config:
|
||||
@@ -104,7 +104,9 @@
|
||||
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
|
||||
# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
|
||||
# process out (the launchers patch the row disabled; config cannot disable
|
||||
# a row). The exporter/processor values bound the shutdown drain to ~1s
|
||||
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
|
||||
# random UUID; delete the file to reset the identity) as the Resource's
|
||||
# user.id. The exporter/processor values bound the shutdown drain to ~1s
|
||||
# against an unreachable collector: exporter.timeoutMillis is both the
|
||||
# per-attempt socket timeout and the retry deadline (1s effectively
|
||||
# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
|
||||
@@ -199,6 +201,11 @@
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
|
||||
# Human `/compact`: one useful reduction below the automatic threshold. Backend
|
||||
# independent, so it follows whichever compaction service this leaf mounts.
|
||||
- id: command-compact
|
||||
name: '@deepseek-ai/dsh-command-compact'
|
||||
|
||||
- id: subagent
|
||||
name: '@deepseek-ai/dsh-subagent'
|
||||
|
||||
@@ -243,6 +250,74 @@
|
||||
config:
|
||||
maxInlineBytes: 50000
|
||||
|
||||
# Durability checkpoints before each model request and top-level dispatch.
|
||||
- id: session-checkpoint-policy
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
|
||||
# Compacts oversized tool results before the broader conversation compactor
|
||||
# runs, preserving the model-visible result within the configured budget.
|
||||
- id: tool-result-prune
|
||||
name: '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
config:
|
||||
thresholdChars: 8192
|
||||
headChars: 4096
|
||||
tailChars: 1024
|
||||
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
|
||||
# Persisted same-session goals reach the model and the slash menu here; the
|
||||
# domain, driver, and `/goal` command are above.
|
||||
- id: tool-goal
|
||||
name: '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
# Fresh-agent Ralph iteration over a build-time-fixed script.
|
||||
- id: tool-ralph
|
||||
name: '@deepseek-ai/dsh-tool-ralph'
|
||||
config:
|
||||
subagentProvider: spawn
|
||||
maxRounds: 64
|
||||
|
||||
- id: tool-session-query
|
||||
name: '@deepseek-ai/dsh-tool-session-query'
|
||||
config:
|
||||
maxSearchResults: 100
|
||||
searchTimeoutMs: 30000
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
|
||||
# Consecutive-repeat reminders on the tool chain.
|
||||
- id: repeat-tool-guard
|
||||
name: '@deepseek-ai/dsh-repeat-tool-guard'
|
||||
config:
|
||||
thresholds: [3, 5, 8]
|
||||
argumentsPreviewChars: 500
|
||||
|
||||
# Every surface enables the stable web_search model surface. DeepSeek search
|
||||
# resolves the same DEEPSEEK_API_KEY credential the Models page manages for
|
||||
# chat, at each search; its Messages endpoint is separate from the
|
||||
# chat-completions endpoint, so it takes its own base-URL override. Fetch stays
|
||||
# disabled and no fetch provider is mounted: that provider defers SSRF
|
||||
# protection and the model would choose the request target.
|
||||
- id: web
|
||||
name: '@deepseek-ai/dsh-web'
|
||||
config:
|
||||
searchProvider: deepseek-official
|
||||
|
||||
- id: web-search-deepseek
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
config:
|
||||
fetch: false
|
||||
|
||||
# ── rows every surface mounts, whose values each overlay states ──────────────
|
||||
|
||||
# The tool registry. Presentation mode is a surface choice, so each overlay
|
||||
|
||||
86
apps/cli/config/core-web.cordis.yml
Normal file
86
apps/cli/config/core-web.cordis.yml
Normal file
@@ -0,0 +1,86 @@
|
||||
# Opt-in two-tool profile over the shipped Web composition. The default native
|
||||
# model surface is exactly persistent `bash` plus `str_replace_editor`; the
|
||||
# Web host, browser shell, workspace, persistence, and permission stack remain.
|
||||
|
||||
# Disable every model-facing consumer in the base/Web tree. plan-mode owns the
|
||||
# always-registered exit_plan_mode tool even while the session is not planning.
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- id: tool-tasks
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs-search
|
||||
disabled: true
|
||||
|
||||
- id: tool-web
|
||||
disabled: true
|
||||
|
||||
- id: tool-skill
|
||||
disabled: true
|
||||
|
||||
- id: plan-mode
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-fork
|
||||
disabled: true
|
||||
|
||||
- id: tool-workflow
|
||||
disabled: true
|
||||
|
||||
- id: tool-todo
|
||||
disabled: true
|
||||
|
||||
# These consumers are shared defaults on the ordinary shipped surfaces, but
|
||||
# this opt-in profile keeps exactly its two named tools.
|
||||
- id: tool-goal
|
||||
disabled: true
|
||||
|
||||
- id: tool-ralph
|
||||
disabled: true
|
||||
|
||||
- id: tool-session-query
|
||||
disabled: true
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
disabled: true
|
||||
|
||||
# The matching browser controls must not offer host tools that this profile
|
||||
# omits. ui-question's host half owns the ask_user_question registration.
|
||||
- id: ui-plan
|
||||
disabled: true
|
||||
|
||||
- id: ui-question
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
|
||||
# This backend consumes the existing Web sandbox and permission policy.
|
||||
# It loads only on Linux/macOS; Windows and other platforms fail at boot.
|
||||
# Its 300s send wait matches the persistent Bash command timeout instead of
|
||||
# pty-local's 30s default. An open persistent shell fences permission-mode
|
||||
# changes until it closes.
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
- id: persistent-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
# The editor consumes the Web fs-sandbox provider and therefore retains
|
||||
# the selected session permission mode.
|
||||
- id: str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
@@ -71,29 +71,17 @@
|
||||
- id: agent-loop-invariant
|
||||
name: '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
|
||||
- id: session-checkpoint-policy
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
|
||||
# The derived query index behind `/resume`. The launcher provides a unique
|
||||
# process-local path because this SQLite backend has one writer owner; the
|
||||
# project-local fallback applies when no launcher sets the typed slot.
|
||||
- id: session-reference
|
||||
name: '@deepseek-ai/dsh-session-reference'
|
||||
|
||||
# Compacts oversized tool results before the broader conversation compactor
|
||||
# runs, preserving the model-visible result within the configured budget.
|
||||
- id: tool-result-prune
|
||||
name: '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
|
||||
# Persisted same-session goals reach the model and the slash menu here; the
|
||||
# domain, driver, and `/goal` command are in the base.
|
||||
- id: tool-goal
|
||||
name: '@deepseek-ai/dsh-tool-goal'
|
||||
|
||||
# A separate fixed consumer demonstrates fresh-agent Ralph iteration without
|
||||
# changing the workflow tool or same-session goal behavior.
|
||||
- id: tool-ralph
|
||||
name: '@deepseek-ai/dsh-tool-ralph'
|
||||
# Terminal-multiplexer context, mounted only where a terminal exists.
|
||||
- id: tmux-context
|
||||
name: '@deepseek-ai/dsh-tmux-context'
|
||||
config:
|
||||
refreshIntervalMs: 900000
|
||||
|
||||
# The keyboard-backed provider behind ask_user_question and the plan-mode
|
||||
# review, and the front door it renders inside.
|
||||
|
||||
@@ -14,9 +14,14 @@
|
||||
- id: hmr
|
||||
disabled: true
|
||||
|
||||
# Session query is a TUI capability; Web owns its own session presentation.
|
||||
# Web content search runs on an ephemeral in-memory index. The service
|
||||
# activates at boot, while first-search defers the node:sqlite import and
|
||||
# in-memory handle so Node 22 startup stays quiet until content search
|
||||
# actually uses SQLite. That search then reconciles this boot's sources.
|
||||
- id: session-query-sqlite
|
||||
disabled: true
|
||||
config:
|
||||
path: ':memory:'
|
||||
openAt: first-search
|
||||
|
||||
- id: tools
|
||||
config:
|
||||
@@ -92,7 +97,7 @@
|
||||
- id: storage-json
|
||||
name: '@deepseek-ai/dsh-storage-json'
|
||||
config:
|
||||
root: './.storages'
|
||||
root: !!js dshHomePath('storages')
|
||||
|
||||
- id: storage-domain
|
||||
name: '@deepseek-ai/dsh-storage-domain'
|
||||
@@ -108,9 +113,6 @@
|
||||
writeEveryEvents: 200
|
||||
writeIntervalMs: 5000
|
||||
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
|
||||
# Resolve bind host, SSH launch, and display once at boot, then mount the
|
||||
# matching dual-face directory picker. Mount -native or -browse directly in
|
||||
# an overlay to pin the interaction.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"files": [
|
||||
"lib/bin.js",
|
||||
"assets",
|
||||
"config",
|
||||
"src"
|
||||
],
|
||||
@@ -47,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-command-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
@@ -68,9 +70,13 @@
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
@@ -79,6 +85,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
|
||||
@@ -99,26 +106,34 @@
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-tmux-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-web-search-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
"@earendil-works/pi-tui": "0.80.7",
|
||||
"commander": "^15.0.0",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"js-yaml": "^4.2.0"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* for the Web/headless surface.
|
||||
* Everything here is what must exist before the Loader runs: the patch
|
||||
* composition over the shipped base and surface overlay (profile json + CLI
|
||||
* flags + the resolved frontend dist), and the fail-loud triple after the tree
|
||||
* flags + the resolved frontend dist), and the fail-loud activation audit after the tree
|
||||
* settles. The environment is what the bin already loaded (ambient plus the
|
||||
* invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
|
||||
* provider and is never hoisted here.
|
||||
@@ -246,7 +246,7 @@ export class AppCLIEntry {
|
||||
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
|
||||
}
|
||||
|
||||
/** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */
|
||||
/** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */
|
||||
private async bootTree(): Promise<void> {
|
||||
// One include of the shared base with every overlay as a sibling patch
|
||||
// list: patches never cross an include boundary, so nesting them would
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* Commander adapter for the `dsh` command-line entry: the one place argv is
|
||||
* parsed and routed to a mode. `bin.ts` switches on the returned discriminant
|
||||
* and dynamic-imports that mode's module. One program: the default (no
|
||||
* subcommand) is the TUI/headless surface with option-only flags; `meta` and
|
||||
* `web` are real subcommands. Commander owns `--help`/`--version` and parse
|
||||
* subcommand) is the TUI/headless surface with option-only flags;
|
||||
* `experimental-meta` and `web` are real subcommands. Commander owns
|
||||
* `--help`/`--version` and parse
|
||||
* errors — it prints and exits at the point of failure (a domain failure routes through
|
||||
* `command.error`), so this returns only a resolved mode.
|
||||
* @module @deepseek-ai/dsh/args
|
||||
@@ -23,6 +24,22 @@ interface TuiInvocation {
|
||||
resume?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the composed config tree and exit, without booting: `--dump-config`
|
||||
* composes the shipped base, the surface overlay, and the `--config` or
|
||||
* personal overlay — exactly the layers that surface would boot;
|
||||
* `--dump-default-config` stops at the surface overlay (the shipped tree, no
|
||||
* user layer).
|
||||
*/
|
||||
interface DumpConfigInvocation {
|
||||
mode: 'dump-config'
|
||||
surface: 'tui' | 'web'
|
||||
/** Omit the `--config`/personal layer and print only the shipped composition. */
|
||||
defaultOnly: boolean
|
||||
/** The `--config` overlay to compose instead of the personal one. */
|
||||
config?: string
|
||||
}
|
||||
|
||||
/** Headless one-shot: `dsh -p "task"`. */
|
||||
interface HeadlessInvocation {
|
||||
mode: 'headless'
|
||||
@@ -35,8 +52,8 @@ interface MetaInvocation {
|
||||
}
|
||||
|
||||
/**
|
||||
* Guided fresh-session entry: `dsh upgrade` seeds the first turn with the
|
||||
* `dsh-upgrade` skill. It always mints a
|
||||
* Guided fresh-session entry: `dsh experimental-upgrade` seeds the first turn
|
||||
* with the `dsh-upgrade` skill. It always mints a
|
||||
* fresh session in the invoking directory and takes no options — `--resume`,
|
||||
* `--config`, and `-p` are rejected as mistyped, so there is nothing to carry.
|
||||
*/
|
||||
@@ -69,6 +86,7 @@ interface WebInvocation {
|
||||
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
|
||||
export type DshInvocation =
|
||||
| TuiInvocation
|
||||
| DumpConfigInvocation
|
||||
| HeadlessInvocation
|
||||
| MetaInvocation
|
||||
| SkillSessionInvocation
|
||||
@@ -82,6 +100,34 @@ interface WebOptions {
|
||||
dev?: boolean
|
||||
workspaceRoot?: string
|
||||
trustedHost?: string[]
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the two dump flags for one surface, or return `undefined` when
|
||||
* neither was passed. Both flags together are contradictory (one includes the
|
||||
* user layer, the other excludes it) and fail loud through `error`.
|
||||
*/
|
||||
function resolveDump(
|
||||
surface: 'tui' | 'web',
|
||||
options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean },
|
||||
error: (message: string) => never,
|
||||
): DumpConfigInvocation | undefined {
|
||||
if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) return undefined
|
||||
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
|
||||
error('error: --dump-config and --dump-default-config are mutually exclusive')
|
||||
}
|
||||
const defaultOnly = options.dumpDefaultConfig === true
|
||||
if (defaultOnly && options.config !== undefined) {
|
||||
error('error: --dump-default-config prints the shipped tree and takes no --config')
|
||||
}
|
||||
return {
|
||||
mode: 'dump-config',
|
||||
surface,
|
||||
defaultOnly,
|
||||
...options.config !== undefined && { config: options.config },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,7 +181,26 @@ Examples:
|
||||
.option('--resume <id>', 'continue a past session by id')
|
||||
.option('--config <path>', 'apply this overlay of loader patches instead of the personal one')
|
||||
.option('--config-replace <path>', 'boot this file as the entire tree, ignoring the shipped and personal configuration')
|
||||
.action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => {
|
||||
.option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit')
|
||||
.option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit')
|
||||
.action((options: {
|
||||
config?: string
|
||||
configReplace?: string
|
||||
prompt?: string
|
||||
resume?: string
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}) => {
|
||||
const dump = resolveDump('tui', options, message => program.error(message))
|
||||
if (dump !== undefined) {
|
||||
// The dump prints composition; a boot-only flag alongside it would be
|
||||
// silently ignored, so reject the mix loud.
|
||||
if (options.prompt !== undefined || options.resume !== undefined || options.configReplace !== undefined) {
|
||||
program.error('error: --dump-config/--dump-default-config take none of -p/--prompt, --resume, or --config-replace')
|
||||
}
|
||||
resolved = dump
|
||||
return
|
||||
}
|
||||
if (options.prompt !== undefined) {
|
||||
// A headless prompt owns the invocation; an empty task has nothing to
|
||||
// run, and --config/--resume are TUI inputs that must not silently
|
||||
@@ -168,22 +233,31 @@ Examples:
|
||||
// a leaked config/prompt/resume option is a mistyped invocation that must fail
|
||||
// loud rather than silently run and drop the input.
|
||||
const rejectParentOptions = (command: string): void => {
|
||||
const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>()
|
||||
const parent = program.opts<{
|
||||
config?: string
|
||||
configReplace?: string
|
||||
prompt?: string
|
||||
resume?: string
|
||||
dumpConfig?: boolean
|
||||
dumpDefaultConfig?: boolean
|
||||
}>()
|
||||
if (parent.config !== undefined || parent.configReplace !== undefined
|
||||
|| parent.prompt !== undefined || parent.resume !== undefined) {
|
||||
program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, or --resume`)
|
||||
|| parent.prompt !== undefined || parent.resume !== undefined
|
||||
|| parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
|
||||
program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, --resume, --dump-config, or --dump-default-config`)
|
||||
}
|
||||
}
|
||||
|
||||
// Registration order is the rendered help order, so daily use comes first
|
||||
// and the harness-development surfaces (`web --dev`, `meta`) come last.
|
||||
// `upgrade` is a guided fresh-session entry: it takes no options and always
|
||||
// mints a fresh session, so nothing is left to carry.
|
||||
// and the harness-development surfaces (`web --dev`, `experimental-meta`)
|
||||
// come last. `experimental-upgrade` is a guided fresh-session entry: it
|
||||
// takes no options and always mints a fresh session, so nothing is left to
|
||||
// carry.
|
||||
program
|
||||
.command('upgrade')
|
||||
.command('experimental-upgrade')
|
||||
.description('update this dsh installation to the latest version')
|
||||
.action(() => {
|
||||
rejectParentOptions('upgrade')
|
||||
rejectParentOptions('experimental-upgrade')
|
||||
resolved = { mode: 'upgrade' }
|
||||
})
|
||||
|
||||
@@ -198,16 +272,23 @@ Examples:
|
||||
.option('--dev', 'developer mode: hot-reload the browser client')
|
||||
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit')
|
||||
.option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit')
|
||||
.action((options: WebOptions) => {
|
||||
rejectParentOptions('web')
|
||||
const dump = resolveDump('web', options, message => program.error(message))
|
||||
if (dump !== undefined) {
|
||||
resolved = dump
|
||||
return
|
||||
}
|
||||
resolved = resolveWeb(options)
|
||||
})
|
||||
|
||||
program
|
||||
.command('meta')
|
||||
.command('experimental-meta')
|
||||
.description('work on the dsh source that runs this command, from any directory')
|
||||
.action(() => {
|
||||
rejectParentOptions('meta')
|
||||
rejectParentOptions('experimental-meta')
|
||||
resolved = { mode: 'meta' }
|
||||
})
|
||||
|
||||
|
||||
@@ -43,14 +43,19 @@ switch (invocation.mode) {
|
||||
await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace)
|
||||
break
|
||||
}
|
||||
case 'dump-config': {
|
||||
const { runDumpConfig } = await import('./dump-config.ts')
|
||||
runDumpConfig(invocation.surface, invocation.defaultOnly, invocation.config)
|
||||
break
|
||||
}
|
||||
case 'meta': {
|
||||
const { runMeta } = await import('./tui.ts')
|
||||
await runMeta()
|
||||
const { runTui, SOURCE_ROOT } = await import('./tui.ts')
|
||||
await runTui(undefined, undefined, SOURCE_ROOT)
|
||||
break
|
||||
}
|
||||
case 'upgrade': {
|
||||
const { runSkillSession } = await import('./tui.ts')
|
||||
await runSkillSession(`dsh-${invocation.mode}`)
|
||||
const { runTui } = await import('./tui.ts')
|
||||
await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`)
|
||||
break
|
||||
}
|
||||
default:
|
||||
|
||||
61
apps/cli/src/dump-config.ts
Normal file
61
apps/cli/src/dump-config.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* `dsh --dump-config` / `dsh web --dump-config` — print the composed config
|
||||
* tree without booting: the shipped base, the surface overlay, and (unless
|
||||
* `--dump-default-config`) the `--config` or personal overlay, composed
|
||||
* through the include's own patch algorithm so the printed tree is exactly
|
||||
* what that surface would mount. `!!js` expressions print verbatim,
|
||||
* unevaluated — the dump shows composition, not one process's environment.
|
||||
* Launcher-provided boot-context values (session identity, CLI-flag patches)
|
||||
* are per-invocation facts outside the config tree and do not appear.
|
||||
* @module @deepseek-ai/dsh/dump-config
|
||||
*/
|
||||
|
||||
import { basename, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
loadOverlayPatches,
|
||||
loadPersonalPatches,
|
||||
PERSONAL_CONFIG_FILENAME,
|
||||
renderConfigDump,
|
||||
type ConfigDumpLayer,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
|
||||
const SURFACE_OVERLAYS = {
|
||||
tui: fileURLToPath(new URL('../config/tui.cordis.yml', import.meta.url)),
|
||||
web: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
|
||||
} as const
|
||||
|
||||
/* v8 ignore start -- composition over the unit-tested renderConfigDump; the
|
||||
built-bin e2e drives this path end to end */
|
||||
/**
|
||||
* Print one surface's composed config tree to stdout, with a comment
|
||||
* separator naming the file each section of rows comes from (and the layers
|
||||
* that patched it).
|
||||
* @param surface - which surface overlay to compose over the shared base.
|
||||
* @param defaultOnly - stop at the surface overlay (no `--config`/personal layer).
|
||||
* @param config - the `--config` overlay path composed instead of the personal
|
||||
* one, or `undefined` to use `$DSH_HOME/config.yaml`.
|
||||
*/
|
||||
export function runDumpConfig(surface: 'tui' | 'web', defaultOnly: boolean, config?: string): void {
|
||||
const overlay = SURFACE_OVERLAYS[surface]
|
||||
const layers: ConfigDumpLayer[] = [
|
||||
{ label: basename(overlay), patches: loadOverlayPatches(NAME, overlay) },
|
||||
]
|
||||
if (!defaultOnly) {
|
||||
if (config === undefined) {
|
||||
const personal = loadPersonalPatches(NAME)
|
||||
// The personal file may be absent; the shipped layers still print.
|
||||
if (personal !== undefined) {
|
||||
layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal })
|
||||
}
|
||||
} else {
|
||||
layers.push({ label: config, patches: loadOverlayPatches(NAME, config) })
|
||||
}
|
||||
}
|
||||
process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers))
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
111
apps/cli/src/tui-onboarding/tui-first-run-welcome-art.ts
Normal file
111
apps/cli/src/tui-onboarding/tui-first-run-welcome-art.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Static terminal rasters derived from the official 24x24 DeepSeek icon.
|
||||
*
|
||||
* Source: `../../assets/deepseek-color.svg`, whose path data is copied exactly
|
||||
* from the supplied official icon (viewBox `0 0 24 24`, fill `#4D6BFE`). Each
|
||||
* tier rasterizes that path into a square binary
|
||||
* mask without redrawing its contour. The Unicode form packs two source rows
|
||||
* into `▀`/`▄`/`█`; the ASCII fallback packs the same two bits into
|
||||
* `'`/`_`/`#`. Assets contain no ANSI and are never generated at runtime.
|
||||
* @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome-art
|
||||
*/
|
||||
|
||||
/** Responsive official-icon raster tier. */
|
||||
export type TuiFirstRunWelcomeArtTier = 'full' | 'compact' | 'minimal'
|
||||
|
||||
/** One raster with a block-cell primary and bit-equivalent ASCII fallback. */
|
||||
export interface TuiFirstRunWelcomeArt {
|
||||
/** Two vertical source pixels per terminal cell. */
|
||||
readonly unicode: readonly string[]
|
||||
/** Same two-bit cells encoded as top `'`, bottom `_`, and both `#`. */
|
||||
readonly ascii: readonly string[]
|
||||
}
|
||||
|
||||
const fullUnicode = Object.freeze([
|
||||
' ▄',
|
||||
' ▄▄▄▄▄▄▄▄▄▄███▀ ██▄',
|
||||
' ▄███████████████▄ ████▄ ▄▄▄▄██',
|
||||
' ▄███████████████████▄ ████████████▀',
|
||||
' ▄██████████████████████▄ ▀█████████▀',
|
||||
'▄███▀█████████████████████▄ ████▀▀',
|
||||
'███ ▀▀█████████▀▀▀█████████▀',
|
||||
'███ ▀███████▀█ ▀███████',
|
||||
'███▄ ▀███████▄ ▀█████▀',
|
||||
'▀███ ▀██████████████',
|
||||
' ▀███▄ ▀███████████▀',
|
||||
' ▀███▄ ▄▄▄ ▀████████▀',
|
||||
' █████▄ ███▄▄ ▀█████▄▄',
|
||||
' ▀█████████████▄▄▄▄█▀█████▀',
|
||||
' ▀▀███████████▀▀',
|
||||
])
|
||||
|
||||
const fullAscii = Object.freeze([
|
||||
' _',
|
||||
" __________###' ##_",
|
||||
' _###############_ ####_ ____##',
|
||||
" _###################_ ############'",
|
||||
" _######################_ '#########'",
|
||||
"_###'#####################_ ####''",
|
||||
"### ''#########'''#########'",
|
||||
"### '#######'# '#######",
|
||||
"###_ '#######_ '#####'",
|
||||
"'### '##############",
|
||||
" '###_ '###########'",
|
||||
" '###_ ___ '########'",
|
||||
" #####_ ###__ '#####__",
|
||||
" '#############____#'#####'",
|
||||
" ''###########''",
|
||||
])
|
||||
|
||||
const compactUnicode = Object.freeze([
|
||||
' ▄▄▄▄▄▄▄██▀ █▄ ▄',
|
||||
' ▄███████████▄▄ ███▄▄████',
|
||||
' ████████████████▄ ▀██████▀',
|
||||
'██▀▀▀▀▀████████████▄▄██▀',
|
||||
'██ ▀█████▄ ▀█████',
|
||||
'██▄ ▀████▄ ▄████',
|
||||
' ██▄ ████████▀',
|
||||
' ██▄ ▄▄ ▀█████▀',
|
||||
' ▀███▄▄▄███▄ ████▄▄',
|
||||
' ▀▀▀███████▀▀',
|
||||
])
|
||||
|
||||
const compactAscii = Object.freeze([
|
||||
" _______##' #_ _",
|
||||
' _###########__ ###__####',
|
||||
" ################_ '######'",
|
||||
"##'''''############__##'",
|
||||
"## '#####_ '#####",
|
||||
"##_ '####_ _####",
|
||||
" ##_ ########'",
|
||||
" ##_ __ '#####'",
|
||||
" '###___###_ ####__",
|
||||
" '''#######''",
|
||||
])
|
||||
|
||||
const minimalUnicode = Object.freeze([
|
||||
' ▄▄▄▄▄▄ ▄▄',
|
||||
' ▄████████▄ ▀████▀',
|
||||
'█▀▀▀▀███████▄██▀',
|
||||
'█▄ ▀███ ▀███',
|
||||
'▀█▄ ▀█████',
|
||||
' ▀█▄▄ █▄▄▀███▄',
|
||||
' ▀▀▀▀▀▀',
|
||||
])
|
||||
|
||||
const minimalAscii = Object.freeze([
|
||||
' ______ __',
|
||||
" _########_ '####'",
|
||||
"#''''#######_##'",
|
||||
"#_ '### '###",
|
||||
"'#_ '#####",
|
||||
" '#__ #__'###_",
|
||||
" ''''''",
|
||||
])
|
||||
|
||||
/** Exact-path terminal rasters by responsive tier. */
|
||||
export const TUI_FIRST_RUN_WELCOME_WHALE = Object.freeze({
|
||||
full: Object.freeze({ unicode: fullUnicode, ascii: fullAscii }),
|
||||
compact: Object.freeze({ unicode: compactUnicode, ascii: compactAscii }),
|
||||
minimal: Object.freeze({ unicode: minimalUnicode, ascii: minimalAscii }),
|
||||
}) satisfies Readonly<Record<TuiFirstRunWelcomeArtTier, TuiFirstRunWelcomeArt>>
|
||||
49
apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts
Normal file
49
apps/cli/src/tui-onboarding/tui-first-run-welcome-copy.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Centrally owned version and all-locale Chinese copy for the shipped TUI first-run notice.
|
||||
*
|
||||
* A material wording change increments {@link TUI_FIRST_RUN_WELCOME_NOTICE_VERSION}
|
||||
* so every Harness home presents the revised notice once.
|
||||
* @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome-copy
|
||||
*/
|
||||
|
||||
/** Copy version persisted after the user explicitly continues. */
|
||||
export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 4
|
||||
|
||||
/** Locale-shaped text rendered by the first-run welcome overlay. */
|
||||
export interface TuiFirstRunWelcomeNoticeCopy {
|
||||
/** Overlay heading. */
|
||||
readonly title: string
|
||||
/** Ordered prose paragraphs. */
|
||||
readonly paragraphs: readonly string[]
|
||||
/** Enter action label. */
|
||||
readonly continueLabel: string
|
||||
/** Hint shown when the prose is scrollable. */
|
||||
readonly scrollHint: string
|
||||
/** Status shown while the acknowledgement reaches disk. */
|
||||
readonly saving: string
|
||||
/** Retry message shown when the acknowledgement cannot be persisted. */
|
||||
readonly saveError: string
|
||||
}
|
||||
|
||||
/** Complete Chinese notice used for every locale. */
|
||||
const TUI_FIRST_RUN_WELCOME_CHINESE_COPY = Object.freeze<TuiFirstRunWelcomeNoticeCopy>({
|
||||
title: 'DeepSeek Harness',
|
||||
paragraphs: Object.freeze([
|
||||
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
|
||||
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
|
||||
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
|
||||
]),
|
||||
continueLabel: '继续',
|
||||
scrollHint: '↑/↓ 滚动',
|
||||
saving: '正在保存确认…',
|
||||
saveError: '无法保存确认,请按 Enter 重试。',
|
||||
})
|
||||
|
||||
/** Locale map whose entries deliberately share the single Chinese owner copy. */
|
||||
export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({
|
||||
'zh-CN': TUI_FIRST_RUN_WELCOME_CHINESE_COPY,
|
||||
en: TUI_FIRST_RUN_WELCOME_CHINESE_COPY,
|
||||
})
|
||||
|
||||
/** Locale presented by the shipped first-run notice. */
|
||||
export const TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE = 'zh-CN' as const
|
||||
385
apps/cli/src/tui-onboarding/tui-first-run-welcome.ts
Normal file
385
apps/cli/src/tui-onboarding/tui-first-run-welcome.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Effect-owned first-run overlay for the shipped `dsh` TUI.
|
||||
*
|
||||
* The launcher owns the per-DSH_HOME acknowledgement boundary; the component
|
||||
* reaches the terminal only through the mounted `ctx.tui` overlay service and
|
||||
* never touches the session or model context.
|
||||
* @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { lstat, mkdir, open, rename, rm } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import {
|
||||
Key,
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import {
|
||||
disposeRootAndExit,
|
||||
type TuiComponent,
|
||||
type TuiFocusable,
|
||||
type TuiOverlayHost,
|
||||
} from '@deepseek-ai/dsh-tui'
|
||||
import {
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
type TuiFirstRunWelcomeNoticeCopy,
|
||||
} from './tui-first-run-welcome-copy.ts'
|
||||
import {
|
||||
TUI_FIRST_RUN_WELCOME_WHALE,
|
||||
type TuiFirstRunWelcomeArtTier,
|
||||
} from './tui-first-run-welcome-art.ts'
|
||||
|
||||
// TODO: Move acknowledgement persistence behind @deepseek-ai/dsh-storage once
|
||||
// its backend contract supports concurrent host processes. This same-value
|
||||
// marker must not inherit JSON lost updates or SQLite busy failures.
|
||||
const ACKNOWLEDGEMENT_DIRECTORY = 'notices'
|
||||
const ACKNOWLEDGEMENT_BASENAME = 'tui-first-run-welcome'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'tui-first-run-welcome'
|
||||
/** The notice can open only after the terminal-local overlay service mounts. */
|
||||
export const inject = ['tui']
|
||||
|
||||
/** Launcher-resolved configuration for the terminal-local notice. */
|
||||
interface Config {
|
||||
/** Absolute DeepSeek Harness home owning this acknowledgement. */
|
||||
readonly dshHome: string
|
||||
/** Render the bit-equivalent printable ASCII icon fallback. */
|
||||
readonly asciiArt?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect an explicitly non-Unicode terminal locale for the static ASCII art fallback.
|
||||
* @param env - Process environment carrying locale and terminal declarations.
|
||||
* @returns `true` only when the environment explicitly declares an ASCII-only locale or dumb terminal.
|
||||
*/
|
||||
export function needsTuiFirstRunWelcomeAsciiArt(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): boolean {
|
||||
const locale = env.LC_ALL ?? env.LC_CTYPE ?? env.LANG
|
||||
return env.TERM === 'dumb' || locale === 'C' || locale === 'POSIX'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the immutable marker for one notice version.
|
||||
* @param dshHome - Resolved Harness home.
|
||||
* @param version - Copy version whose acknowledgement is queried.
|
||||
* @returns Absolute marker path beneath the Harness home.
|
||||
*/
|
||||
export function tuiFirstRunWelcomeAcknowledgementPath(dshHome: string, version: number): string {
|
||||
return join(
|
||||
dshHome,
|
||||
ACKNOWLEDGEMENT_DIRECTORY,
|
||||
`${ACKNOWLEDGEMENT_BASENAME}-v${String(version)}.ack`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether one notice version has been acknowledged.
|
||||
* @param dshHome - Resolved Harness home.
|
||||
* @param version - Copy version to inspect.
|
||||
* @returns `true` only for a regular marker file; a malformed marker fails loud.
|
||||
*/
|
||||
export async function hasTuiFirstRunWelcomeAcknowledgement(
|
||||
dshHome: string,
|
||||
version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
): Promise<boolean> {
|
||||
const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version)
|
||||
try {
|
||||
const info = await lstat(path)
|
||||
if (!info.isFile()) throw new Error(`TUI welcome acknowledgement is not a file: ${path}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one version acknowledgement by syncing a random same-directory file
|
||||
* before atomically replacing the immutable marker. Concurrent launches publish
|
||||
* the same fact, so same-value last-writer-wins replacement loses no state.
|
||||
* @param dshHome - Resolved Harness home.
|
||||
* @param version - Copy version being acknowledged.
|
||||
*/
|
||||
export async function acknowledgeTuiFirstRunWelcome(
|
||||
dshHome: string,
|
||||
version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
): Promise<void> {
|
||||
const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version)
|
||||
const directory = dirname(path)
|
||||
const temp = join(directory, `.${basename(path)}.${randomUUID()}.tmp`)
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 })
|
||||
await syncDirectory(dirname(directory))
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined
|
||||
try {
|
||||
handle = await open(temp, 'wx', 0o600)
|
||||
await handle.sync()
|
||||
const created = handle
|
||||
handle = undefined
|
||||
await created.close()
|
||||
await rename(temp, path)
|
||||
} catch (error) {
|
||||
/* v8 ignore start -- fault-injected UI coverage proves failed acknowledgements stay uncommitted and retryable */
|
||||
try {
|
||||
await handle?.close()
|
||||
} finally {
|
||||
await rm(temp, { force: true })
|
||||
}
|
||||
throw error
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
try {
|
||||
await syncDirectory(directory)
|
||||
/* v8 ignore next -- rename is the commit point; directory-fsync fault injection is platform-specific */
|
||||
} catch {
|
||||
// Swallow post-rename directory fsync failure: the marker is already committed,
|
||||
// and crash loss can only make the notice reappear on the safe side.
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync one POSIX directory after publishing a child entry. */
|
||||
/* v8 ignore start -- Windows rejects directory opens; POSIX unit coverage owns this path. */
|
||||
async function syncDirectory(path: string): Promise<void> {
|
||||
if (process.platform === 'win32') return
|
||||
const handle = await open(path, 'r')
|
||||
try {
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
/** Render one visible-width-padded line inside the notice frame. */
|
||||
function framed(content: string, innerWidth: number, host: TuiOverlayHost): string {
|
||||
const clipped = truncateToWidth(content, innerWidth, '')
|
||||
return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${host.theme.dim('│')}`
|
||||
}
|
||||
|
||||
/** Center one line by terminal column width. */
|
||||
function centered(content: string, width: number): string {
|
||||
const clipped = truncateToWidth(content, width, '')
|
||||
const remaining = Math.max(0, width - visibleWidth(clipped))
|
||||
return `${' '.repeat(Math.floor(remaining / 2))}${clipped}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the art tier for the actual overlay width and viewport height.
|
||||
* @param innerWidth - Columns inside the frame.
|
||||
* @param viewportRows - Current terminal rows.
|
||||
* @returns full, compact, minimal, or no art when prose must take priority.
|
||||
*/
|
||||
export function tuiFirstRunWelcomeArtTier(
|
||||
innerWidth: number,
|
||||
viewportRows: number,
|
||||
): TuiFirstRunWelcomeArtTier | undefined {
|
||||
const compositionCapacity = Math.max(1, Math.max(7, Math.floor(viewportRows * 0.9)) - 5)
|
||||
if (innerWidth >= 96 && TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length <= compositionCapacity) return 'full'
|
||||
if (innerWidth >= 80 && TUI_FIRST_RUN_WELCOME_WHALE.compact.unicode.length + 4 <= compositionCapacity) return 'compact'
|
||||
if (innerWidth >= 64 && TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode.length + 4 <= compositionCapacity) return 'minimal'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Wrap the centrally owned prose while promoting its opening quotation. */
|
||||
function proseLines(
|
||||
copy: TuiFirstRunWelcomeNoticeCopy,
|
||||
width: number,
|
||||
host: TuiOverlayHost,
|
||||
): string[] {
|
||||
const lines: string[] = []
|
||||
for (const [index, paragraph] of copy.paragraphs.entries()) {
|
||||
if (index > 0) lines.push('')
|
||||
const quoteEnd = paragraph.startsWith('“') ? paragraph.indexOf('”') : -1
|
||||
if (quoteEnd > 0) {
|
||||
const quote = paragraph.slice(0, quoteEnd + 1)
|
||||
const remainder = paragraph.slice(quoteEnd + 1).trimStart()
|
||||
lines.push(...wrapTextWithAnsi(host.theme.bold(host.theme.text(host.display(quote))), width))
|
||||
lines.push('')
|
||||
if (remainder !== '') lines.push(...wrapTextWithAnsi(host.theme.text(host.display(remainder)), width))
|
||||
} else {
|
||||
lines.push(...wrapTextWithAnsi(host.theme.text(host.display(paragraph)), width))
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Render centered static brand art without putting ANSI into its owner file. */
|
||||
function artLines(
|
||||
tier: TuiFirstRunWelcomeArtTier,
|
||||
width: number,
|
||||
host: TuiOverlayHost,
|
||||
asciiArt: boolean,
|
||||
): string[] {
|
||||
const art = TUI_FIRST_RUN_WELCOME_WHALE[tier][asciiArt ? 'ascii' : 'unicode']
|
||||
return art.map(line => centered(host.theme.brand(line), width))
|
||||
}
|
||||
|
||||
/** Responsive, scrollable notice whose only completion input is Enter. */
|
||||
export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable {
|
||||
focused = false
|
||||
private scrollOffset = 0
|
||||
private bodyCapacity = 1
|
||||
private maxScrollOffset = 0
|
||||
private saving = false
|
||||
private saveFailed = false
|
||||
|
||||
constructor(
|
||||
private readonly host: TuiOverlayHost,
|
||||
private readonly copy: TuiFirstRunWelcomeNoticeCopy,
|
||||
private readonly acknowledge: () => Promise<void>,
|
||||
private readonly exit: () => void,
|
||||
private readonly asciiArt = false,
|
||||
) {}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const frameWidth = Math.max(6, width)
|
||||
const innerWidth = Math.max(1, frameWidth - 4)
|
||||
const viewportRows = this.host.viewport.rows
|
||||
const tier = tuiFirstRunWelcomeArtTier(innerWidth, viewportRows)
|
||||
const availableRows = Math.max(7, Math.floor(viewportRows * 0.9))
|
||||
const title = this.host.theme.bold(this.host.theme.brand(this.copy.title))
|
||||
let fixedHeader: string[] = []
|
||||
let fullContentHeader: string[] = []
|
||||
let body: string[]
|
||||
let fullArt: string[] | undefined
|
||||
const fullArtWidth = 44
|
||||
|
||||
if (tier === 'full') {
|
||||
fullArt = artLines(tier, fullArtWidth, this.host, this.asciiArt)
|
||||
const contentWidth = Math.max(1, innerWidth - fullArtWidth - 3)
|
||||
fullContentHeader = [centered(title, contentWidth), '']
|
||||
body = proseLines(this.copy, contentWidth, this.host)
|
||||
} else {
|
||||
const art = tier === undefined ? [] : artLines(tier, innerWidth, this.host, this.asciiArt)
|
||||
fixedHeader = [...art, ...art.length === 0 ? [] : [''], centered(title, innerWidth), '']
|
||||
body = proseLines(this.copy, innerWidth, this.host)
|
||||
}
|
||||
|
||||
const compositionCapacity = Math.max(1, availableRows - 5)
|
||||
const bodyLimit = Math.max(1, compositionCapacity - fixedHeader.length - fullContentHeader.length)
|
||||
this.bodyCapacity = Math.min(body.length, bodyLimit)
|
||||
const maxOffset = Math.max(0, body.length - this.bodyCapacity)
|
||||
this.maxScrollOffset = maxOffset
|
||||
this.scrollOffset = Math.min(this.scrollOffset, maxOffset)
|
||||
const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + this.bodyCapacity)
|
||||
|
||||
const top = this.host.theme.dim(`╭${'─'.repeat(Math.max(0, frameWidth - 2))}╮`)
|
||||
const separator = this.host.theme.dim(`├${'─'.repeat(Math.max(0, frameWidth - 2))}┤`)
|
||||
const bottom = this.host.theme.dim(`╰${'─'.repeat(Math.max(0, frameWidth - 2))}╯`)
|
||||
const action = this.host.theme.bold(this.host.theme.accent(`Enter ${this.copy.continueLabel}`))
|
||||
const hasAbove = this.scrollOffset > 0
|
||||
const hasBelow = this.scrollOffset < maxOffset
|
||||
const scroll = hasAbove || hasBelow
|
||||
? `${hasAbove ? '↑' : ' '} ${this.copy.scrollHint} ${hasBelow ? '↓' : ' '}`
|
||||
: ''
|
||||
const status = this.saveFailed
|
||||
? this.host.theme.error(this.copy.saveError)
|
||||
: this.saving
|
||||
? this.host.theme.dim(this.copy.saving)
|
||||
: this.host.theme.dim(scroll)
|
||||
|
||||
const fullContent = [...fullContentHeader, ...visibleBody]
|
||||
const composition = fullArt === undefined
|
||||
? [...fixedHeader, ...visibleBody]
|
||||
: Array.from({ length: Math.max(fullArt.length, fullContent.length) }, (_, index) => {
|
||||
const art = fullArt[index] ?? ''
|
||||
const line = fullContent[index] ?? ''
|
||||
const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}`
|
||||
return `${left} ${line}`
|
||||
})
|
||||
|
||||
return [
|
||||
top,
|
||||
...composition.map(line => framed(line, innerWidth, this.host)),
|
||||
separator,
|
||||
framed(centered(action, innerWidth), innerWidth, this.host),
|
||||
framed(centered(status, innerWidth), innerWidth, this.host),
|
||||
bottom,
|
||||
]
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) {
|
||||
this.exit()
|
||||
return
|
||||
}
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
if (!this.saving) void this.commit()
|
||||
return
|
||||
}
|
||||
if (this.saving || matchesKey(data, Key.escape)) return
|
||||
if (matchesKey(data, Key.up)) this.scrollBy(-1)
|
||||
else if (matchesKey(data, Key.down)) this.scrollBy(1)
|
||||
else if (matchesKey(data, Key.pageUp)) this.scrollBy(-this.bodyCapacity)
|
||||
else if (matchesKey(data, Key.pageDown)) this.scrollBy(this.bodyCapacity)
|
||||
else if (matchesKey(data, Key.home)) this.scrollTo(0)
|
||||
else if (matchesKey(data, Key.end)) this.scrollTo(this.maxScrollOffset)
|
||||
}
|
||||
|
||||
private scrollBy(delta: number): void {
|
||||
this.scrollTo(this.scrollOffset + delta)
|
||||
}
|
||||
|
||||
private scrollTo(offset: number): void {
|
||||
this.scrollOffset = Math.min(this.maxScrollOffset, Math.max(0, offset))
|
||||
this.host.invalidate()
|
||||
}
|
||||
|
||||
private async commit(): Promise<void> {
|
||||
this.saving = true
|
||||
this.saveFailed = false
|
||||
this.host.invalidate()
|
||||
try {
|
||||
await this.acknowledge()
|
||||
this.host.close()
|
||||
} catch {
|
||||
this.saving = false
|
||||
this.saveFailed = true
|
||||
this.host.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the first-run notice through the mounted TUI's FIFO overlay owner.
|
||||
* @param ctx - Plugin context carrying the terminal-local TUI service.
|
||||
* @param config - Launcher-resolved Harness home.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE]
|
||||
const pending = new Set<Promise<void>>()
|
||||
const acknowledge = (): Promise<void> => {
|
||||
const task = acknowledgeTuiFirstRunWelcome(config.dshHome)
|
||||
pending.add(task)
|
||||
const settled = (): void => { pending.delete(task) }
|
||||
void task.then(settled, settled)
|
||||
return task
|
||||
}
|
||||
ctx.effect(() => async () => {
|
||||
await Promise.allSettled(pending)
|
||||
}, 'tui first-run welcome acknowledgement')
|
||||
ctx.tui.openOverlay({
|
||||
create: host => new TuiFirstRunWelcomeComponent(
|
||||
host,
|
||||
copy,
|
||||
acknowledge,
|
||||
() => { disposeRootAndExit(ctx, 0) },
|
||||
config.asciiArt ?? false,
|
||||
),
|
||||
options: {
|
||||
width: '100%',
|
||||
maxHeight: '90%',
|
||||
anchor: 'center',
|
||||
margin: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -8,12 +8,11 @@
|
||||
* from it, so `dsh` acts on whatever project it is launched in. Session storage
|
||||
* is the exception — it lives under the Harness home so `/resume` reaches every
|
||||
* workspace, and an in-place resume enters the selected session's own directory.
|
||||
* `dsh meta`
|
||||
* ({@link runMeta}) is the one exception — it makes this harness checkout the
|
||||
* workspace. `dsh upgrade` ({@link runSkillSession}) is a fresh
|
||||
* session whose first turn auto-invokes a bundled skill. After boot, the
|
||||
* agent's system prompt is told the path to this harness checkout so it can
|
||||
* find its own source.
|
||||
* `dsh experimental-meta` is the one exception — it makes this harness
|
||||
* checkout the workspace. `dsh experimental-upgrade` is a fresh session whose
|
||||
* first turn auto-invokes a bundled skill. After boot, the agent's system
|
||||
* prompt is told the path to this harness checkout so it can find its own
|
||||
* source.
|
||||
* @module @deepseek-ai/dsh/tui
|
||||
*/
|
||||
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
loadPersonalPatches,
|
||||
resolveConfigPath,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
|
||||
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
|
||||
@@ -42,6 +42,16 @@ import {
|
||||
type MainSessionIdentity,
|
||||
type TuiResumeHost,
|
||||
} from '@deepseek-ai/dsh-tui'
|
||||
import {
|
||||
apply as applyTuiFirstRunWelcome,
|
||||
hasTuiFirstRunWelcomeAcknowledgement,
|
||||
inject as tuiFirstRunWelcomeInject,
|
||||
name as tuiFirstRunWelcomeName,
|
||||
needsTuiFirstRunWelcomeAsciiArt,
|
||||
} from './tui-onboarding/tui-first-run-welcome.ts'
|
||||
import {
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
} from './tui-onboarding/tui-first-run-welcome-copy.ts'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
@@ -61,30 +71,11 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d
|
||||
// The harness checkout root: three hops up from apps/cli/{src,lib}, resolved
|
||||
// from this bin's location so it holds however `dsh` is launched (a PATH
|
||||
// symlink, an arbitrary cwd). The agent is told where its own source lives.
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
/** The harness checkout used as the `dsh experimental-meta` workspace and source prompt path. */
|
||||
export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
|
||||
the CLI PTY smoke drives this path end to end, personal overlay included */
|
||||
/**
|
||||
* Run the interactive TUI with this harness checkout as the workspace
|
||||
* (`dsh meta`), whatever directory it was launched from.
|
||||
*/
|
||||
export async function runMeta(): Promise<void> {
|
||||
return runTui(undefined, undefined, SOURCE_ROOT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive TUI as a guided fresh session whose first turn invokes a
|
||||
* bundled skill (`dsh upgrade` → `dsh-upgrade`).
|
||||
* Always mints a fresh session in the invoking directory; the skill is seeded
|
||||
* only on this first launch, so a later `--resume` of the session is an ordinary
|
||||
* TUI session with no re-injection.
|
||||
* @param skill - the bundled skill name to auto-invoke as the first turn.
|
||||
*/
|
||||
export async function runSkillSession(skill: string): Promise<void> {
|
||||
return runTui(undefined, undefined, undefined, skill)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive TUI from the invoking directory.
|
||||
* @param config - an overlay patch list applied over the shared base and the
|
||||
@@ -97,10 +88,10 @@ export async function runSkillSession(skill: string): Promise<void> {
|
||||
* {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session
|
||||
* and an overlay replacing the agent row cannot drop it.
|
||||
* @param workspace - a directory to make the workspace instead of the invoking
|
||||
* one, or `undefined` to keep the cwd. Only `dsh meta` passes it.
|
||||
* one, or `undefined` to keep the cwd. Only `dsh experimental-meta` passes it.
|
||||
* @param initialSkill - a bundled skill to auto-invoke as a fresh session's
|
||||
* first turn, or `undefined`. Set only by {@link runSkillSession} and ignored
|
||||
* on a resume, so it never re-fires; reaches the app through
|
||||
* first turn, or `undefined`. Set only by `dsh experimental-upgrade` and
|
||||
* ignored on a resume, so it never re-fires; reaches the app through
|
||||
* {@link INITIAL_SKILL_KEY}.
|
||||
* @param configReplace - a config path to boot as the ENTIRE tree, bypassing the
|
||||
* shared base, the TUI overlay, and the personal overlay alike, or `undefined`
|
||||
@@ -133,6 +124,11 @@ export async function runTui(
|
||||
// both together. Sessions themselves live under the Harness home so `/resume`
|
||||
// spans every workspace, and are unaffected by this chdir.
|
||||
if (workspace !== undefined) process.chdir(workspace)
|
||||
const dshHome = resolveDshHome()
|
||||
const showFirstRunWelcome = !await hasTuiFirstRunWelcomeAcknowledgement(
|
||||
dshHome,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
)
|
||||
process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills')
|
||||
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
|
||||
// flag, so the resumed process rehydrates through this same intake. The
|
||||
@@ -144,8 +140,9 @@ export async function runTui(
|
||||
const entry = process.argv[1]
|
||||
const execve = process.execve?.bind(process)
|
||||
const app: { current?: Context } = {}
|
||||
// Resume always enters the default surface because meta rejects parent
|
||||
// options, including `--resume`. The resumed session already persists its cwd.
|
||||
// Resume always enters the default surface because experimental-meta rejects
|
||||
// parent options, including `--resume`. The resumed session already persists
|
||||
// its cwd.
|
||||
const resumeArgs = (sessionId: string): string[] => [
|
||||
`--resume=${sessionId}`,
|
||||
// Both config flags must survive the handoff: resuming into a different
|
||||
@@ -251,5 +248,15 @@ export async function runTui(
|
||||
)
|
||||
app.current = ctx
|
||||
addHarnessSourceSection(ctx, SOURCE_ROOT)
|
||||
if (showFirstRunWelcome) {
|
||||
await ctx.plugin({
|
||||
name: tuiFirstRunWelcomeName,
|
||||
inject: tuiFirstRunWelcomeInject,
|
||||
apply: applyTuiFirstRunWelcome,
|
||||
}, {
|
||||
dshHome,
|
||||
asciiArt: needsTuiFirstRunWelcomeAsciiArt(),
|
||||
})
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
@@ -57,12 +57,14 @@ export async function runWeb(
|
||||
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
|
||||
}
|
||||
|
||||
// Install shutdown handling before publishing readiness: supervisors may
|
||||
// send a signal as soon as they observe the URL line.
|
||||
process.on('SIGTERM', () => { shutdown(0) })
|
||||
process.on('SIGINT', () => { shutdown(130) })
|
||||
|
||||
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
|
||||
// must name an address the /api trust fence was configured with.
|
||||
const lanCandidate = entry.lanAddresses[0]
|
||||
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
|
||||
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
|
||||
|
||||
process.on('SIGTERM', () => { shutdown(0) })
|
||||
process.on('SIGINT', () => { shutdown(130) })
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@ function exitCode(argv: string[]): number {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => {
|
||||
it('routes each mode by its shape: default TUI, -p headless, experimental and web subcommands', () => {
|
||||
expect(parse([])).toEqual({ mode: 'tui' })
|
||||
expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
|
||||
expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' })
|
||||
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
|
||||
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
|
||||
expect(parse(['meta'])).toEqual({ mode: 'meta' })
|
||||
expect(parse(['experimental-meta'])).toEqual({ mode: 'meta' })
|
||||
// Bare `web` carries no host/port: the shipped Web overlay owns the default.
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
|
||||
expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' })
|
||||
@@ -39,12 +39,35 @@ describe('parseDshArgs', () => {
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
|
||||
// Guided fresh-session entries carry nothing: bare mode discriminant only.
|
||||
expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' })
|
||||
expect(parse(['experimental-upgrade'])).toEqual({ mode: 'upgrade' })
|
||||
// --trusted-host is variadic and repeatable; authorities pass through unvalidated.
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
})
|
||||
|
||||
it('routes the dump flags per surface: composed with the user layer, or shipped only', () => {
|
||||
expect(parse(['--dump-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false })
|
||||
expect(parse(['--dump-config', '--config', 'c.yml']))
|
||||
.toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false, config: 'c.yml' })
|
||||
expect(parse(['--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: true })
|
||||
expect(parse(['web', '--dump-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false })
|
||||
expect(parse(['web', '--dump-config', '--config', 'w.yml']))
|
||||
.toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false, config: 'w.yml' })
|
||||
expect(parse(['web', '--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: true })
|
||||
// The two dump flags contradict each other; boot-only flags alongside a
|
||||
// dump would be silently ignored; the shipped tree takes no user overlay.
|
||||
expect(exitCode(['--dump-config', '--dump-default-config'])).toBe(1)
|
||||
expect(exitCode(['--dump-default-config', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['--dump-config', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['--dump-config', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['--dump-config', '--config-replace', 'tree.yml'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
|
||||
expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1)
|
||||
// A leaked dump flag on a subcommand that has none is a mistyped invocation.
|
||||
expect(exitCode(['experimental-meta', '--dump-config'])).toBe(1)
|
||||
expect(exitCode(['experimental-upgrade', '--dump-config'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
|
||||
// Empty resume/prompt would be swallowed downstream; --prompt mixed with
|
||||
// TUI inputs must not lose them. (Bad host/port are gated by the webserver
|
||||
@@ -65,17 +88,20 @@ describe('parseDshArgs', () => {
|
||||
expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1)
|
||||
// Same rule for each subcommand that shares no option with the default
|
||||
// surface, so a leaked flag is a typo, not something to ignore.
|
||||
// `meta` fixes its own config tree and always starts fresh, so every
|
||||
// default-surface option is rejected.
|
||||
expect(exitCode(['meta', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1)
|
||||
expect(exitCode(['meta', '-p', 'task'])).toBe(1)
|
||||
// `upgrade` takes no options: any leaked default-surface flag is a
|
||||
// mistyped invocation, not a silently-dropped input.
|
||||
expect(exitCode(['upgrade', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1)
|
||||
// `experimental-meta` fixes its own config tree and always starts fresh,
|
||||
// so every default-surface option is rejected.
|
||||
expect(exitCode(['experimental-meta', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['experimental-meta', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['experimental-meta', '--config-replace', 'tree.yml'])).toBe(1)
|
||||
expect(exitCode(['experimental-meta', '-p', 'task'])).toBe(1)
|
||||
// `experimental-upgrade` takes no options: any leaked default-surface flag
|
||||
// is a mistyped invocation, not a silently-dropped input.
|
||||
expect(exitCode(['experimental-upgrade', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['experimental-upgrade', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['-p', 'task', 'experimental-upgrade'])).toBe(1)
|
||||
// The pre-release command names have no compatibility aliases.
|
||||
expect(exitCode(['meta'])).toBe(1)
|
||||
expect(exitCode(['upgrade'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 0 for --help (disclosing web) and --version', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
|
||||
@@ -22,13 +23,20 @@ import { describe, expect, it } from 'vitest'
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
|
||||
/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */
|
||||
async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const result = await execa(process.execPath, [dshBin], {
|
||||
/**
|
||||
* Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output
|
||||
* + exit code. `env` isolates the Harness home for surfaces that read it.
|
||||
*/
|
||||
async function runBuiltBin(
|
||||
args: readonly string[] = [],
|
||||
env: Record<string, string> = {},
|
||||
): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const result = await execa(process.execPath, [dshBin, ...args], {
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
@@ -45,4 +53,61 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
// The refusal happens before any plugin mounts: stdout stays silent.
|
||||
expect(stdout).toBe('')
|
||||
}, 30_000)
|
||||
|
||||
describe('dsh --dump-config', () => {
|
||||
let home: string
|
||||
beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
|
||||
afterEach(() => { rmSync(home, { recursive: true, force: true }) })
|
||||
|
||||
it('prints the shipped TUI composition without booting or needing a TTY', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
// Base rows composed with the TUI overlay's surface values, `!!js`
|
||||
// expressions verbatim (unevaluated), and TUI-only inserted rows present.
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
|
||||
expect(stdout).toContain('model: deepseek-v4-pro')
|
||||
expect(stdout).toContain('cwd: !!js process.cwd()')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'")
|
||||
// Provenance comment separators name each section's source file.
|
||||
expect(stdout).toContain('# == base.cordis.yml')
|
||||
expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml')
|
||||
expect(stdout).toContain('# == tui.cordis.yml')
|
||||
}, 30_000)
|
||||
|
||||
it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => {
|
||||
writeFileSync(join(home, 'config.yaml'), [
|
||||
'- id: agent-loop',
|
||||
' config:',
|
||||
' agents:',
|
||||
' - id: main',
|
||||
' provider: custom-provider',
|
||||
' model: custom-model',
|
||||
'- id: only-on-web',
|
||||
' config:',
|
||||
' value: 1',
|
||||
'',
|
||||
].join('\n'))
|
||||
const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(stdout).toContain('provider: custom-provider')
|
||||
expect(stdout).not.toContain('model: deepseek-v4-pro')
|
||||
// The personal layer appears in the patched row's provenance and the
|
||||
// skipped-patch warning carries its label.
|
||||
expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`)
|
||||
expect(stderr).toContain('patch: entry "only-on-web" not found')
|
||||
|
||||
// The shipped view ignores the personal overlay entirely.
|
||||
const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home })
|
||||
expect(shipped.stdout).not.toContain('custom-provider')
|
||||
expect(shipped.stdout).toContain('model: deepseek-v4-pro')
|
||||
}, 30_000)
|
||||
|
||||
it('composes the web overlay for `dsh web --dump-config`', async () => {
|
||||
const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-tui'")
|
||||
}, 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
51
apps/cli/tests/fixtures/composition-echo-llm.ts
vendored
Normal file
51
apps/cli/tests/fixtures/composition-echo-llm.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmModelInfo,
|
||||
LlmResolvedModelInfo,
|
||||
StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Terminal marker the preset smoke waits for before it asks the TUI to exit. */
|
||||
export const COMPOSITION_REPLY_TEXT = 'Shipped composition acknowledged.'
|
||||
|
||||
// Provider id and model the keyless tail routes `main` to; that overlay is the
|
||||
// only caller, so the pair lives here as plain constants.
|
||||
const COMPOSITION_PROVIDER = 'composition-keyless'
|
||||
const COMPOSITION_MODEL = 'composition-keyless-model'
|
||||
|
||||
/**
|
||||
* Network-free adapter for the shipped-composition smoke. It answers every
|
||||
* request — tool-ful agent turns and the tool-less auxiliary calls alike — with
|
||||
* one fixed text and never calls a tool, because the assertion under test is the
|
||||
* assembled tool catalog the loop logs, not any tool's behavior.
|
||||
*/
|
||||
class CompositionEchoAdapter extends LlmAdapter {
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve([{ provider, id: COMPOSITION_MODEL, name: 'Preset Keyless' }])
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: 'Preset Keyless', context: { contextWindow: 128_000 } })
|
||||
}
|
||||
|
||||
override async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
for (const char of COMPOSITION_REPLY_TEXT) yield { type: 'text-delta', index: 0, text: char }
|
||||
yield { type: 'block-end', index: 0, block: { type: 'text', text: COMPOSITION_REPLY_TEXT } }
|
||||
yield { type: 'usage', usage: { inputTokens: 20, outputTokens: COMPOSITION_REPLY_TEXT.length } }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'composition-echo-llm'
|
||||
export const inject = ['llm']
|
||||
|
||||
/**
|
||||
* Register the network-free adapter the shipped-composition smoke routes through.
|
||||
* @param ctx - the loader-mounted plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.llm.registerAdapter([COMPOSITION_PROVIDER], new CompositionEchoAdapter())
|
||||
}
|
||||
52
apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml
vendored
Normal file
52
apps/cli/tests/fixtures/composition-keyless-tail.cordis.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Keyless tail for the shipped-composition smoke, applied as `--config` so the
|
||||
# launcher boots `base.cordis.yml` + `tui.cordis.yml` and then this file.
|
||||
#
|
||||
# Everything below is test isolation, never composition under test: the model is
|
||||
# replaced so no request leaves the process, the settle marker gates the smoke's
|
||||
# first prompt, and the session artifacts move into the smoke's temporary
|
||||
# workspace so the log inspection can read them.
|
||||
|
||||
# A patch's `name` is an assertion rather than a replacement, so the base
|
||||
# adapter row is disabled and the scripted one inserted. Relative specifiers
|
||||
# resolve against the INCLUDED file's directory (apps/cli/config), not this
|
||||
# file's, because the include moves baseUrl there.
|
||||
- id: llm-deepseek
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: composition-echo-llm
|
||||
name: '../tests/fixtures/composition-echo-llm.ts'
|
||||
- id: composition-settled
|
||||
name: '../tests/fixtures/composition-settled.ts'
|
||||
|
||||
- id: agent-loop
|
||||
config:
|
||||
agents:
|
||||
- id: main
|
||||
provider: composition-keyless
|
||||
model: composition-keyless-model
|
||||
cwd: !!js process.cwd()
|
||||
|
||||
- id: session-persistence-jsonl
|
||||
config:
|
||||
root: './.sessions'
|
||||
compression: none
|
||||
|
||||
- id: session-query-sqlite
|
||||
config:
|
||||
path: './.sessions/session-query.db'
|
||||
|
||||
# The title call is a second, tool-less request that would race the log
|
||||
# inspection for no coverage: the catalog under test rides the agent turn.
|
||||
- id: session-title-llm
|
||||
disabled: true
|
||||
|
||||
- id: tui
|
||||
config:
|
||||
sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'
|
||||
welcome: 'composition smoke ready.'
|
||||
showReasoning: true
|
||||
|
||||
# HMR watches the repository; a PTY subprocess test must not start a watcher.
|
||||
- id: hmr
|
||||
disabled: true
|
||||
24
apps/cli/tests/fixtures/composition-settled.ts
vendored
Normal file
24
apps/cli/tests/fixtures/composition-settled.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
/**
|
||||
* Marker the shipped-composition smoke gates its first prompt on. The TUI renders as soon as
|
||||
* its own fiber starts, so a prompt typed at the banner can reach the loop while
|
||||
* later rows — tool plugins, persistence — are still activating, and would
|
||||
* assemble a partial catalog. Waiting for this line makes the turn observe the
|
||||
* settled tree.
|
||||
*/
|
||||
export const COMPOSITION_SETTLED_MARKER = 'COMPOSITION_TREE_SETTLED'
|
||||
|
||||
export const name = 'composition-settled'
|
||||
|
||||
/**
|
||||
* Announce settled Loader activation on the terminal byte stream, after every
|
||||
* entry in the booted tree has started. The write is detached: awaiting the
|
||||
* Loader from inside an entry would wait on this entry's own activation.
|
||||
* @param ctx - the loader-mounted plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
void ctx.loader.await().then(() => {
|
||||
process.stdout.write(`\n${COMPOSITION_SETTLED_MARKER}\n`)
|
||||
})
|
||||
}
|
||||
8
apps/cli/tests/fixtures/memory-mcp-base.cordis.yml
vendored
Normal file
8
apps/cli/tests/fixtures/memory-mcp-base.cordis.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Minimal keyless composition for loading example MCP overlays against the
|
||||
# package-owned fixture server in memory-mcp-configs.spec.ts. Source builtins
|
||||
# keep this unit test independent of prebuilt workspace artifacts.
|
||||
- id: system-prompt
|
||||
name: cordis:memory-test-system-prompt
|
||||
|
||||
- id: tools
|
||||
name: cordis:memory-test-tools
|
||||
112
apps/cli/tests/lazy-search-startup.compat.spec.ts
Normal file
112
apps/cli/tests/lazy-search-startup.compat.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Node 22 startup-output smoke for the shipped Web CLI composition.
|
||||
*
|
||||
* Only the dedicated Node compatibility gate opts this test in after building
|
||||
* both artifacts; ordinary Vitest inventory deterministically skips it.
|
||||
* The child runs built artifacts under plain Node with the real shipped
|
||||
* config (base.cordis.yml + the web.cordis.yml overlay).
|
||||
* Its URL line follows AppCLIEntry's settled boot; SIGTERM then exercises the
|
||||
* shipped quiescent disposer.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import yaml from 'js-yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const webDist = join(repoRoot, 'apps/web/dist/index.html')
|
||||
// The web overlay owns the session-query-sqlite lazy-open patch row.
|
||||
const configPath = join(repoRoot, 'apps/cli/config/web.cordis.yml')
|
||||
const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1'
|
||||
|
||||
interface ConfigRow {
|
||||
id?: string
|
||||
config?: { openAt?: unknown }
|
||||
}
|
||||
|
||||
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar',
|
||||
construct: value => String(value),
|
||||
})
|
||||
const configSchema = yaml.JSON_SCHEMA.extend(jsExprType)
|
||||
|
||||
/** Boot the built Web CLI, wait for its settled URL, then dispose through SIGTERM. */
|
||||
function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return new Promise((resolveRun, rejectRun) => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'dsh-cli-smoke-dummy-key',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
}
|
||||
delete env.DEEPSEEK_BASE_URL
|
||||
delete env.NODE_OPTIONS
|
||||
delete env.NODE_NO_WARNINGS
|
||||
const child = spawn(process.execPath, [
|
||||
builtBin,
|
||||
'web',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
if (!settled && /dsh web: http:\/\/127\.0\.0\.1:\d+/u.test(stdout)) {
|
||||
settled = true
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
})
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
rejectRun(new Error(`built Web CLI did not settle and dispose within 60s\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
}, 60_000)
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
rejectRun(error)
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (!settled) {
|
||||
rejectRun(new Error(`built Web CLI exited before settled startup (code ${String(code)})\nstdout:\n${stdout}\nstderr:\n${stderr}`))
|
||||
return
|
||||
}
|
||||
resolveRun({ stdout, stderr, code: code ?? -1 })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!requireBuiltArtifacts)('built CLI lazy-search startup', () => {
|
||||
it('boots and disposes the shipped composition without a SQLite startup warning', async () => {
|
||||
expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true)
|
||||
expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true)
|
||||
const rows = yaml.load(await readFile(configPath, 'utf8'), { schema: configSchema }) as ConfigRow[]
|
||||
const searchRow = rows.find(row => row.id === 'session-query-sqlite')
|
||||
expect(searchRow?.config?.openAt).toBe('first-search')
|
||||
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-'))
|
||||
try {
|
||||
const result = await runBuiltWeb(cwd)
|
||||
expect(result.stdout).toMatch(/dsh web: http:\/\/127\.0\.0\.1:\d+/u)
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stderr).not.toMatch(/ExperimentalWarning: SQLite/u)
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, 70_000)
|
||||
})
|
||||
132
apps/cli/tests/memory-mcp-configs.spec.ts
Normal file
132
apps/cli/tests/memory-mcp-configs.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The third-party memory examples stay config-only. This suite parses every
|
||||
* checked-in overlay, verifies its pin/transport/secret boundary, then replaces
|
||||
* only the upstream endpoint with the package-owned keyless MCP fixture and
|
||||
* proves the real Cordis Loader discovers a tool through the generic bridge.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import * as McpClient from '@deepseek-ai/dsh-mcp-client/src/index.ts'
|
||||
|
||||
interface ExampleContract {
|
||||
file: string
|
||||
id: string
|
||||
serverName: string
|
||||
transport: 'stdio' | 'streamable-http'
|
||||
pin: string
|
||||
}
|
||||
|
||||
interface InsertedRow {
|
||||
id?: string
|
||||
name?: string
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '../../..')
|
||||
const exampleDir = resolve(root, 'examples/mcp-memory')
|
||||
const baseConfig = resolve(import.meta.dirname, 'fixtures/memory-mcp-base.cordis.yml')
|
||||
const fixtureServer = resolve(root, 'packages/mcp/mcp-client/tests/fixture-server.ts')
|
||||
|
||||
const examples: ExampleContract[] = [
|
||||
{
|
||||
file: 'memorix.cordis.yml',
|
||||
id: 'memory-memorix',
|
||||
serverName: 'memorix',
|
||||
transport: 'stdio',
|
||||
pin: '1.3.0',
|
||||
},
|
||||
{
|
||||
file: 'mcp-reference-memory.cordis.yml',
|
||||
id: 'memory-mcp-reference',
|
||||
serverName: 'reference_memory',
|
||||
transport: 'stdio',
|
||||
pin: '2026.7.4',
|
||||
},
|
||||
{
|
||||
file: 'engram.cordis.yml',
|
||||
id: 'memory-engram',
|
||||
serverName: 'engram',
|
||||
transport: 'stdio',
|
||||
pin: '1.20.0',
|
||||
},
|
||||
]
|
||||
|
||||
const liveContexts = new Set<Context>()
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...liveContexts].map(async ctx => ctx.fiber.dispose()))
|
||||
liveContexts.clear()
|
||||
})
|
||||
|
||||
function insertedRow(patches: PatchOptions[]): InsertedRow {
|
||||
expect(patches).toHaveLength(1)
|
||||
const insert = patches[0]?.insert
|
||||
expect(insert).toHaveLength(1)
|
||||
return insert?.[0] as InsertedRow
|
||||
}
|
||||
|
||||
async function waitForTool(ctx: Context, name: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!ctx.tools.schemas().some(schema => schema.name === name)) {
|
||||
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${name}`)
|
||||
await new Promise(resolveWait => setTimeout(resolveWait, 25))
|
||||
}
|
||||
}
|
||||
|
||||
describe('third-party memory MCP example overlays', () => {
|
||||
it.each(examples)('parses $file with the documented generic boundary', (contract) => {
|
||||
const file = resolve(exampleDir, contract.file)
|
||||
const source = readFileSync(file, 'utf8')
|
||||
const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file))
|
||||
|
||||
expect(row.id).toBe(contract.id)
|
||||
expect(row.name).toBe('@deepseek-ai/dsh-mcp-client')
|
||||
expect(row.config?.serverName).toBe(contract.serverName)
|
||||
expect(row.config?.transport).toBe(contract.transport)
|
||||
expect(source.split('\n', 1)[0]).toContain(contract.pin)
|
||||
expect(source).not.toMatch(/\bsk-[A-Za-z0-9_-]{8,}\b/)
|
||||
expect(source).not.toContain('DEEPSEEK_API_KEY')
|
||||
})
|
||||
|
||||
it.each(examples)('loads $file and discovers a keyless fixture tool', async (contract) => {
|
||||
const patches = loadOverlayPatches(
|
||||
'memory-mcp-config-test',
|
||||
resolve(exampleDir, contract.file),
|
||||
)
|
||||
// The static config gate verifies the checked-in bare package specifier.
|
||||
// The unit test maps it to the source module so a clean checkout needs no
|
||||
// prebuilt `lib/` artifacts before proving the Loader/MCP behavior.
|
||||
insertedRow(patches).name = 'cordis:memory-test-mcp-client'
|
||||
const fixturePatch: PatchOptions = {
|
||||
id: contract.id,
|
||||
config: {
|
||||
serverName: contract.serverName,
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
env: {},
|
||||
cwd: root,
|
||||
toolCallTimeoutMs: 5_000,
|
||||
},
|
||||
}
|
||||
const ctx = await boot(
|
||||
'memory-mcp-config-test',
|
||||
baseConfig,
|
||||
[...patches, fixturePatch],
|
||||
(ctx) => {
|
||||
liveContexts.add(ctx)
|
||||
ctx.loader.builtins['memory-test-system-prompt'] = SystemPrompt
|
||||
ctx.loader.builtins['memory-test-tools'] = ToolRegistry
|
||||
ctx.loader.builtins['memory-test-mcp-client'] = McpClient
|
||||
},
|
||||
)
|
||||
await waitForTool(ctx, `mcp__${contract.serverName}__greet`)
|
||||
}, 15_000)
|
||||
})
|
||||
@@ -6,11 +6,11 @@ import { execa } from 'execa'
|
||||
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const POSIX_PTY_DRIVER = String.raw`
|
||||
import errno, json, os, pty, select, signal, sys, time
|
||||
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds = sys.argv[1:]
|
||||
import errno, fcntl, json, os, pty, select, signal, struct, sys, termios, time
|
||||
node, launch_args_json, launch_env_json, cwd, actions_json, expected_exit, timeout_seconds, columns, rows = sys.argv[1:]
|
||||
env = os.environ.copy()
|
||||
env.update(json.loads(launch_env_json))
|
||||
env.update({"COLUMNS": "100", "LINES": "30"})
|
||||
env.update({"COLUMNS": columns, "LINES": rows})
|
||||
# Deterministic banner: a developer shell's COLORTERM=truecolor would switch the
|
||||
# banner to the per-letter gradient (one SGR per letter), breaking literal
|
||||
# DEEPSEEK assertions. The gradient path has its own unit and snapshot coverage.
|
||||
@@ -20,6 +20,7 @@ pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", int(rows), int(columns), 0, 0))
|
||||
|
||||
output = bytearray()
|
||||
action_index = 0
|
||||
@@ -36,9 +37,14 @@ while time.monotonic() < deadline:
|
||||
chunk = b""
|
||||
if chunk:
|
||||
output.extend(chunk)
|
||||
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
|
||||
while action_index < len(actions):
|
||||
marker = actions[action_index]["waitFor"].encode()
|
||||
if output.count(marker) < actions[action_index].get("occurrence", 1):
|
||||
break
|
||||
action = actions[action_index]
|
||||
if "writeFile" in action:
|
||||
if "signal" in action:
|
||||
os.kill(pid, getattr(signal, action["signal"]))
|
||||
elif "writeFile" in action:
|
||||
target = os.path.join(cwd, action["writeFile"]["path"])
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
with open(target, "w", encoding="utf-8") as handle:
|
||||
@@ -68,9 +74,15 @@ if actual_exit != int(expected_exit):
|
||||
|
||||
/** One terminal input or workspace mutation performed after its marker renders. */
|
||||
type TuiPtyAction =
|
||||
| { readonly waitFor: string; readonly send: string }
|
||||
| {
|
||||
readonly waitFor: string
|
||||
readonly occurrence?: number
|
||||
readonly send: string
|
||||
}
|
||||
| { readonly waitFor: string; readonly occurrence?: number; readonly signal: 'SIGTERM' }
|
||||
| {
|
||||
readonly waitFor: string
|
||||
readonly occurrence?: number
|
||||
readonly writeFile: { readonly path: string; readonly content: string }
|
||||
readonly send?: string
|
||||
}
|
||||
@@ -89,6 +101,12 @@ export interface TuiPtySmokeOptions {
|
||||
readonly env?: Readonly<NodeJS.ProcessEnv>
|
||||
readonly expectedExitCode?: number
|
||||
readonly timeoutMs?: number
|
||||
/** Existing isolated workspace to reuse; when omitted the harness creates and removes one. */
|
||||
readonly cwd?: string
|
||||
/** Pseudo-terminal columns; defaults to 100. */
|
||||
readonly columns?: number
|
||||
/** Pseudo-terminal rows; defaults to 30. */
|
||||
readonly rows?: number
|
||||
/** Seed the isolated workspace (`cwd`, with `$DSH_HOME` at `.dsh` and the agents home at `.agents`) before launch. */
|
||||
readonly prepare?: (cwd: string) => Promise<void>
|
||||
/** Inspect the workspace after a passing run, before the temp dir is removed. */
|
||||
@@ -119,6 +137,8 @@ async function runPosixPtySmoke(
|
||||
JSON.stringify(options.actions ?? []),
|
||||
String(options.expectedExitCode ?? 0),
|
||||
String(timeoutMs / 1_000),
|
||||
String(options.columns ?? 100),
|
||||
String(options.rows ?? 30),
|
||||
], {
|
||||
stdin: 'ignore',
|
||||
timeout: timeoutMs + 5_000,
|
||||
@@ -150,8 +170,8 @@ async function runWindowsPtySmoke(
|
||||
let timedOut = false
|
||||
const terminal = pty.spawn(launch.command, launch.args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
cols: options.columns ?? 100,
|
||||
rows: options.rows ?? 30,
|
||||
cwd,
|
||||
env: definedEnv({
|
||||
...process.env,
|
||||
@@ -159,8 +179,8 @@ async function runWindowsPtySmoke(
|
||||
// Match the POSIX driver: no COLORTERM, so the banner never takes the
|
||||
// truecolor gradient path under a developer's shell.
|
||||
COLORTERM: undefined,
|
||||
COLUMNS: '100',
|
||||
LINES: '30',
|
||||
COLUMNS: String(options.columns ?? 100),
|
||||
LINES: String(options.rows ?? 30),
|
||||
}),
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
@@ -169,9 +189,14 @@ async function runWindowsPtySmoke(
|
||||
}, timeoutMs)
|
||||
terminal.onData((chunk) => {
|
||||
output += chunk
|
||||
while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
|
||||
while (
|
||||
actionIndex < actions.length
|
||||
&& output.split(actions[actionIndex]!.waitFor).length - 1 >= (actions[actionIndex]!.occurrence ?? 1)
|
||||
) {
|
||||
const action = actions[actionIndex]!
|
||||
if ('writeFile' in action) {
|
||||
if ('signal' in action) {
|
||||
terminal.kill(action.signal)
|
||||
} else if ('writeFile' in action) {
|
||||
const target = join(cwd, action.writeFile.path)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, action.writeFile.content)
|
||||
@@ -205,7 +230,8 @@ async function runWindowsPtySmoke(
|
||||
* @returns complete pseudo-terminal output.
|
||||
*/
|
||||
export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<string> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
|
||||
const ownedCwd = options.cwd === undefined
|
||||
const cwd = options.cwd ?? await mkdtemp(join(tmpdir(), options.tempDirPrefix))
|
||||
const timeoutMs = options.timeoutMs ?? 25_000
|
||||
try {
|
||||
await options.prepare?.(cwd)
|
||||
@@ -231,6 +257,6 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<strin
|
||||
await options.inspect?.(cwd)
|
||||
return output
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
if (ownedCwd) await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
126
apps/cli/tests/shipped-composition.e2e.ts
Normal file
126
apps/cli/tests/shipped-composition.e2e.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { COMPOSITION_REPLY_TEXT } from './fixtures/composition-echo-llm.ts'
|
||||
import { COMPOSITION_SETTLED_MARKER } from './fixtures/composition-settled.ts'
|
||||
import { runTuiPtySmoke } from './pty-harness.ts'
|
||||
import { acknowledgeTuiFirstRunWelcome } from '../src/tui-onboarding/tui-first-run-welcome.ts'
|
||||
|
||||
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
// An overlay over the shipped tree, so the catalog under test is the one
|
||||
// `base.cordis.yml` + `tui.cordis.yml` assemble; the tail only swaps the model
|
||||
// and redirects session artifacts.
|
||||
const keylessTail = fileURLToPath(new URL('./fixtures/composition-keyless-tail.cordis.yml', import.meta.url))
|
||||
|
||||
/**
|
||||
* The catalog the shipped `dsh` TUI puts in front of the model, as the loop
|
||||
* logged it, minus the ripgrep-dependent pair below.
|
||||
* The absences are the composition's security decisions, not incidental gaps:
|
||||
* the `cordis_*` toolset executes model-written JavaScript that no sandbox row
|
||||
* confines, `web_fetch` chooses its own request target, and `mcp_*` servers
|
||||
* spawn outside `ctx.bash`. The composition Agent Note owns the rationale and
|
||||
* its sources.
|
||||
*/
|
||||
const EXPECTED_TUI_TOOLS = [
|
||||
'ask_user_question',
|
||||
'bash',
|
||||
'create_goal',
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'ralph',
|
||||
'read',
|
||||
'session_event_read',
|
||||
'session_event_search',
|
||||
'session_event_trace',
|
||||
'session_search',
|
||||
'session_trace',
|
||||
'skill',
|
||||
'str_replace_editor',
|
||||
'subagent',
|
||||
'subagent_fork',
|
||||
'task_kill',
|
||||
'task_list',
|
||||
'task_output',
|
||||
'todo_write',
|
||||
'update_goal',
|
||||
'web_search',
|
||||
'workflow',
|
||||
'write',
|
||||
]
|
||||
|
||||
/**
|
||||
* `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg`
|
||||
* through the mounted bash executor at load and registers neither tool when
|
||||
* ripgrep is absent. That is a host dependency, not a composition decision, so the
|
||||
* pair is asserted separately — present together or absent together.
|
||||
*/
|
||||
const RIPGREP_TOOLS = ['glob', 'grep']
|
||||
|
||||
/** The assembled request header the smoke asserts on. */
|
||||
interface LoggedHeader {
|
||||
/** Assembled tool names, sorted. */
|
||||
names: string[]
|
||||
/** `bash`'s assembled parameter properties; the escalation pair is present only under a confining executor. */
|
||||
bashArguments: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the request header the loop assembled for its first request from the
|
||||
* session log the smoke's workspace persisted — the model-visible composition
|
||||
* itself, not a registry projection taken beside it.
|
||||
* @param cwd - the smoke's temporary workspace.
|
||||
* @returns the assembled catalog, system prompt, and `bash` argument shape.
|
||||
*/
|
||||
async function loggedHeader(cwd: string): Promise<LoggedHeader> {
|
||||
const sessionsDir = join(cwd, '.sessions')
|
||||
const entries = await readdir(sessionsDir, { recursive: true })
|
||||
// A single keyless run writes one session log.
|
||||
const logRelPath = entries.find(name => name.endsWith('.jsonl'))
|
||||
if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`)
|
||||
const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
const event = JSON.parse(line) as SessionEvent
|
||||
if (event.type !== 'request/header') continue
|
||||
const tools = event.data.header.tools ?? []
|
||||
const bash = tools.find(schema => schema.name === 'bash')
|
||||
return {
|
||||
names: tools.map(schema => schema.name).sort(),
|
||||
bashArguments: (bash?.parameters as { properties?: Record<string, unknown> } | undefined)?.properties ?? {},
|
||||
}
|
||||
}
|
||||
throw new Error(`session log ${logRelPath} has no request/header event`)
|
||||
}
|
||||
|
||||
describe('shipped dsh composition (real Loader tree in a PTY)', () => {
|
||||
it('assembles exactly the shipped TUI catalog', async () => {
|
||||
let observed: LoggedHeader | undefined
|
||||
const output = await runTuiPtySmoke({
|
||||
label: 'dsh shipped composition',
|
||||
tempDirPrefix: 'dsh-shipped-tui-',
|
||||
binScript: dshBinScript,
|
||||
tsconfigPath,
|
||||
configPath: keylessTail,
|
||||
env: { DEEPSEEK_API_KEY: 'keyless-composition-no-call', DSH_TELEMETRY_DISABLED: '1' },
|
||||
prepare: cwd => acknowledgeTuiFirstRunWelcome(join(cwd, '.dsh')),
|
||||
// Artifact CI builds and smokes concurrently on a contended runner.
|
||||
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
|
||||
actions: [
|
||||
{ waitFor: COMPOSITION_SETTLED_MARKER, send: 'Describe the shipped composition.\r' },
|
||||
{ waitFor: COMPOSITION_REPLY_TEXT, send: '/exit\r' },
|
||||
],
|
||||
inspect: async (cwd) => { observed = await loggedHeader(cwd) },
|
||||
})
|
||||
expect(output).toContain(COMPOSITION_REPLY_TEXT)
|
||||
expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS)
|
||||
expect([[], RIPGREP_TOOLS]).toContainEqual(observed?.names.filter(name => RIPGREP_TOOLS.includes(name)))
|
||||
// The TUI mounts the unrestricted local executors, so `tool-bash` emits no
|
||||
// escalation pair. Pinning its absence keeps a later sandbox change from
|
||||
// arriving here unannounced.
|
||||
expect(Object.keys(observed?.bashArguments ?? {})).not.toContain('sandbox_permissions')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
terminal 100x36 buffer=normal length=66 base=30 viewport=30
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "Reply with exactly the word: — DSH TUI snapshot"
|
||||
cursor hidden column=7 viewportRow=35 bufferRow=65
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
style 10-16 bold
|
||||
1| " Reply with exactly the word:"
|
||||
style 1-28 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Context · snapshot-seed"
|
||||
style 0-22 dim
|
||||
5| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
6| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
7| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
8| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
9| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
10| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
11| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
12| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
13| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
14| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
15| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
16| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
17| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
18| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-99 dim
|
||||
19| "Older snapshot context. Older snapshot context. Older snapshot context. Older snapshot context. "
|
||||
style 0-94 dim
|
||||
20| <blank>
|
||||
21| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
22| "Reply with exactly the word: ONE. No tools. "
|
||||
23| <blank>
|
||||
24| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
25| "Reasoning "
|
||||
style 0-8 dim italic
|
||||
26| "The user wants me to reply with exactly the word \"ONE\" and use no tools. "
|
||||
style 0-71 dim italic
|
||||
27| "ONE "
|
||||
28| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
|
||||
style 0-46 dim
|
||||
29| <blank>
|
||||
30| "Keyboard shortcuts "
|
||||
style 0-17 fg=bright-magenta bold
|
||||
31| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 0-60 dim
|
||||
32| "Esc cancel turn • Ctrl+O cycle cards (collapse/expand/hide) • Ctrl+R toggle reasoning • Ctrl+L "
|
||||
style 0-99 dim
|
||||
33| "redraw "
|
||||
style 0-5 dim
|
||||
34| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 0-72 dim
|
||||
35| " "
|
||||
36| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 dim
|
||||
37| "/compact — Compact older conversation history "
|
||||
style 0-44 dim
|
||||
38| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
39| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 dim
|
||||
40| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 dim
|
||||
41| "/palette — Show every color and attribute role this terminal renders "
|
||||
style 0-67 dim
|
||||
42| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 dim
|
||||
43| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 0-87 dim
|
||||
44| "/resume — List this workspace's resumable sessions "
|
||||
style 0-49 dim
|
||||
45| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
style 0-70 dim
|
||||
46| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 dim
|
||||
47| <blank>
|
||||
48| "Context · snapshot-injector"
|
||||
style 0-26 dim
|
||||
49| "Injected while compaction was running. "
|
||||
style 0-37 dim
|
||||
50| <blank>
|
||||
51| "… earlier context was compacted … "
|
||||
style 0-32 dim
|
||||
52| <blank>
|
||||
53| "You "
|
||||
style 0-2 fg=bright-magenta bold underline
|
||||
54| "Reply with exactly the word: TWO. No tools. "
|
||||
55| <blank>
|
||||
56| "Compacted 2 history items (~387 tokens). "
|
||||
style 0-39 dim
|
||||
57| <blank>
|
||||
58| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
59| "Reasoning "
|
||||
style 0-8 dim italic
|
||||
60| "The user wants me to reply with exactly the word \"TWO\" and no tools. "
|
||||
style 0-67 dim italic
|
||||
61| "TWO "
|
||||
62| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
|
||||
style 0-46 dim
|
||||
63| <blank>
|
||||
64| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% cont"
|
||||
style 0-49 fg=bright-magenta bold
|
||||
style 52-68 dim
|
||||
style 71-90 dim
|
||||
style 93-99 dim
|
||||
65| " dsh ◍ "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
@@ -0,0 +1,76 @@
|
||||
overlay 120x30 rows=20
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-119 dim
|
||||
1| "│ ▄ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 10-37 fg=blue
|
||||
style 75-90 fg=blue bold
|
||||
style 119-119 dim
|
||||
2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 119-119 dim
|
||||
3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能 │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 119-119 dim
|
||||
4| "│ ▄███████████████████▄ ████████████▀ 仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 119-119 dim
|
||||
5| "│ ▄██████████████████████▄ ▀█████████▀ │"
|
||||
style 0-0 dim
|
||||
style 4-42 fg=blue
|
||||
style 119-119 dim
|
||||
6| "│ ▄███▀█████████████████████▄ ████▀▀ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 6-41 fg=blue
|
||||
style 49-70 bold
|
||||
style 119-119 dim
|
||||
7| "│ ███ ▀▀█████████▀▀▀█████████▀ │"
|
||||
style 0-0 dim
|
||||
style 7-40 fg=blue
|
||||
style 119-119 dim
|
||||
8| "│ ███ ▀███████▀█ ▀███████ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的 │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 119-119 dim
|
||||
9| "│ ███▄ ▀███████▄ ▀█████▀ 问题,也可能促使我们重新审视,甚至推翻已有的设计。 │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 119-119 dim
|
||||
10| "│ ▀███ ▀██████████████ │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 119-119 dim
|
||||
11| "│ ▀███▄ ▀███████████▀ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 │"
|
||||
style 0-0 dim
|
||||
style 8-38 fg=blue
|
||||
style 119-119 dim
|
||||
12| "│ ▀███▄ ▄▄▄ ▀████████▀ Session Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外 │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 119-119 dim
|
||||
13| "│ █████▄ ███▄▄ ▀█████▄▄ ,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈, │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 119-119 dim
|
||||
14| "│ ▀█████████████▄▄▄▄█▀█████▀ 都会帮助我们把它打磨得更好。 │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 119-119 dim
|
||||
15| "│ ▀▀███████████▀▀ │"
|
||||
style 0-0 dim
|
||||
style 12-34 fg=blue
|
||||
style 119-119 dim
|
||||
16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤"
|
||||
style 0-119 dim
|
||||
17| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 54-64 fg=bright-magenta bold
|
||||
style 119-119 dim
|
||||
18| "│ │"
|
||||
style 0-0 dim
|
||||
style 119-119 dim
|
||||
19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-119 dim
|
||||
@@ -0,0 +1,76 @@
|
||||
overlay 160x30 rows=20
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-159 dim
|
||||
1| "│ ▄ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 10-37 fg=blue
|
||||
style 95-110 fg=blue bold
|
||||
style 159-159 dim
|
||||
2| "│ ▄▄▄▄▄▄▄▄▄▄███▀ ██▄ │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 159-159 dim
|
||||
3| "│ ▄███████████████▄ ████▄ ▄▄▄▄██ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 159-159 dim
|
||||
4| "│ ▄███████████████████▄ ████████████▀ │"
|
||||
style 0-0 dim
|
||||
style 4-43 fg=blue
|
||||
style 159-159 dim
|
||||
5| "│ ▄██████████████████████▄ ▀█████████▀ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 4-42 fg=blue
|
||||
style 49-70 bold
|
||||
style 159-159 dim
|
||||
6| "│ ▄███▀█████████████████████▄ ████▀▀ │"
|
||||
style 0-0 dim
|
||||
style 6-41 fg=blue
|
||||
style 159-159 dim
|
||||
7| "│ ███ ▀▀█████████▀▀▀█████████▀ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已 │"
|
||||
style 0-0 dim
|
||||
style 7-40 fg=blue
|
||||
style 159-159 dim
|
||||
8| "│ ███ ▀███████▀█ ▀███████ 有的设计。 │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 159-159 dim
|
||||
9| "│ ███▄ ▀███████▄ ▀█████▀ │"
|
||||
style 0-0 dim
|
||||
style 7-39 fg=blue
|
||||
style 159-159 dim
|
||||
10| "│ ▀███ ▀██████████████ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,请设置环境变量 │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 159-159 dim
|
||||
11| "│ ▀███▄ ▀███████████▀ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我 │"
|
||||
style 0-0 dim
|
||||
style 8-38 fg=blue
|
||||
style 159-159 dim
|
||||
12| "│ ▀███▄ ▄▄▄ ▀████████▀ 们把它打磨得更好。 │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 159-159 dim
|
||||
13| "│ █████▄ ███▄▄ ▀█████▄▄ │"
|
||||
style 0-0 dim
|
||||
style 9-38 fg=blue
|
||||
style 159-159 dim
|
||||
14| "│ ▀█████████████▄▄▄▄█▀█████▀ │"
|
||||
style 0-0 dim
|
||||
style 8-39 fg=blue
|
||||
style 159-159 dim
|
||||
15| "│ ▀▀███████████▀▀ │"
|
||||
style 0-0 dim
|
||||
style 12-34 fg=blue
|
||||
style 159-159 dim
|
||||
16| "├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤"
|
||||
style 0-159 dim
|
||||
17| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 74-84 fg=bright-magenta bold
|
||||
style 159-159 dim
|
||||
18| "│ │"
|
||||
style 0-0 dim
|
||||
style 159-159 dim
|
||||
19| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-159 dim
|
||||
@@ -0,0 +1,31 @@
|
||||
overlay 60x12 rows=10
|
||||
0| "╭──────────────────────────────────────────────────────────╮"
|
||||
style 0-59 dim
|
||||
1| "│ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 22-37 fg=blue bold
|
||||
style 59-59 dim
|
||||
2| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
5| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
6| "├──────────────────────────────────────────────────────────┤"
|
||||
style 0-59 dim
|
||||
7| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 24-34 fg=bright-magenta bold
|
||||
style 59-59 dim
|
||||
8| "│ ↑/↓ 滚动 ↓ │"
|
||||
style 0-0 dim
|
||||
style 24-35 dim
|
||||
style 59-59 dim
|
||||
9| "╰──────────────────────────────────────────────────────────╯"
|
||||
style 0-59 dim
|
||||
@@ -0,0 +1,64 @@
|
||||
overlay 60x30 rows=21
|
||||
0| "╭──────────────────────────────────────────────────────────╮"
|
||||
style 0-59 dim
|
||||
1| "│ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 22-37 fg=blue bold
|
||||
style 59-59 dim
|
||||
2| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
3| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
4| "│ 测试阶段,功能仍待完善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
5| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
6| "│ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 2-23 bold
|
||||
style 59-59 dim
|
||||
7| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
8| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
9| "│ 使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
10| "│ 设计。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
11| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
12| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
13| "│ 会上传所有 Session Log;如需关闭,请设置环境变量 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
14| "│ DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议, │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
15| "│ 请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
16| "│ 它打磨得更好。 │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
17| "├──────────────────────────────────────────────────────────┤"
|
||||
style 0-59 dim
|
||||
18| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 24-34 fg=bright-magenta bold
|
||||
style 59-59 dim
|
||||
19| "│ │"
|
||||
style 0-0 dim
|
||||
style 59-59 dim
|
||||
20| "╰──────────────────────────────────────────────────────────╯"
|
||||
style 0-59 dim
|
||||
@@ -0,0 +1,89 @@
|
||||
overlay 80x30 rows=27
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-79 dim
|
||||
1| "│ ▄▄▄▄▄▄ ▄▄ │"
|
||||
style 0-0 dim
|
||||
style 33-46 fg=blue
|
||||
style 79-79 dim
|
||||
2| "│ ▄████████▄ ▀████▀ │"
|
||||
style 0-0 dim
|
||||
style 31-48 fg=blue
|
||||
style 79-79 dim
|
||||
3| "│ █▀▀▀▀███████▄██▀ │"
|
||||
style 0-0 dim
|
||||
style 32-47 fg=blue
|
||||
style 79-79 dim
|
||||
4| "│ █▄ ▀███ ▀███ │"
|
||||
style 0-0 dim
|
||||
style 32-46 fg=blue
|
||||
style 79-79 dim
|
||||
5| "│ ▀█▄ ▀█████ │"
|
||||
style 0-0 dim
|
||||
style 33-46 fg=blue
|
||||
style 79-79 dim
|
||||
6| "│ ▀█▄▄ █▄▄▀███▄ │"
|
||||
style 0-0 dim
|
||||
style 33-46 fg=blue
|
||||
style 79-79 dim
|
||||
7| "│ ▀▀▀▀▀▀ │"
|
||||
style 0-0 dim
|
||||
style 35-44 fg=blue
|
||||
style 79-79 dim
|
||||
8| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
9| "│ DeepSeek Harness │"
|
||||
style 0-0 dim
|
||||
style 32-47 fg=blue bold
|
||||
style 79-79 dim
|
||||
10| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
11| "│ 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
12| "│ 善,体验难免有些粗糙。 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
13| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
14| "│ “如切如磋,如琢如磨。” │"
|
||||
style 0-0 dim
|
||||
style 2-23 bold
|
||||
style 79-79 dim
|
||||
15| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
16| "│ 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
17| "│ 可能促使我们重新审视,甚至推翻已有的设计。 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
18| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
19| "│ 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
20| "│ Log;如需关闭,请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
21| "│ 馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
22| "│ 好。 │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
23| "├──────────────────────────────────────────────────────────────────────────────┤"
|
||||
style 0-79 dim
|
||||
24| "│ Enter 继续 │"
|
||||
style 0-0 dim
|
||||
style 34-44 fg=bright-magenta bold
|
||||
style 79-79 dim
|
||||
25| "│ │"
|
||||
style 0-0 dim
|
||||
style 79-79 dim
|
||||
26| "╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
style 0-79 dim
|
||||
346
apps/cli/tests/tui-first-run-welcome.spec.ts
Normal file
346
apps/cli/tests/tui-first-run-welcome.spec.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { visibleWidth } from '@earendil-works/pi-tui'
|
||||
import {
|
||||
type TuiOverlayHost,
|
||||
type TuiOverlayRequest,
|
||||
type TuiTheme,
|
||||
} from '@deepseek-ai/dsh-tui'
|
||||
import {
|
||||
acknowledgeTuiFirstRunWelcome,
|
||||
apply,
|
||||
hasTuiFirstRunWelcomeAcknowledgement,
|
||||
needsTuiFirstRunWelcomeAsciiArt,
|
||||
TuiFirstRunWelcomeComponent,
|
||||
tuiFirstRunWelcomeAcknowledgementPath,
|
||||
tuiFirstRunWelcomeArtTier,
|
||||
} from '../src/tui-onboarding/tui-first-run-welcome.ts'
|
||||
import {
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
|
||||
} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts'
|
||||
import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts'
|
||||
|
||||
const mockDisposeRootAndExit = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@deepseek-ai/dsh-tui', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@deepseek-ai/dsh-tui')>(),
|
||||
disposeRootAndExit: mockDisposeRootAndExit,
|
||||
}))
|
||||
|
||||
const identityTheme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => value,
|
||||
brand: (value: string) => value,
|
||||
dim: (value: string) => value,
|
||||
accent: (value: string) => value,
|
||||
success: (value: string) => value,
|
||||
warning: (value: string) => value,
|
||||
error: (value: string) => value,
|
||||
bold: (value: string) => value,
|
||||
})
|
||||
|
||||
function hostFixture(rows: number): {
|
||||
host: TuiOverlayHost
|
||||
closed: () => boolean
|
||||
invalidations: () => number
|
||||
} {
|
||||
let closed = false
|
||||
let invalidations = 0
|
||||
const controller = new AbortController()
|
||||
return {
|
||||
host: Object.freeze({
|
||||
signal: controller.signal,
|
||||
viewport: Object.freeze({ columns: 160, rows }),
|
||||
theme: identityTheme,
|
||||
display: (value: string) => value,
|
||||
invalidate: () => { invalidations += 1 },
|
||||
close: () => { closed = true },
|
||||
}),
|
||||
closed: () => closed,
|
||||
invalidations: () => invalidations,
|
||||
}
|
||||
}
|
||||
|
||||
const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE]
|
||||
const openingSentence = `${copy.paragraphs[0]!.split('。', 1)[0]}。`
|
||||
const temporaryHomes: string[] = []
|
||||
|
||||
function artAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string {
|
||||
return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim()
|
||||
}
|
||||
|
||||
function withoutWhitespace(value: string): string {
|
||||
return value.replace(/\s/gu, '')
|
||||
}
|
||||
|
||||
async function temporaryHome(prefix: string): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), prefix))
|
||||
temporaryHomes.push(home)
|
||||
return home
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mockDisposeRootAndExit.mockClear()
|
||||
await Promise.all(temporaryHomes.splice(0).map(home => rm(home, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('TUI first-run welcome acknowledgement', () => {
|
||||
it('publishes one immutable per-version marker safely across concurrent acknowledgements', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-ack-')
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(false)
|
||||
|
||||
await Promise.all(Array.from({ length: 8 }, () => acknowledgeTuiFirstRunWelcome(home)))
|
||||
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true)
|
||||
const info = await stat(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION))
|
||||
expect(info.isFile()).toBe(true)
|
||||
if (process.platform !== 'win32') expect(info.mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('treats a notice-version bump as a new one-time acknowledgement', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-version-')
|
||||
await acknowledgeTuiFirstRunWelcome(home)
|
||||
const nextVersion = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION + 1
|
||||
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(false)
|
||||
await acknowledgeTuiFirstRunWelcome(home, nextVersion)
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home, nextVersion)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a malformed marker instead of silently acknowledging it', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-malformed-')
|
||||
await mkdir(tuiFirstRunWelcomeAcknowledgementPath(home, TUI_FIRST_RUN_WELCOME_NOTICE_VERSION), {
|
||||
recursive: true,
|
||||
})
|
||||
await expect(hasTuiFirstRunWelcomeAcknowledgement(home)).rejects.toThrow('is not a file')
|
||||
await expect(acknowledgeTuiFirstRunWelcome(home)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('detects only explicit ASCII-only terminal environments', () => {
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ TERM: 'dumb' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LC_ALL: 'C' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LC_CTYPE: 'POSIX' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'C' })).toBe(true)
|
||||
expect(needsTuiFirstRunWelcomeAsciiArt({ LANG: 'en_US.UTF-8' })).toBe(false)
|
||||
expect(typeof needsTuiFirstRunWelcomeAsciiArt()).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TUI first-run welcome composition', () => {
|
||||
it('pins the supplied official icon and exact Chinese copy at their owner boundaries', async () => {
|
||||
const icon = (await readFile(new URL('../assets/deepseek-color.svg', import.meta.url), 'utf8')).trimEnd()
|
||||
expect(createHash('sha256').update(icon).digest('hex'))
|
||||
.toBe('deba5f98a5c1796e20fcac3149bcd7eb8a32f0bdd04d048819400b1f28bd1439')
|
||||
expect(createHash('sha256').update(copy.paragraphs.join('\n')).digest('hex'))
|
||||
.toBe('99f9a828b4f083b28de21bf5e03f939c00238531e765db78911957c44c6e98da')
|
||||
expect(TUI_FIRST_RUN_WELCOME_NOTICE_COPY.en).toBe(copy)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ columns: 60, inner: 50, rows: 30, tier: undefined },
|
||||
{ columns: 80, inner: 68, rows: 30, tier: 'minimal' },
|
||||
{ columns: 100, inner: 84, rows: 34, tier: 'compact' },
|
||||
{ columns: 120, inner: 104, rows: 30, tier: 'full' },
|
||||
{ columns: 160, inner: 140, rows: 30, tier: 'full' },
|
||||
] as const)('renders the responsive composition at $columns columns without overdraw', ({ inner, rows, tier }) => {
|
||||
const fixture = hostFixture(rows)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
|
||||
const renderWidth = inner + 4
|
||||
const lines = component.render(renderWidth)
|
||||
|
||||
expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier)
|
||||
expect(lines.every(line => visibleWidth(line) <= renderWidth)).toBe(true)
|
||||
if (tier === undefined) {
|
||||
expect(lines.join('\n')).not.toMatch(/[▀▄█]/u)
|
||||
} else {
|
||||
expect(lines.join('\n')).toContain(artAnchor(tier))
|
||||
}
|
||||
const rendered = lines.join('\n')
|
||||
const optOut = copy.paragraphs.at(-1)!.match(/[A-Z_]+=1/u)![0]
|
||||
expect(rendered).not.toContain(copy.scrollHint)
|
||||
expect(rendered).toContain(copy.paragraphs.at(-1)!.match(/[A-Za-z]+ [A-Za-z]+/u)![0])
|
||||
expect(rendered).toContain(optOut)
|
||||
expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`)
|
||||
expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9))
|
||||
expect(lines.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ inner: 68, rows: 14, tier: undefined },
|
||||
{ inner: 68, rows: 17, tier: undefined },
|
||||
{ inner: 68, rows: 18, tier: 'minimal' },
|
||||
{ inner: 84, rows: 21, tier: 'minimal' },
|
||||
{ inner: 84, rows: 22, tier: 'compact' },
|
||||
] as const)('degrades art to preserve the action at $rows rows', ({ inner, rows, tier }) => {
|
||||
const fixture = hostFixture(rows)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
|
||||
const lines = component.render(inner + 4)
|
||||
expect(tuiFirstRunWelcomeArtTier(inner, rows)).toBe(tier)
|
||||
expect(lines.length).toBeLessThanOrEqual(Math.floor(rows * 0.9))
|
||||
expect(lines.join('\n')).toContain(`Enter ${copy.continueLabel}`)
|
||||
})
|
||||
|
||||
it('drops the whale at low height while keeping prose, scrolling, and Enter reachable', () => {
|
||||
const fixture = hostFixture(10)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {})
|
||||
const initial = component.render(54).join('\n')
|
||||
expect(tuiFirstRunWelcomeArtTier(50, 10)).toBeUndefined()
|
||||
expect(initial).toContain(openingSentence)
|
||||
expect(initial).toContain(`Enter ${copy.continueLabel}`)
|
||||
|
||||
component.handleInput('\x1b[F')
|
||||
const end = component.render(54).join('\n')
|
||||
expect(withoutWhitespace(end)).toContain(withoutWhitespace(copy.paragraphs.at(-1)!.slice(-7)))
|
||||
expect(end).toContain(`Enter ${copy.continueLabel}`)
|
||||
|
||||
for (const key of ['\x1b[A', '\x1b[B', '\x1b[5~', '\x1b[6~', '\x1b[H', 'x']) {
|
||||
component.handleInput(key)
|
||||
}
|
||||
component.invalidate()
|
||||
})
|
||||
|
||||
it('renders a tiny viewport and a quotation-only paragraph without overdraw', () => {
|
||||
const fixture = hostFixture(5)
|
||||
const quoteOnly = { ...copy, paragraphs: ['“如切如磋,如琢如磨。”'] }
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, quoteOnly, async () => {}, () => {})
|
||||
const lines = component.render(2)
|
||||
expect(lines.every(line => visibleWidth(line) <= 6)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the side-by-side composition aligned when prose outgrows the full raster', () => {
|
||||
const fixture = hostFixture(40)
|
||||
const longCopy = { ...copy, paragraphs: [copy.paragraphs.join(' ').repeat(4)] }
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, longCopy, async () => {}, () => {})
|
||||
const lines = component.render(100)
|
||||
expect(lines.length).toBeGreaterThan(TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length)
|
||||
expect(lines.every(line => visibleWidth(line) <= 100)).toBe(true)
|
||||
component.handleInput('\x1b[F')
|
||||
expect(component.render(100).join('\n')).toContain(copy.title)
|
||||
})
|
||||
|
||||
it('renders the bit-equivalent ASCII icon fallback for an explicitly non-Unicode terminal', () => {
|
||||
const fixture = hostFixture(30)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {}, () => {}, true)
|
||||
const rendered = component.render(72).join('\n')
|
||||
expect(rendered).toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim())
|
||||
expect(rendered).not.toMatch(/[▀▄█]/u)
|
||||
})
|
||||
|
||||
it.each(['full', 'compact', 'minimal'] as const)('keeps the $tier ASCII raster bit-equivalent', (tier) => {
|
||||
const mapped = TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode.map(line => Array.from(line).map((cell) => {
|
||||
if (cell === '▀') return "'"
|
||||
if (cell === '▄') return '_'
|
||||
if (cell === '█') return '#'
|
||||
return cell
|
||||
}).join(''))
|
||||
expect(mapped).toEqual(TUI_FIRST_RUN_WELCOME_WHALE[tier].ascii)
|
||||
})
|
||||
|
||||
it('ignores Escape and acknowledges only Enter before closing', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
const acknowledge = vi.fn(async () => {})
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {})
|
||||
component.render(72)
|
||||
|
||||
component.handleInput('\x1b')
|
||||
await Promise.resolve()
|
||||
expect(acknowledge).not.toHaveBeenCalled()
|
||||
expect(fixture.closed()).toBe(false)
|
||||
|
||||
component.handleInput('\r')
|
||||
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
|
||||
expect(acknowledge).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the notice eligible when Ctrl+C or Ctrl+D requests a normal exit', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
const acknowledge = vi.fn(async () => {})
|
||||
const exit = vi.fn()
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, exit)
|
||||
component.handleInput('\x03')
|
||||
component.handleInput('\x04')
|
||||
expect(exit).toHaveBeenCalledTimes(2)
|
||||
expect(acknowledge).not.toHaveBeenCalled()
|
||||
expect(fixture.closed()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not start a second acknowledgement while the first Enter is pending', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
const acknowledge = vi.fn(async () => pending.promise)
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, acknowledge, () => {})
|
||||
component.render(72)
|
||||
|
||||
component.handleInput('\r')
|
||||
component.handleInput('\r')
|
||||
component.handleInput('\x1b[B')
|
||||
expect(component.render(72).join('\n')).toContain(copy.saving)
|
||||
expect(acknowledge).toHaveBeenCalledOnce()
|
||||
|
||||
pending.resolve(undefined)
|
||||
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
|
||||
})
|
||||
|
||||
it('keeps the overlay open after a persistence failure and lets Enter retry', async () => {
|
||||
const fixture = hostFixture(30)
|
||||
let attempts = 0
|
||||
const component = new TuiFirstRunWelcomeComponent(fixture.host, copy, async () => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error('disk unavailable')
|
||||
}, () => {})
|
||||
component.render(72)
|
||||
|
||||
component.handleInput('\r')
|
||||
await vi.waitFor(() => {
|
||||
expect(component.render(72).join('\n')).toContain(copy.saveError)
|
||||
})
|
||||
expect(fixture.closed()).toBe(false)
|
||||
|
||||
component.handleInput('\r')
|
||||
await vi.waitFor(() => { expect(fixture.closed()).toBe(true) })
|
||||
expect(attempts).toBe(2)
|
||||
expect(fixture.invalidations()).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('opens through the TUI extension and uses the launcher-owned acknowledgement closure', async () => {
|
||||
const home = await temporaryHome('dsh-tui-welcome-apply-')
|
||||
let request: TuiOverlayRequest | undefined
|
||||
let disposePending: (() => Promise<void>) | undefined
|
||||
const ctx = {
|
||||
effect(register: () => () => Promise<void>) {
|
||||
disposePending = register()
|
||||
return () => {}
|
||||
},
|
||||
tui: {
|
||||
openOverlay(value: TuiOverlayRequest) {
|
||||
request = value
|
||||
return {} as never
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
apply(ctx, { dshHome: home })
|
||||
expect(request?.options).toEqual({
|
||||
width: '100%',
|
||||
maxHeight: '90%',
|
||||
anchor: 'center',
|
||||
margin: 0,
|
||||
})
|
||||
|
||||
const fixture = hostFixture(30)
|
||||
const component = request?.create(fixture.host)
|
||||
expect(component).toBeInstanceOf(TuiFirstRunWelcomeComponent)
|
||||
component?.handleInput?.('\x03')
|
||||
expect(mockDisposeRootAndExit).toHaveBeenCalledWith(ctx, 0)
|
||||
component?.handleInput?.('\r')
|
||||
await disposePending?.()
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(home)).toBe(true)
|
||||
|
||||
apply(ctx, { dshHome: home, asciiArt: true })
|
||||
expect(request?.create(fixture.host).render(72).join('\n'))
|
||||
.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.ascii[0]!.trim())
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -8,12 +9,30 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
|
||||
import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
|
||||
import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts'
|
||||
import {
|
||||
acknowledgeTuiFirstRunWelcome,
|
||||
hasTuiFirstRunWelcomeAcknowledgement,
|
||||
} from '../src/tui-onboarding/tui-first-run-welcome.ts'
|
||||
import {
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
|
||||
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
|
||||
} from '../src/tui-onboarding/tui-first-run-welcome-copy.ts'
|
||||
import { TUI_FIRST_RUN_WELCOME_WHALE } from '../src/tui-onboarding/tui-first-run-welcome-art.ts'
|
||||
|
||||
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
// `--config` layers an overlay over the shared base, so the default surface
|
||||
// needs no config argument at all; these are the overlays under test.
|
||||
const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url))
|
||||
const synchronizedFrameEnd = '\x1b[?2026l'
|
||||
// Artifact mode gives the inner PTY driver 60 seconds and its execa owner a
|
||||
// five-second backstop. Keep Vitest outside both deadlines so the harness can
|
||||
// report its own marker, exit, and cleanup failure instead of being cut off.
|
||||
const PTY_SMOKE_TEST_TIMEOUT_MS = process.env.DSH_EXAMPLE_MODE === 'lib'
|
||||
? 75_000
|
||||
: LOADER_SMOKE_TEST_TIMEOUT_MS
|
||||
|
||||
/**
|
||||
* Seed the isolated process workspace: ordinary files land in `cwd`, personal
|
||||
@@ -125,20 +144,77 @@ async function readLoggedRequestContext(cwd: string): Promise<LoggedRequestConte
|
||||
* `tui.cordis.yml`, with no flags) or `configPath` (an overlay layered over that
|
||||
* same base through `--config`).
|
||||
*/
|
||||
function smoke(overrides: Partial<TuiPtySmokeOptions> & { label: string }): Promise<string> {
|
||||
function smoke(overrides: Partial<TuiPtySmokeOptions> & {
|
||||
label: string
|
||||
showFirstRunWelcome?: boolean
|
||||
}): Promise<string> {
|
||||
const { showFirstRunWelcome = false, prepare, ...options } = overrides
|
||||
return runTuiPtySmoke({
|
||||
tempDirPrefix: 'dsh-tui-smoke-',
|
||||
binScript: dshBinScript,
|
||||
tsconfigPath,
|
||||
// Telemetry now mounts in the shared base: keep fixture sessions from
|
||||
// POSTing to the production endpoint when run outside CI's workflow env.
|
||||
env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call', DSH_TELEMETRY_DISABLED: '1' },
|
||||
env: {
|
||||
DEEPSEEK_API_KEY: 'keyless-tui-no-call',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
LC_CTYPE: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
},
|
||||
// Artifact CI builds and smokes concurrently on a contended runner.
|
||||
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
|
||||
...overrides,
|
||||
...options,
|
||||
prepare: async (cwd) => {
|
||||
if (!showFirstRunWelcome) await acknowledgeTuiFirstRunWelcome(join(cwd, '.dsh'))
|
||||
await prepare?.(cwd)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const firstRunCopy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE]
|
||||
const firstRunOpeningSentence = `${firstRunCopy.paragraphs[0]!.split('。', 1)[0]}。`
|
||||
|
||||
function firstRunArtAnchor(tier: keyof typeof TUI_FIRST_RUN_WELCOME_WHALE): string {
|
||||
return TUI_FIRST_RUN_WELCOME_WHALE[tier].unicode[tier === 'full' ? 2 : 0]!.trim()
|
||||
}
|
||||
|
||||
/** Keep only the overlay rows, excluding platform-specific scrollback and the underlying TUI. */
|
||||
function overlaySnapshot(snapshot: string, columns: number, rows: number): string {
|
||||
const blocks: string[][] = []
|
||||
for (const line of snapshot.split('\n')) {
|
||||
if (/^\d+(?:-\d+)?~?\| /u.test(line)) blocks.push([line])
|
||||
else if (line.startsWith(' style ') && blocks.length > 0) blocks.at(-1)?.push(line)
|
||||
}
|
||||
const first = blocks.findIndex(block => block[0]?.includes('╭') === true)
|
||||
const last = blocks.findIndex((block, index) => index >= first && block[0]?.includes('╰') === true)
|
||||
if (first < 0 || last < first) throw new Error('first-run PTY snapshot has no complete overlay frame')
|
||||
const overlay = blocks.slice(first, last + 1).flatMap((block, index) => [
|
||||
block[0]!.replace(/^\d+(?:-\d+)?(~)?\|/u, `${String(index)}$1|`),
|
||||
...block.slice(1),
|
||||
])
|
||||
return [`overlay ${String(columns)}x${String(rows)} rows=${String(last - first + 1)}`, ...overlay, ''].join('\n')
|
||||
}
|
||||
|
||||
/** Project the first synchronized PTY frame containing `marker` into an overlay-only snapshot. */
|
||||
async function firstRunFrameSnapshot(
|
||||
output: string,
|
||||
marker: string,
|
||||
columns: number,
|
||||
rows: number,
|
||||
): Promise<string> {
|
||||
const markerIndex = output.indexOf(marker)
|
||||
if (markerIndex < 0) throw new Error(`first-run PTY output has no marker ${JSON.stringify(marker)}`)
|
||||
const frameEnd = output.indexOf(synchronizedFrameEnd, markerIndex)
|
||||
if (frameEnd < 0) throw new Error(`first-run PTY output has no complete frame after ${JSON.stringify(marker)}`)
|
||||
const terminal = new HeadlessTerminal(columns, rows)
|
||||
try {
|
||||
terminal.write(output.slice(0, frameEnd + synchronizedFrameEnd.length))
|
||||
return overlaySnapshot(await terminal.snapshot(), columns, rows)
|
||||
} finally {
|
||||
await terminal.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
// The scripted conversation switches to the pro model first: the scripted
|
||||
// adapter proves routing + prompt variables by rejecting tool-ful calls on any
|
||||
// other route (see fixtures/tui-scripted-llm.ts).
|
||||
@@ -148,6 +224,138 @@ const SELECT_PRO_MODEL = [
|
||||
] as const
|
||||
|
||||
describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
it.each([
|
||||
{ columns: 60, tier: undefined },
|
||||
{ columns: 80, tier: 'minimal' },
|
||||
{ columns: 120, tier: 'full' },
|
||||
{ columns: 160, tier: 'full' },
|
||||
] as const)('renders and acknowledges the responsive first-run composition at $columns columns', async ({ columns, tier }) => {
|
||||
const output = await smoke({
|
||||
label: `dsh first-run welcome ${String(columns)} columns`,
|
||||
tempDirPrefix: `dsh-tui-welcome-${String(columns)}-`,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: 0,
|
||||
columns,
|
||||
rows: 30,
|
||||
actions: [
|
||||
{
|
||||
waitFor: `Enter ${firstRunCopy.continueLabel}`,
|
||||
send: '\r\x03',
|
||||
},
|
||||
],
|
||||
inspect: async (cwd) => {
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(join(cwd, '.dsh'))).toBe(true)
|
||||
const entries = await readdir(join(cwd, '.sessions'), { recursive: true })
|
||||
const logs = entries.filter(name => name.endsWith('.jsonl'))
|
||||
for (const log of logs) {
|
||||
const stored = await readFile(join(cwd, '.sessions', log), 'utf8')
|
||||
expect(stored).not.toContain(firstRunCopy.paragraphs[0])
|
||||
}
|
||||
},
|
||||
})
|
||||
await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, columns, 30))
|
||||
.toMatchFileSnapshot(join(firstRunSnapshots, `${String(columns)}-columns.expected.txt`))
|
||||
if (tier === undefined) {
|
||||
expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim())
|
||||
} else {
|
||||
expect(output).toContain(firstRunArtAnchor(tier))
|
||||
}
|
||||
expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('keeps prose and Enter reachable in a low-height real PTY after dropping the whale', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh low-height first-run welcome',
|
||||
tempDirPrefix: 'dsh-tui-welcome-low-',
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: 0,
|
||||
columns: 60,
|
||||
rows: 12,
|
||||
actions: [
|
||||
{ waitFor: firstRunOpeningSentence, send: '\x1b[F' },
|
||||
{
|
||||
waitFor: `Enter ${firstRunCopy.continueLabel}`,
|
||||
occurrence: 2,
|
||||
send: '\r\x03',
|
||||
},
|
||||
],
|
||||
})
|
||||
await expect(await firstRunFrameSnapshot(output, firstRunOpeningSentence, 60, 12))
|
||||
.toMatchFileSnapshot(join(firstRunSnapshots, '60-columns-low-height.expected.txt'))
|
||||
expect(output).toContain(firstRunCopy.title)
|
||||
expect(output).toContain(firstRunOpeningSentence)
|
||||
expect(output).toContain('企业微信群')
|
||||
expect(output).toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
expect(output).not.toContain(TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode[0]!.trim())
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('shows once and skips the second launch under the same DSH_HOME', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-twice-'))
|
||||
try {
|
||||
const first = await smoke({
|
||||
label: 'dsh first welcome launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: 0,
|
||||
actions: [
|
||||
{ waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
|
||||
],
|
||||
})
|
||||
expect(first).toContain(firstRunCopy.title)
|
||||
|
||||
const second = await smoke({
|
||||
label: 'dsh second welcome launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: process.platform === 'win32' ? 0 : -15,
|
||||
actions: [{ waitFor: 'main-session-', signal: 'SIGTERM' }],
|
||||
})
|
||||
expect(second).not.toContain(firstRunOpeningSentence)
|
||||
expect(second).not.toContain(`Enter ${firstRunCopy.continueLabel}`)
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it.skipIf(process.platform === 'win32')('keeps the notice eligible when the process exits before Enter', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-welcome-abort-'))
|
||||
try {
|
||||
await smoke({
|
||||
label: 'dsh aborted welcome launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: -15,
|
||||
actions: [{ waitFor: firstRunOpeningSentence, signal: 'SIGTERM' }],
|
||||
inspect: async (workspace) => {
|
||||
expect(await hasTuiFirstRunWelcomeAcknowledgement(join(workspace, '.dsh'))).toBe(false)
|
||||
},
|
||||
})
|
||||
|
||||
const next = await smoke({
|
||||
label: 'dsh welcome after aborted launch',
|
||||
tempDirPrefix: 'unused-',
|
||||
cwd,
|
||||
configPath: scriptedConfigPath,
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: 0,
|
||||
actions: [
|
||||
{ waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
|
||||
],
|
||||
})
|
||||
expect(next).toContain(firstRunOpeningSentence)
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => {
|
||||
// With no configured welcome the borderless banner sweeps in left-to-right;
|
||||
// the detail line's session id (`main-session-<uuid>`) renders only once
|
||||
@@ -170,7 +378,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).not.toContain('╭')
|
||||
expect(output).not.toContain('╮')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
|
||||
const output = await smoke({
|
||||
@@ -223,7 +431,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).toContain('Registered tools')
|
||||
expect(output).toContain('ask_user_question')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => {
|
||||
// The whole user-only invocation path in one keyless boot: `ctx.get('skills')`
|
||||
@@ -259,7 +467,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).not.toContain('[instructions]')
|
||||
expect(output).toContain('Scripted skill body received.')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('adds a watched local skill to live /skill: autocomplete without restarting', async () => {
|
||||
const skill = [
|
||||
@@ -289,7 +497,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
})
|
||||
expect(output).toContain('HOT_ADDED_COMPLETION_MARKER')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it.skipIf(process.env.DSH_EXAMPLE_MODE === 'lib')('fuzzy-completes an @file path without reading or submitting the file', async () => {
|
||||
const output = await smoke({
|
||||
@@ -314,11 +522,51 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
|
||||
expect(output).toContain('File · terminal-special-case.t')
|
||||
expect(output).toContain('@src/terminal-special-case.ts')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
})
|
||||
|
||||
describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
it('shows the terminal-local notice over a resumed session without changing its log', async () => {
|
||||
let originalLineCount = 0
|
||||
const output = await smoke({
|
||||
label: 'dsh first-run notice on resume',
|
||||
tempDirPrefix: 'dsh-tui-welcome-resume-',
|
||||
binScript: dshBinScript,
|
||||
configArgs: ['--resume', 'resume-target', '--config', scriptedConfigPath],
|
||||
showFirstRunWelcome: true,
|
||||
expectedExitCode: 0,
|
||||
prepare: async (cwd) => {
|
||||
await seedResumeSession(cwd)
|
||||
const before = await readFile(logPath(
|
||||
join(cwd, '.sessions'),
|
||||
realpathSync.native(cwd),
|
||||
SessionId('resume-target'),
|
||||
'none',
|
||||
), 'utf8')
|
||||
originalLineCount = before.split('\n').filter(Boolean).length
|
||||
},
|
||||
actions: [
|
||||
{ waitFor: `Enter ${firstRunCopy.continueLabel}`, send: '\r\x03' },
|
||||
],
|
||||
inspect: async (cwd) => {
|
||||
const after = await readFile(logPath(
|
||||
join(cwd, '.sessions'),
|
||||
realpathSync.native(cwd),
|
||||
SessionId('resume-target'),
|
||||
'none',
|
||||
), 'utf8')
|
||||
expect(after).not.toContain(firstRunCopy.paragraphs[0])
|
||||
const appended = after.split('\n').filter(Boolean).slice(originalLineCount)
|
||||
.map(line => JSON.parse(line) as SessionEvent)
|
||||
expect(appended).not.toContainEqual(expect.objectContaining({ type: 'user/message' }))
|
||||
expect(appended).not.toContainEqual(expect.objectContaining({ type: 'turn/start' }))
|
||||
},
|
||||
})
|
||||
expect(output).toContain(firstRunOpeningSentence)
|
||||
expect(output).toContain('Resume selector design — DeepSeek Harness')
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('exec-replaces the TUI for /resume and restores the same session state', async () => {
|
||||
const output = await smoke({
|
||||
label: 'dsh in-place resume',
|
||||
@@ -338,7 +586,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(released).toBeGreaterThanOrEqual(0)
|
||||
expect(restored).toBeGreaterThan(released)
|
||||
expect(output).toContain('Preserve restored state')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('boots the shipped default config with no arguments and no personal overlay', async () => {
|
||||
const output = await smoke({
|
||||
@@ -353,7 +601,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(output).not.toContain('╭')
|
||||
expect(output).not.toContain('╮')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('applies the personal overlay: config.yaml patches an overlay-inserted row, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => {
|
||||
// The whole personal-config chain in one boot, plus the environment layer
|
||||
@@ -390,7 +638,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(output).toContain('PROJECT OVERLAY READY.')
|
||||
expect(output).not.toContain('HOME ENV LEAKED.')
|
||||
expect(output).toContain('\u001B[?2004l')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
|
||||
const output = await smoke({
|
||||
@@ -402,7 +650,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expectedExitCode: 1,
|
||||
})
|
||||
expect(output).toContain('must be a top-level YAML array of loader patch entries')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => {
|
||||
// The flag path end to end: apps/cli parses `--resume missing-session`,
|
||||
@@ -417,7 +665,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expectedExitCode: 1,
|
||||
})
|
||||
expect(output).toContain('ui-tui: session "missing-session" failed to start:')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('prints the launcher-owned resume command on exit, naming the booted config', async () => {
|
||||
// The exit line is built by apps/cli from this invocation, so it must carry
|
||||
@@ -430,7 +678,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }],
|
||||
})
|
||||
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => {
|
||||
// Loader patches replace a targeted `config` key wholesale, so a personal
|
||||
@@ -465,7 +713,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }],
|
||||
})
|
||||
expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('reports a failing bash command exactly once, as the terminal card exit pill', async () => {
|
||||
// The model-facing result ends in `[exit code: 3]`, which the terminal card
|
||||
@@ -488,7 +736,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(output).toContain('SCRIPTED_BASH_FAILED')
|
||||
expect(output).toContain('[exit 3]')
|
||||
expect(output).not.toContain('[exit code: 3]')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('tells the model its source path and offers the bundled maintenance skills', async () => {
|
||||
// The launcher resolves the checkout root three hops up from apps/cli/{src,lib};
|
||||
@@ -514,5 +762,5 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
|
||||
expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.")
|
||||
expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.')
|
||||
expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
}, PTY_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -11,8 +11,12 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import * as CommandCompact from '@deepseek-ai/dsh-command-compact'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
@@ -45,6 +49,8 @@ type ScenarioInteraction = 'skill-invocation-policy'
|
||||
|
||||
interface Scenario {
|
||||
name: string
|
||||
/** Replay fixture owned by an earlier scenario, for a derived presentation case. */
|
||||
fixture?: string
|
||||
composition: Composition
|
||||
expectedTools: string[]
|
||||
expectedEventCounts?: Record<string, number>
|
||||
@@ -68,6 +74,13 @@ interface Scenario {
|
||||
spillMaxInlineBytes?: number
|
||||
/** Run scenario-specific terminal input instead of replaying recorded user prompts. */
|
||||
interaction?: ScenarioInteraction
|
||||
/**
|
||||
* Mount a deterministic compaction backend plus `/compact`, then run the
|
||||
* human command with a held summary while a prompt and injected context
|
||||
* arrive. Proves queued input waits for the standalone bracket's durability
|
||||
* checkpoint instead of racing the replacement.
|
||||
*/
|
||||
manualCompact?: boolean
|
||||
}
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
@@ -80,6 +93,14 @@ const SCENARIOS: Scenario[] = [
|
||||
leavePlanModeAfterFirstTurn: true,
|
||||
recorded: true,
|
||||
},
|
||||
{
|
||||
name: 'queued-manual-compact',
|
||||
fixture: 'multi-turn-conversation',
|
||||
composition: 'native',
|
||||
expectedTools: [],
|
||||
recorded: false,
|
||||
manualCompact: true,
|
||||
},
|
||||
{
|
||||
name: 'todo-plan',
|
||||
composition: 'native',
|
||||
@@ -149,6 +170,44 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotMode {
|
||||
|
||||
const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT)
|
||||
const observedScenarios = new Set<string>()
|
||||
const workerState = Reflect.get(globalThis, '__vitest_worker__') as
|
||||
| { readonly config?: { readonly testNamePattern?: RegExp } }
|
||||
| undefined
|
||||
// Worker argv omits the parent CLI's `-t`; the serialized runner config is the
|
||||
// authoritative distinction between a focused replay and the full suite.
|
||||
const TEST_NAME_FILTERED = workerState?.config?.testNamePattern !== undefined
|
||||
|
||||
/**
|
||||
* Deterministic keyless summary that pauses so the scenario can submit a real
|
||||
* prompt and inject context while manual compaction holds turn admission.
|
||||
*/
|
||||
class DeferredSnapshotCompactService extends BasicCompactService {
|
||||
readonly summaryStarted = Promise.withResolvers<undefined>()
|
||||
readonly releaseSummary = Promise.withResolvers<undefined>()
|
||||
|
||||
override async summarize(
|
||||
_input: SummarizationInput,
|
||||
_agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ summary: [{ type: 'text'; text: string }]; provider: string; model: string }> {
|
||||
this.summaryStarted.resolve(undefined)
|
||||
await this.releaseSummary.promise
|
||||
signal?.throwIfAborted()
|
||||
return {
|
||||
summary: [{ type: 'text', text: 'Keyless manual compaction checkpoint.' }],
|
||||
provider: 'snapshot',
|
||||
model: 'snapshot-compactor',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed between-turn model-visible history without inventing a loop execution. */
|
||||
function seedCompactableHistory(agent: Agent): void {
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'Older snapshot context. '.repeat(60) }],
|
||||
source: { kind: 'plugin', plugin: 'snapshot-seed' },
|
||||
}))
|
||||
}
|
||||
|
||||
function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string {
|
||||
const rel = relative(cwd, displayPath)
|
||||
@@ -161,10 +220,15 @@ function scenarioDir(scenario: Scenario): string {
|
||||
return join(SNAPSHOTS_DIR, scenario.name)
|
||||
}
|
||||
|
||||
/** Directory owning the replay fixture: the scenario's own, or the one it derives from. */
|
||||
function fixtureDir(scenario: Scenario): string {
|
||||
return join(SNAPSHOTS_DIR, scenario.fixture ?? scenario.name)
|
||||
}
|
||||
|
||||
function childFixturePaths(scenario: Scenario): string[] {
|
||||
return Array.from(
|
||||
{ length: scenario.childSessions ?? 0 },
|
||||
(_, index) => join(scenarioDir(scenario), `session.${index + 1}.jsonl`),
|
||||
(_, index) => join(fixtureDir(scenario), `session.${index + 1}.jsonl`),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -206,6 +270,24 @@ async function settleTerminal(terminal: HeadlessTerminal): Promise<void> {
|
||||
if (stable < 3) throw new Error('TUI frames did not quiesce within 200ms')
|
||||
}
|
||||
|
||||
/** Bound deterministic in-process coordination waits with actionable state. */
|
||||
async function snapshotDeadline<T>(
|
||||
operation: Promise<T>,
|
||||
detail: () => string,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => { reject(new Error(detail())) }, 5_000)
|
||||
}),
|
||||
])
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function mountScenarioContext(
|
||||
scenario: Scenario,
|
||||
cwd: string,
|
||||
@@ -232,6 +314,9 @@ async function mountScenarioContext(
|
||||
skills: { local: { agentsHome: join(cwd, '.agents') } },
|
||||
})
|
||||
await ctx.plugin(TokenMeterService)
|
||||
if (scenario.manualCompact === true) {
|
||||
await ctx.plugin(DeferredSnapshotCompactService, { auto: false })
|
||||
}
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
|
||||
await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' })
|
||||
@@ -249,6 +334,7 @@ async function mountScenarioContext(
|
||||
await ctx.plugin(ToolWorkflow)
|
||||
await ctx.plugin(ToolRalph)
|
||||
await ctx.plugin(CommandService)
|
||||
if (scenario.manualCompact === true) await ctx.plugin(CommandCompact)
|
||||
if (scenario.enterPlanMode === true) {
|
||||
await ctx.plugin(PlanModeService, { section: 'Snapshot plan mode instructions.' })
|
||||
}
|
||||
@@ -276,9 +362,9 @@ interface ScenarioResult {
|
||||
}
|
||||
|
||||
async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 0, 0).getTime())
|
||||
const dir = scenarioDir(scenario)
|
||||
const fixtureFile = join(dir, 'session.jsonl')
|
||||
const snapshotTime = new Date(2026, 6, 21, 12, 0, 0).getTime()
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(snapshotTime)
|
||||
const fixtureFile = join(fixtureDir(scenario), 'session.jsonl')
|
||||
const childFiles = childFixturePaths(scenario)
|
||||
const prompts = userPrompts(await readFile(fixtureFile, 'utf8'))
|
||||
if (scenario.interaction === undefined) {
|
||||
@@ -292,7 +378,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
const terminal = new HeadlessTerminal(100, 36)
|
||||
try {
|
||||
if (scenario.seedWorkspace === true) {
|
||||
const source = join(scenarioDir(scenario), 'workspace')
|
||||
const source = join(fixtureDir(scenario), 'workspace')
|
||||
await cp(source, cwd, { recursive: true })
|
||||
}
|
||||
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles)
|
||||
@@ -308,6 +394,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
const agent: Agent = handle.agent
|
||||
if (scenario.manualCompact === true) seedCompactableHistory(agent)
|
||||
controller = createTuiChat(ctx, {
|
||||
sessionId: 'main-session',
|
||||
theme: { color: true },
|
||||
@@ -380,6 +467,14 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
}
|
||||
|
||||
let remainingPrompts = prompts
|
||||
let queuedPrompt: string | undefined
|
||||
let manualOrder: string[] | undefined
|
||||
let manualCommandId: string | undefined
|
||||
if (scenario.manualCompact === true) {
|
||||
expect(prompts.length, 'queued manual compaction needs a second replayed prompt').toBeGreaterThanOrEqual(2)
|
||||
queuedPrompt = prompts.at(-1)
|
||||
remainingPrompts = prompts.slice(0, -1)
|
||||
}
|
||||
if (scenario.enterPlanMode === true) {
|
||||
const firstPrompt = prompts[0]!
|
||||
terminal.send(`/plan ${firstPrompt}`)
|
||||
@@ -396,12 +491,93 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
}
|
||||
|
||||
for (const prompt of remainingPrompts) {
|
||||
const admitted = agent.session.events.filter(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'user').length
|
||||
terminal.send(prompt)
|
||||
terminal.send('\r')
|
||||
await terminal.flush()
|
||||
await expect.poll(() => agent.session.events.filter(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'user').length).toBe(admitted + 1)
|
||||
await agent.whenIdle()
|
||||
await settleTerminal(terminal)
|
||||
}
|
||||
|
||||
if (scenario.manualCompact === true && queuedPrompt !== undefined) {
|
||||
terminal.send('/help')
|
||||
terminal.send('\r')
|
||||
await settleTerminal(terminal)
|
||||
expect(await terminal.snapshot({ includeScrollback: true }))
|
||||
.toContain('/compact — Compact older conversation history')
|
||||
|
||||
const compact = ctx.compact as DeferredSnapshotCompactService
|
||||
const inbox: string[] = []
|
||||
manualOrder = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent) inbox.push(`enqueue:${item.placement}:${item.id}`)
|
||||
})
|
||||
ctx.on('agent/inbox/dequeue', (subject, message) => {
|
||||
if (subject === agent) inbox.push(`dequeue:${message.id}`)
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (event.type === 'command/run' && event.data.name === 'compact') {
|
||||
manualCommandId = event.data.commandId
|
||||
manualOrder?.push('command/run')
|
||||
}
|
||||
if (event.type === 'command/done' && event.data.commandId === manualCommandId) {
|
||||
manualOrder?.push('command/done')
|
||||
}
|
||||
if (event.type.startsWith('compact/')) manualOrder?.push(event.type)
|
||||
if (event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.source.plugin === 'compact') manualOrder?.push('checkpoint')
|
||||
if (event.type === 'turn/start') manualOrder?.push(`turn/start:${event.data.trigger.kind}`)
|
||||
})
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session === agent.session) manualOrder?.push('flush')
|
||||
})
|
||||
|
||||
terminal.send('/compact')
|
||||
terminal.send('\r')
|
||||
await terminal.flush()
|
||||
await snapshotDeadline(compact.summaryStarted.promise, () =>
|
||||
`manual summary did not start; status=${agent.status}; tail=${
|
||||
agent.session.events.slice(-8).map(event => event.type).join(',')
|
||||
}`)
|
||||
clock.mockReturnValue(snapshotTime + 1_000)
|
||||
await settleTerminal(terminal)
|
||||
await expect.poll(() => terminal.snapshot()).toContain('dsh ⊙')
|
||||
await expect.poll(() => terminal.snapshot()).toContain('Context being compacted 1.0s')
|
||||
const liveCompaction = await terminal.snapshot()
|
||||
expect(liveCompaction.indexOf('Context being compacted 1.0s')).toBeLessThan(liveCompaction.indexOf('dsh ⊙'))
|
||||
clock.mockReturnValue(snapshotTime)
|
||||
|
||||
// Real keystrokes: the prompt keeps its ordinary queue identity while
|
||||
// admission is reserved, and an injection appends immediately.
|
||||
terminal.send(queuedPrompt)
|
||||
terminal.send('\r')
|
||||
await terminal.flush()
|
||||
await expect.poll(() => inbox.length).toBe(1)
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'Injected while compaction was running.' }],
|
||||
source: { kind: 'plugin', plugin: 'snapshot-injector' },
|
||||
}))
|
||||
expect(inbox[0]).toMatch(/^enqueue:queued:/u)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(agent.session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text === queuedPrompt))).toBe(false)
|
||||
|
||||
const idle = agent.whenIdle()
|
||||
compact.releaseSummary.resolve(undefined)
|
||||
await snapshotDeadline(idle, () =>
|
||||
`manual compaction did not reach idle; status=${agent.status}; order=${manualOrder?.join(',') ?? ''}; tail=${
|
||||
agent.session.events.slice(-12).map(event => event.type).join(',')
|
||||
}`)
|
||||
await settleTerminal(terminal)
|
||||
expect(inbox).toEqual([inbox[0], `dequeue:${inbox[0]?.slice('enqueue:queued:'.length) ?? ''}`])
|
||||
}
|
||||
|
||||
const events: SessionEvent[] = [...agent.session.events]
|
||||
const firstHeader = events.find(event => event.type === 'request/header')
|
||||
expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
|
||||
@@ -437,6 +613,87 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
|
||||
expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content))
|
||||
.toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }])
|
||||
}
|
||||
if (scenario.manualCompact === true) {
|
||||
const compactStart = events.find(event => event.type === 'compact/start')
|
||||
const compactSummary = events.find(event => event.type === 'compact/summary')
|
||||
const compactCheckpoint = events.find(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'compact')
|
||||
const injectedEvent = events.find(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'snapshot-injector')
|
||||
const compactEnd = events.find(event => event.type === 'compact/end')
|
||||
expect(compactStart?.data.turn).toBeNull()
|
||||
expect(compactEnd?.data.turn).toBeNull()
|
||||
expect(events.filter(event => event.type === 'compact/summary')).toHaveLength(1)
|
||||
if (compactStart === undefined || compactSummary === undefined
|
||||
|| compactCheckpoint === undefined || injectedEvent === undefined
|
||||
|| compactEnd === undefined) {
|
||||
throw new Error('manual compaction snapshot is missing its durable marker, summary, checkpoint, or injection')
|
||||
}
|
||||
// The markers are time points, not an exclusive container: unrelated
|
||||
// idle injection is allowed between them while the selected span stays stable.
|
||||
expect(compactStart.seq).toBeLessThan(injectedEvent.seq)
|
||||
expect(injectedEvent.seq).toBeLessThan(compactSummary.seq)
|
||||
expect(compactSummary.seq).toBeLessThan(compactCheckpoint.seq)
|
||||
expect(compactCheckpoint.seq).toBeLessThan(compactEnd.seq)
|
||||
|
||||
const manualTimeline = manualOrder ?? []
|
||||
const commandRunIndex = manualTimeline.indexOf('command/run')
|
||||
const compactStartIndex = manualTimeline.indexOf('compact/start')
|
||||
const compactEndIndex = manualTimeline.indexOf('compact/end')
|
||||
const firstFlushIndex = manualTimeline.indexOf('flush')
|
||||
const queuedTurnIndex = manualTimeline.indexOf('turn/start:message')
|
||||
const commandDoneIndex = manualTimeline.indexOf('command/done')
|
||||
expect(manualTimeline.filter(item => item === 'command/run')).toHaveLength(1)
|
||||
expect(manualTimeline.filter(item => item === 'command/done')).toHaveLength(1)
|
||||
expect(compactStartIndex).toBeGreaterThan(commandRunIndex)
|
||||
expect(compactEndIndex).toBeGreaterThan(compactStartIndex)
|
||||
expect(firstFlushIndex).toBeGreaterThan(compactEndIndex)
|
||||
expect(queuedTurnIndex).toBeGreaterThan(firstFlushIndex)
|
||||
expect(commandDoneIndex).toBeGreaterThan(firstFlushIndex)
|
||||
|
||||
const commandRun = events.find(event => event.type === 'command/run'
|
||||
&& event.data.name === 'compact')
|
||||
const commandRunId = commandRun?.type === 'command/run'
|
||||
? commandRun.data.commandId
|
||||
: undefined
|
||||
const commandDone = events.find(event => event.type === 'command/done'
|
||||
&& event.data.commandId === commandRunId)
|
||||
expect(commandRun?.type === 'command/run' && commandRun.data).toEqual({
|
||||
commandId: commandRunId,
|
||||
name: 'compact',
|
||||
args: '',
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
expect(commandDone?.type === 'command/done' && commandDone.data).toEqual({
|
||||
commandId: commandRunId,
|
||||
kind: 'success',
|
||||
text: 'Compacted 2 history items (~387 tokens).',
|
||||
})
|
||||
expect(commandRun !== undefined && commandRun.seq < compactStart.seq).toBe(true)
|
||||
expect(commandDone !== undefined && commandDone.seq > compactEnd.seq).toBe(true)
|
||||
expect(agent.session.surface.nodes).not.toContain(commandRun?.seq)
|
||||
expect(agent.session.surface.nodes).not.toContain(commandDone?.seq)
|
||||
|
||||
// The command line itself never becomes a prompt.
|
||||
expect(events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text.trim() === '/compact'))).toBe(false)
|
||||
const derived = agent.session.deriveMessages().map(message => message.content
|
||||
.map(block => block.type === 'text' ? block.text : '')
|
||||
.join(''))
|
||||
const checkpoint = derived.findIndex(text => text.includes('Keyless manual compaction checkpoint.'))
|
||||
const injected = derived.findIndex(text => text.includes('Injected while compaction was running.'))
|
||||
const queued = derived.findIndex(text => text === queuedPrompt)
|
||||
expect(checkpoint).toBe(0)
|
||||
expect(injected).toBeGreaterThan(checkpoint)
|
||||
expect(queued).toBeGreaterThan(injected)
|
||||
expect(derived).not.toContain('/compact')
|
||||
expect(derived).not.toContain('Compacted 2 history items (~387 tokens).')
|
||||
expect(derived.filter(text => text.includes('Injected while compaction was running.'))).toHaveLength(1)
|
||||
expect(compactSummary.data.shadowedSeqs).not.toContain(injectedEvent.seq)
|
||||
const queuedTurn = events.findLast(event => event.type === 'turn/start')
|
||||
expect(queuedTurn !== undefined && compactEnd.seq < queuedTurn.seq).toBe(true)
|
||||
}
|
||||
if (scenario.spillMaxInlineBytes !== undefined) {
|
||||
// The REAL pipeline ran (tools execute on replay too): the durable
|
||||
// dispatch copy is bounded to a preview + locator under the run cwd,
|
||||
@@ -514,7 +771,23 @@ describe('TUI recorded-session terminal snapshots', () => {
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
expect([...observedScenarios].sort()).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
|
||||
const scenarioNames = SCENARIOS.map(scenario => scenario.name).sort()
|
||||
const observedNames = [...observedScenarios].sort()
|
||||
if (TEST_NAME_FILTERED) {
|
||||
expect(observedNames).not.toHaveLength(0)
|
||||
expect(scenarioNames).toEqual(expect.arrayContaining(observedNames))
|
||||
} else {
|
||||
expect(observedNames).toEqual(scenarioNames)
|
||||
}
|
||||
for (const [index, scenario] of SCENARIOS.entries()) {
|
||||
if (scenario.fixture === undefined) continue
|
||||
const sourceIndex = SCENARIOS.findIndex(candidate => candidate.name === scenario.fixture)
|
||||
expect(sourceIndex, `${scenario.name} fixture source ${scenario.fixture} must exist`).toBeGreaterThanOrEqual(0)
|
||||
expect(sourceIndex, `${scenario.name} fixture source must precede it`).toBeLessThan(index)
|
||||
const source = SCENARIOS[sourceIndex]
|
||||
expect(source?.fixture, `${scenario.name} fixture source must own its replay files`).toBeUndefined()
|
||||
expect(source?.recorded, `${scenario.name} fixture source must be recordable`).toBe(true)
|
||||
}
|
||||
const directories = (await readdir(SNAPSHOTS_DIR, { withFileTypes: true }))
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name)
|
||||
@@ -522,14 +795,14 @@ afterAll(async () => {
|
||||
expect(directories).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
|
||||
for (const scenario of SCENARIOS) {
|
||||
const expected = [
|
||||
'session.jsonl',
|
||||
...scenario.fixture === undefined ? ['session.jsonl'] : [],
|
||||
'terminal.expected.txt',
|
||||
...scenario.seedWorkspace === true ? ['workspace'] : [],
|
||||
...scenario.seedWorkspace === true && scenario.fixture === undefined ? ['workspace'] : [],
|
||||
...Array.from({ length: scenario.childSessions ?? 0 }, (_, index) => `session.${index + 1}.jsonl`),
|
||||
].sort()
|
||||
expect((await readdir(scenarioDir(scenario))).sort()).toEqual(expected)
|
||||
for (const fixture of ['session.jsonl', ...childFixturePaths(scenario).map(path => basename(path))]) {
|
||||
const content = await readFile(join(scenarioDir(scenario), fixture), 'utf8')
|
||||
const content = await readFile(join(fixtureDir(scenario), fixture), 'utf8')
|
||||
expect(scrubRequestHeaders(content), `${scenario.name}/${fixture} carries request-header bulk`).toBe(content)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user