Merge remote-tracking branch 'origin/master' into worktree/fix-multi-select-custom-answer

# Conflicts:
#	apps/web/tests/snapshots/question-composer/answered.expected.md
#	apps/web/tests/snapshots/question-composer/session.jsonl
#	docs/core-data-structures/user-interaction.i18n.yaml
#	packages/client/ui-question/README.i18n.yaml
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/user-interaction/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-08-03 16:09:17 +08:00
2065 changed files with 168375 additions and 14240 deletions

View File

@@ -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: 2bc36cce6205a4bfc3ba1d7ee15f0e0b2feab215
README.zh.md: 0e0771658cecb4bee0f3eadd0639ad531e64ea3c
README.md: 76d9ed65398322cb9244a31661ee59b60c23f793
README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b

View File

@@ -3,7 +3,7 @@
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 `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is a guided-session entry, and whose `web` subcommand is the browser UI. `meta` and `upgrade` are experimental: each runs only with its `--experimental` flag or with `DSH_EXPERIMENTAL=1` in the environment, and fails loud (stderr, exit 1) otherwise. `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`/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:
@@ -11,18 +11,64 @@ The TUI surface:
- 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);
- 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)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
- 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`. The shipped tree's Cordis HMR keeps `config.yaml` live; an explicit `--config` tree replaces that overlay, and a tree without HMR reads it at startup only.
- 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 both `.env` layers are loaded, so environment 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 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 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 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 beyond the experimental gate `--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 tell the coding agent its resolved model and session working directory, 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. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and ordinary package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails before listening because it cannot inject `window.__DSH_BOOT__`. The index 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 shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one.
All three surfaces consume `$DSH_HOME/config.yaml`; the TUI and Web apply valid edits live, while one-shot headless runs read it at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command:
```yaml
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
config:
repositories:
- 'github:PolyArch/humanize#<commit>'
```
The repository must contain a prepared `.dsh-plugin` package; the [repository Plugin contract](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration) documents authoring, nested Plugin paths, the immutable cache, trust boundary, and failure semantics. A failed live edit keeps the last good tree and emits Cordis's `hmr/config-update-failed` event.
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)

View File

@@ -3,7 +3,7 @@
[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``meta` 子命令是以本 checkout 为 workspace 的同一个 TUI`upgrade` 子命令是引导会话入口,`web` 子命令则是浏览器 UI。`meta``upgrade` 是实验性命令:只有带上各自的 `--experimental` 标志或在环境中设置 `DSH_EXPERIMENTAL=1` 才会运行否则明确报错stderr退出码 1`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错stderr退出码 1而不会被错路由。凡与默认界面不共享任何选项的子命令`upgrade``web``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 界面:
@@ -11,18 +11,64 @@ TUI 界面:
- 使用 `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` 是唯一例外,见下文);
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env``config.yaml` 则修补已启动的树
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。已交付配置树中的 Cordis HMR 会持续应用 `config.yaml` 的变更;显式 `--config` 配置树会替代该个人覆盖,未包含 HMR 的配置树只在启动时读取该文件
-`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`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`
`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。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 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>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle因此刷新现有 URL 即可更新该 GUI无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR热模块替换接收端但要实现无刷新更新还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundleshell 和普通包package的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会在开始监听前失败,因为它无法注入 `window.__DSH_BOOT__`。索引服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。
三个界面都会使用 `$DSH_HOME/config.yaml`TUI 和 Web 实时应用有效编辑,而一次性无头运行只在启动时读取。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只需配置即可添加已准备的 GitHub 插件:
```yaml
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
config:
repositories:
- 'github:PolyArch/humanize#<commit>'
```
仓库必须包含已准备的 `.dsh-plugin` 包;[仓库插件契约](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)说明创作方式、嵌套插件路径、不可变缓存、信任边界和失败语义。实时编辑失败时,最后一个可用树保持运行,并发出 Cordis 的 HMR热模块替换事件 `hmr/config-update-failed`
已交付的 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 在启动时明确报错。它是一个临时 seamLoader 组合是静态的,因此该设置作用于整个进程;待 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)。
## 安装(开发机)

View 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

View File

@@ -12,6 +12,8 @@ flowchart LR
cfg --> plugin_tui_timer
plugin_tui_hmr["hmr<br/>@cordisjs/plugin-hmr"]
cfg --> plugin_tui_hmr
plugin_tui_repository_plugins["repository-plugins<br/>@deepseek-ai/dsh-repository-plugin"]
cfg --> plugin_tui_repository_plugins
plugin_tui_llm["llm<br/>@deepseek-ai/dsh-llm"]
cfg --> plugin_tui_llm
plugin_tui_session["session<br/>@deepseek-ai/dsh-session"]
@@ -28,16 +30,30 @@ flowchart LR
cfg --> plugin_tui_tasks
plugin_tui_llm_retry["llm-retry<br/>@deepseek-ai/dsh-llm-retry"]
cfg --> plugin_tui_llm_retry
plugin_tui_settings["settings<br/>@deepseek-ai/dsh-settings-local"]
cfg --> plugin_tui_settings
plugin_tui_credentials["credentials<br/>@deepseek-ai/dsh-credentials-local"]
cfg --> plugin_tui_credentials
plugin_tui_llm_pi_ai["llm-pi-ai<br/>@deepseek-ai/dsh-llm-pi-ai"]
cfg --> plugin_tui_llm_pi_ai
plugin_tui_session_persistence_jsonl["session-persistence-jsonl<br/>@deepseek-ai/dsh-session-persistence-jsonl"]
cfg --> plugin_tui_session_persistence_jsonl
plugin_tui_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
cfg --> plugin_tui_session_query_sqlite
plugin_tui_telemetry_otel["telemetry-otel<br/>@deepseek-ai/dsh-session-telemetry-otel"]
cfg --> plugin_tui_telemetry_otel
plugin_tui_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
cfg --> plugin_tui_subprocess
plugin_tui_bash_local["bash-local<br/>@deepseek-ai/dsh-bash-local"]
cfg --> plugin_tui_bash_local
plugin_tui_sandbox["sandbox<br/>@deepseek-ai/dsh-sandbox-local"]
cfg --> plugin_tui_sandbox
plugin_tui_sandbox_policy["sandbox-policy<br/>@deepseek-ai/dsh-sandbox-policy"]
cfg --> plugin_tui_sandbox_policy
plugin_tui_bash_sandbox["bash-sandbox<br/>@deepseek-ai/dsh-bash-sandbox"]
cfg --> plugin_tui_bash_sandbox
plugin_tui_approval["approval<br/>@deepseek-ai/dsh-user-approval"]
cfg --> plugin_tui_approval
plugin_tui_permission["permission<br/>@deepseek-ai/dsh-permission"]
cfg --> plugin_tui_permission
plugin_tui_tool_bash["tool-bash<br/>@deepseek-ai/dsh-tool-bash"]
cfg --> plugin_tui_tool_bash
plugin_tui_tool_tasks["tool-tasks<br/>@deepseek-ai/dsh-tool-tasks"]
@@ -70,16 +86,24 @@ 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"]
cfg --> plugin_tui_subagent_spawn
plugin_tui_subagent_fork["subagent-fork<br/>@deepseek-ai/dsh-subagent-fork"]
cfg --> plugin_tui_subagent_fork
plugin_tui_tool_subagent_control["tool-subagent-control<br/>@deepseek-ai/dsh-tool-subagent-control"]
cfg --> plugin_tui_tool_subagent_control
plugin_tui_tool_subagent_list_agents["tool-subagent-list-agents<br/>@deepseek-ai/dsh-tool-subagent-control/list-agents"]
cfg --> plugin_tui_tool_subagent_list_agents
plugin_tui_tool_subagent["tool-subagent<br/>@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_tui_tool_subagent
plugin_tui_tool_subagent_fork["tool-subagent-fork<br/>@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_tui_tool_subagent_fork
plugin_tui_tool_subagent_report["tool-subagent-report<br/>@deepseek-ai/dsh-tool-subagent-report"]
cfg --> plugin_tui_tool_subagent_report
plugin_tui_workflow_workerthread["workflow-workerthread<br/>@deepseek-ai/dsh-workflow-workerthread"]
cfg --> plugin_tui_workflow_workerthread
plugin_tui_tool_workflow["tool-workflow<br/>@deepseek-ai/dsh-tool-workflow"]
@@ -90,14 +114,34 @@ 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_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"]
cfg --> plugin_tui_system_prompt
plugin_tui_agent_loop["agent-loop<br/>@deepseek-ai/dsh-agent-loop"]
cfg --> plugin_tui_agent_loop
plugin_tui_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
cfg --> plugin_tui_fs_local
plugin_tui_fs_sandbox["fs-sandbox<br/>@deepseek-ai/dsh-fs-sandbox"]
cfg --> plugin_tui_fs_sandbox
plugin_tui_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_tui_llm_deepseek
```
@@ -106,6 +150,7 @@ flowchart LR
| --- | --- |
| `timer` | `@cordisjs/plugin-timer` |
| `hmr` | `@cordisjs/plugin-hmr` |
| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
| `llm` | `@deepseek-ai/dsh-llm` |
| `session` | `@deepseek-ai/dsh-session` |
| `session-title` | `@deepseek-ai/dsh-session-title` |
@@ -114,11 +159,18 @@ flowchart LR
| `agent` | `@deepseek-ai/dsh-agent` |
| `tasks` | `@deepseek-ai/dsh-tasks-local` |
| `llm-retry` | `@deepseek-ai/dsh-llm-retry` |
| `settings` | `@deepseek-ai/dsh-settings-local` |
| `credentials` | `@deepseek-ai/dsh-credentials-local` |
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
| `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
| `bash-local` | `@deepseek-ai/dsh-bash-local` |
| `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
| `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` |
| `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` |
| `approval` | `@deepseek-ai/dsh-user-approval` |
| `permission` | `@deepseek-ai/dsh-permission` |
| `tool-bash` | `@deepseek-ai/dsh-tool-bash` |
| `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` |
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
@@ -135,20 +187,34 @@ 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` |
| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` |
| `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` |
| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` |
| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
| `tool-subagent-report` | `@deepseek-ai/dsh-tool-subagent-report` |
| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` |
| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` |
| `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-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` |
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
| `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` |
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
Source config: [`apps/cli/config/base.cordis.yml`](config/base.cordis.yml).

View File

@@ -22,6 +22,13 @@
config:
root: ['.']
# `$DSH_HOME/config.yaml` replaces this row's config to select exact GitHub
# repository Plugin generations. The app registers the DSH-owned runtime even
# when the list is empty so a later personal-config edit can load
# transactionally; one-shot headless runs consume the startup value only.
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
- id: llm
name: '@deepseek-ai/dsh-llm'
@@ -56,38 +63,117 @@
- id: llm-retry
name: '@deepseek-ai/dsh-llm-retry'
# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a
# `llm-deepseek:` or `llm-pi-ai:` section there overrides the adapter entries
# below without a restart, and is what the web Models page writes.
- id: settings
name: '@deepseek-ai/dsh-settings-local'
# Credential store: the live process environment over `$DSH_HOME/.env`
# (owner-only file, hot-reloaded). Adapters resolve their key references
# through it at each request, so no key is inlined in this file. The web
# Models page's key inputs write it through `credentials.set`; nothing hoists
# the document into the process environment, which would make every stored key
# read as an unrotatable ambient override.
- id: credentials
name: '@deepseek-ai/dsh-credentials-local'
# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra
# models in the picker) until a `llm-pi-ai:` settings section supplies provider
# profiles — then those routes register live, keys resolving per request
# through their apiKeyEnv references, and drop again when the section empties.
# Supplying those profiles is exactly what the web Models page does. Which
# adapters exist is composition; which providers run is the user's settings
# document.
- id: llm-pi-ai
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
- provider: openai
apiKey: !!js process.env.OPENAI_API_KEY
baseURL: !!js process.env.OPENAI_BASE_URL
- provider: anthropic
apiKey: !!js process.env.ANTHROPIC_API_KEY
baseURL: !!js process.env.ANTHROPIC_BASE_URL
- 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:
path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db'
# Session telemetry, on for every dsh surface: mirrors every session-log
# event (assistant/chunk projected to first-of-step) plus ops markers onto
# OTLP/HTTP log records, streaming on the batch processor's cadence
# (10s/batch here) — not at exit; a crash loses at most the last unexported
# interval. No telemetry/record redaction rule is mounted yet, so exports
# are the raw captured copy; the deployment stance, env seams, and
# follow-ups are pinned in the web-telemetry-default-mount Agent Note.
# 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). 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
# (both explicit) makes the drain a single batch, and exportTimeoutMillis
# is the processor's own cap on that one export cycle — the second bound
# when the exporter's clock alone does not fire. Every surface's exit path
# drains it: web/headless dispose on SIGINT/SIGTERM, and the TUI's normal
# exit and /resume handoff both dispose the root.
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
exporter:
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
compression: gzip
timeoutMillis: 1000
processor:
scheduledDelayMillis: 10000
maxQueueSize: 2048
maxExportBatchSize: 2048
exportTimeoutMillis: 1500
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: bash-local
name: '@deepseek-ai/dsh-bash-local'
# Every shipped product surface starts with the same file-effect boundary.
# The environment remains an explicit deployment override; otherwise fresh
# sessions pin workspace-write + ask through the permission service below.
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
workspaceRoot: !!js process.cwd()
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
config:
timeoutMs: 60000
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'"
- id: permission
name: '@deepseek-ai/dsh-permission'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
@@ -102,6 +188,8 @@
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
config:
sampleOverCapGlobResults: false
- id: workspace-context
name: '@deepseek-ai/dsh-workspace-context'
@@ -151,6 +239,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'
@@ -164,17 +257,31 @@
config:
providerName: fork
# Continuable background children are selected per delegation tool. The
# separately loaded follow-up tool registers the one global `send_message`.
- id: tool-subagent-control
name: '@deepseek-ai/dsh-tool-subagent-control'
- id: tool-subagent-list-agents
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent
backgroundMode: continuable
- id: tool-subagent-fork
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
backgroundMode: continuable
# Optional direct-child return channel; absent from roots and one-shot agents.
- id: tool-subagent-report
name: '@deepseek-ai/dsh-tool-subagent-report'
- id: workflow-workerthread
name: '@deepseek-ai/dsh-workflow-workerthread'
@@ -195,6 +302,71 @@
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-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. Search is a full
# auxiliary model request with server-side retrieval, so this shipped DeepSeek
# route gets 60s while the provider-neutral tool default remains 30s.
- 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
searchTimeoutMs: 60000
# ── rows every surface mounts, whose values each overlay states ──────────────
# The tool registry. Presentation mode is a surface choice, so each overlay
@@ -216,15 +388,14 @@
config:
agents: []
# The filesystem provider. `cwd` defaults to the package's `process.cwd()`; the
# TUI states it explicitly because that value is also the session workspace.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
# The sandboxed filesystem provider. `cwd` defaults to `process.cwd()`; the TUI
# states it explicitly because that value is also the session workspace.
- id: fs-sandbox
name: '@deepseek-ai/dsh-fs-sandbox'
# The native DeepSeek adapter; reads the key/base-url the boot's layered .env
# loading left in the environment. Thinking defaults are a surface choice.
# The native DeepSeek adapter. No key or endpoint is inlined: both resolve per
# request from the `llm-deepseek:` settings section over this entry, with the
# key coming from the credential store below. Thinking defaults are a surface
# choice.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL

View File

@@ -0,0 +1,89 @@
# 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-control
disabled: true
- id: tool-subagent-list-agents
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-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

View File

@@ -22,7 +22,7 @@
config:
agents:
- id: main
provider: deepseek
provider: deepseek-official
model: deepseek-v4-pro
cwd: !!js process.cwd()
@@ -36,8 +36,8 @@
Verify your work by running the code or tests. Keep answers brief and
factual.
# Shipped default: full thinking at max effort on every request (wire-only
# defaults; they never enter the request header).
# Shipped default: full thinking at max effort on every request. Exact-model
# resolution materializes request defaults before the request header is logged.
- id: llm-deepseek
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
@@ -46,7 +46,7 @@
reasoningEffort: max
# This single-session app resolves relative paths from the process cwd.
- id: fs-local
- id: fs-sandbox
config:
cwd: !!js process.cwd()
@@ -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.

View File

@@ -10,13 +10,23 @@
# ── surface-specific values the base deliberately omits ─────────────────────
- id: system-prompt
config:
persona: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
# TODO: Re-enable shared HMR for Web after its reload lifecycle is tested.
- 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:
@@ -31,50 +41,6 @@
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
# The web surface replaces the unrestricted local executors with the shared
# sandbox policy. Its default preserves the previous unrestricted behavior;
# DSH_PERMISSION_MODE and the browser permission picker can confine a session.
- insert:
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
workspaceRoot: !!js process.cwd()
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'"
- id: permission
name: '@deepseek-ai/dsh-permission'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
- id: fs-sandbox
name: '@deepseek-ai/dsh-fs-sandbox'
- id: bash-local
disabled: true
- id: fs-local
disabled: true
# ── web-only host rows, the transport layer, and the browser roster ─────────
# `dshClient` rows are the browser roster the modules node half scans into
@@ -92,7 +58,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,19 +74,19 @@
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.
- id: directory-picker
name: '@deepseek-ai/dsh-host-directory-picker-auto'
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. provider/model are the host default routing — the profile json's
# mapping target (user config overrides these engineering defaults).
- id: directory-picker
name: '@deepseek-ai/dsh-host-directory-picker-browse'
- id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy'
config:
provider: deepseek
provider: deepseek-official
model: deepseek-v4-flash
# ── layer 2: transport/service ──────────────────────────────────────────────

View File

@@ -9,6 +9,7 @@
},
"files": [
"lib/bin.js",
"assets",
"config",
"src"
],
@@ -47,10 +48,12 @@
"@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:^",
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
@@ -58,29 +61,38 @@
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@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-repository-plugin": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@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:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-settings-local": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
@@ -95,31 +107,43 @@
"@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-skill": "workspace:^",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-report": "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"
},
"devDependencies": {
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
"@types/js-yaml": "^4.0.9",
"execa": "^10.0.0",
"node-pty": "1.1.0"
}
}

View File

@@ -1,10 +1,12 @@
/**
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
* for the Web/headless surface.
* Everything here is what must exist before the Loader runs: layered env,
* 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 settles.
* (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly).
* 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 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.
*/
import { readFileSync } from 'node:fs'
@@ -14,8 +16,13 @@ import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { boot, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import {
boot,
installFailLoud,
loadOverlayPatches,
loadPersonalPatches,
watchPersonalPatches,
} from '@deepseek-ai/dsh-app-boot'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -23,6 +30,9 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */
const TELEMETRY_ROW_ID = 'telemetry-otel'
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
const ALL_INTERFACES_HOST = '0.0.0.0'
@@ -58,6 +68,38 @@ export function resolveLanTrust(
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
/**
* Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
* value (including `'0'`/`'false'`) disables: a privacy switch prefers
* off-by-mistake over on-by-mistake. Throws when the switch is set but the
* row is absent — a silently no-op "disabled" privacy switch would keep
* exporting while the user believes it is off.
* @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
* @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row.
* @returns the disable patch, or `undefined` when telemetry stays enabled.
*/
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
if ((disabledEnv ?? '') === '') return undefined
if (!hasRow) {
throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`)
}
return { id: TELEMETRY_ROW_ID, disabled: true }
}
/**
* Whether a config file carries the telemetry row, parsed under the same
* `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers
* that compose their patch lists outside {@link AppCLIEntry} (the TUI).
* @param file - absolute path of the config or overlay file.
* @returns true when a top-level (or inserted) row has the telemetry id.
*/
export function configHasTelemetryRow(file: string): boolean {
const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row =>
row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID))
}
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
@@ -104,8 +146,10 @@ export interface AppCLIEntryOptions {
* `$DSH_HOME/config.yaml` overlay is applied instead.
*/
extraOverlayPath?: string
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
/** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */
dev: boolean
/** Whether `$DSH_HOME/config.yaml` remains live after the initial boot. */
watchPersonalConfig: boolean
/** --host when explicitly passed; undefined keeps the yml engineering default. */
host?: string
/**
@@ -119,6 +163,8 @@ export interface AppCLIEntryOptions {
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
trustedHosts?: string[]
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
prepare?: (ctx: Context) => Promise<void> | void
}
/**
@@ -144,12 +190,11 @@ export class AppCLIEntry {
constructor(private readonly options: AppCLIEntryOptions) {}
/**
* Run the boot chain: layered env → patch composition → Loader include
* boot (dev row before await) → fail-loud triple.
* Run the boot chain: patch composition → Loader installation → surface
* preparation → config-tree boot (dev row before await) → fail-loud triple.
* @returns the settled root context and the listening port.
*/
async run(): Promise<{ ctx: Context; port: number }> {
this.loadEnvLayers()
this.composePatches()
await this.bootTree()
this.assertBoot()
@@ -159,11 +204,6 @@ export class AppCLIEntry {
return { ctx: this.ctx, port }
}
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
private loadEnvLayers(): void {
loadEnv('dsh', resolveDshHome())
}
/**
* Compose the patch set from profile json, CLI flags, and the resolved
* frontend dist. Patches replace a row's config wholesale, so each patched row's yml
@@ -203,29 +243,50 @@ export class AppCLIEntry {
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
this.patches = [...overrides.entries()].map(([id, bag]) => {
const generated = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
})
this.patches = generated
// Telemetry opt-out: a row can only be turned off at the patch layer
// (config cannot disable an entry), and the switch must hold BEFORE the
// plugin constructs — its exporter.url validation is load-time fail-loud.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
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; surface preparation precedes the tree, and the dev HMR row precedes the activation audit. */
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
// silently stop reaching base rows. The surface overlay applies first, then
// this entry's profile-json and CLI-flag patches, which therefore win.
const patches = [
const compose = (overlay: PatchOptions[]): PatchOptions[] => [
...loadOverlayPatches('dsh', this.options.overlayPath),
...this.options.extraOverlayPath === undefined
? loadPersonalPatches('dsh') ?? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
...overlay,
...this.patches,
]
// An explicit --config overlay REPLACES the personal overlay, so there is
// then no personal layer to keep live — the watcher is personal-only.
const watchPersonal = this.options.watchPersonalConfig && this.options.extraOverlayPath === undefined
const patches = compose(
this.options.extraOverlayPath === undefined
? loadPersonalPatches('dsh') ?? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
)
this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => {
await this.options.prepare?.(ctx)
// Config-only HMR for the personal overlay: module reload stays off for
// this surface (web.cordis.yml disables the shared `hmr` row until its
// reload lifecycle is tested), so this row watches no module roots.
if (watchPersonal) await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
})
if (watchPersonal) {
await watchPersonalPatches(this.ctx, { binName: 'dsh', compose })
}
}
/** Install the diagnostic for plugin rejections that happen after settled boot. */
@@ -288,7 +349,7 @@ export class AppCLIEntry {
try {
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
throw new Error('dsh: frontend dist not built; run pnpm run build from the repository root first')
}
}
}

View File

@@ -2,8 +2,11 @@
* 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;
* `meta`, `upgrade`, and `web` are real subcommands; the experimental ones
* (`meta`, `upgrade`) run only under the `--experimental` flag or
* `DSH_EXPERIMENTAL=1`. 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,22 +26,39 @@ 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'
prompt: string
}
/** Interactive fresh TUI over this harness checkout; accepts no default-surface options. */
/** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */
interface MetaInvocation {
mode: 'meta'
}
/**
* Guided fresh-session entry: `dsh 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.
* Guided fresh-session entry: `dsh upgrade` seeds the first turn
* with the `dsh-upgrade` skill. It always mints a
* fresh session in the invoking directory and takes no options beyond the
* experimental gate — `--resume`, `--config`, and `-p` are rejected as
* mistyped, so there is nothing to carry.
*/
interface SkillSessionInvocation {
mode: 'upgrade'
@@ -69,6 +89,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 +103,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 },
}
}
/**
@@ -108,9 +157,11 @@ function resolveWeb(options: WebOptions): WebInvocation {
* TUI/headless surface; `web` is a subcommand.
* @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
* @param version - the version string `--version` prints; read from this app's package.json.
* @param experimentalEnv - whether the environment opts into experimental
* subcommands (`DSH_EXPERIMENTAL=1`); the caller reads the process boundary.
* @returns the resolved invocation (only reached on a valid, non-help invocation).
*/
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
export function parseDshArgs(argv: readonly string[], version: string, experimentalEnv: boolean): DshInvocation {
let resolved: DshInvocation | undefined
const program = new Command()
.name('dsh')
@@ -135,7 +186,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 +238,42 @@ 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`)
}
}
// `meta` and `upgrade` are experimental: each runs only under its own
// `--experimental` flag or an environment-wide `DSH_EXPERIMENTAL=1` opt-in,
// and fails loud otherwise so the gate is never silently skipped.
const requireExperimental = (command: string, flag: boolean | undefined): void => {
if (flag !== true && !experimentalEnv) {
program.error(`error: ${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`)
}
}
// 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`, `meta`)
// come last. `upgrade` is a guided fresh-session entry: beyond the
// experimental gate it takes no options and always mints a fresh session,
// so nothing is left to carry.
program
.command('upgrade')
.description('update this dsh installation to the latest version')
.action(() => {
.description('update this dsh installation to the latest version (experimental)')
.option('--experimental', 'acknowledge this subcommand is experimental')
.action((options: { experimental?: boolean }) => {
rejectParentOptions('upgrade')
requireExperimental('upgrade', options.experimental)
resolved = { mode: 'upgrade' }
})
@@ -195,19 +285,28 @@ Examples:
.option('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--dev', 'developer mode: hot-reload the browser client')
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
.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')
.description('work on the dsh source that runs this command, from any directory')
.action(() => {
.description('work on the dsh source that runs this command, from any directory (experimental)')
.option('--experimental', 'acknowledge this subcommand is experimental')
.action((options: { experimental?: boolean }) => {
rejectParentOptions('meta')
requireExperimental('meta', options.experimental)
resolved = { mode: 'meta' }
})

View File

@@ -25,7 +25,8 @@ function readVersion(): string {
}
loadEnv('dsh')
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
// The env opt-in is read at the process boundary; `1` is the documented value.
const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1')
switch (invocation.mode) {
case 'web': {
@@ -43,14 +44,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:

View 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 */

View File

@@ -78,10 +78,22 @@ export async function runHeadless(task: string): Promise<void> {
configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)),
overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
dev: false,
watchPersonalConfig: false,
port: 0,
})
const { ctx, port } = await entry.run()
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
// Signal exits must still dispose the tree: the composition mounts
// exit-drained plugins (telemetry's queued tail and shutdown marker would
// otherwise be lost), and Node's default signal exit skips disposal.
let signalled = false
const disposeAndExit = (code: number): void => {
if (signalled) return
signalled = true
void dispose().finally(() => { process.exit(code) })
}
process.on('SIGTERM', () => { disposeAndExit(143) })
process.on('SIGINT', () => { disposeAndExit(130) })
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))

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

View 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

View 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,
},
})
}

View File

@@ -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 meta` is the one exception — it makes this harness
* checkout the workspace. `dsh 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
*/
@@ -26,13 +25,15 @@ import {
addHarnessSourceSection,
boot,
installFailLoud,
loadEnv,
loadOverlayPatches,
loadPersonalPatches,
resolveConfigPath,
watchPersonalPatches,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { PatchOptions } from '@cordisjs/plugin-include'
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'
import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
import type { Context } from 'cordis'
@@ -43,6 +44,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'
@@ -62,30 +73,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 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
@@ -100,8 +92,8 @@ export async function runSkillSession(skill: string): Promise<void> {
* @param workspace - a directory to make the workspace instead of the invoking
* one, or `undefined` to keep the cwd. Only `dsh 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 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`
@@ -123,16 +115,21 @@ export async function runTui(
)
process.exit(1)
}
installFailLoud(NAME)
// The bin already loaded the invoking directory's .env; the personal .env
// only fills what is still unset (process.loadEnvFile never overrides).
loadEnv(NAME, resolveDshHome())
// Both .env layers are loaded, so switching the workspace here cannot alter
// environment precedence. The cwd IS the workspace seam: the shipped config
// The bin already loaded the invoking directory's .env, and that is the
// whole environment: $DSH_HOME/.env is credentials-local's writable store,
// and hoisting it would make every stored key read as a read-only ambient
// override on the next run — unrotatable from the TUI or the web page.
// The environment is settled, so switching the workspace here cannot alter
// its precedence. The cwd IS the workspace seam: the shipped config
// resolves the session cwd and the HMR watch root from it, so one chdir moves
// 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 +141,22 @@ 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.
// The Loader mounts entries concurrently, so `ui-tui` can already hold the
// terminal (raw mode, bracketed paste, keyboard protocol) when something
// else fails. A config-tree failure settles through `boot`, which disposes
// the tree itself; this release covers the rejections `boot` cannot see — a
// plugin's detached async work rejecting while mounting is still in flight
// or after the tree settled. Disposing the tree runs the TUI's own shutdown,
// which stops the terminal and hands the shell back; without it such a
// failure returns to a corrupted prompt. `app.current` is captured from
// boot's `prepare` hook, so it holds the root context for the whole mounting
// window rather than only after boot resolves.
installFailLoud(NAME, process, async () => {
await app.current?.fiber.dispose()
})
// Resume always enters the default surface because 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
@@ -197,18 +208,33 @@ export async function runTui(
// demo or test config would silently run on the user's provider and model.
// `--config-replace` additionally discards the base and the surface overlay.
const replaceTree = configReplace !== undefined
const patches = replaceTree ? [] : [
...loadOverlayPatches(NAME, TUI_OVERLAY),
...resolvedConfig === undefined
? loadPersonalPatches(NAME) ?? []
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined)
// Same opt-out semantics as the web surface (resolveTelemetryPatch: any
// non-empty value disables; setting the switch against a tree without the
// row fails loud rather than silently no-opping a privacy switch). The row
// presence is checked against the tree actually booting, so a
// --config-replace tree is judged on its own rows, not the shipped base's.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig))
const composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [
...replaceTree ? [] : [
...loadOverlayPatches(NAME, TUI_OVERLAY),
...resolvedConfig === undefined
? personalPatches
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
],
...telemetryPatch === undefined ? [] : [telemetryPatch],
]
const patches = composePatches(loadPersonalPatches(NAME) ?? [])
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
const ctx = await boot(
NAME,
resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined),
bootConfig,
patches,
(hostCtx) => {
// Runs after the Loader installs and before any config-tree entry mounts,
// so the fail-loud release hook can reach the tree for the whole window in
// which an entry may reject.
app.current = hostCtx
// The launcher owns session identity and the exit line: a config-mounted
// app bundle reads both from these slots, so no cordis.yml key can drop
// resume.
@@ -239,7 +265,25 @@ export async function runTui(
}
},
)
// The shipped tree includes HMR and keeps personal config live. An explicit
// --config tree replaces the personal overlay (so there is nothing to keep
// live), and a --config-replace or HMR-less tree remains a valid composition
// that still receives the startup overlay but deliberately has no hidden
// watcher.
if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) {
await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches })
}
app.current = ctx
addHarnessSourceSection(ctx, SOURCE_ROOT)
if (showFirstRunWelcome) {
await ctx.plugin({
name: tuiFirstRunWelcomeName,
inject: tuiFirstRunWelcomeInject,
apply: applyTuiFirstRunWelcome,
}, {
dshHome,
asciiArt: needsTuiFirstRunWelcomeAsciiArt(),
})
}
}
/* v8 ignore stop */

View File

@@ -7,23 +7,87 @@
*/
import { fileURLToPath } from 'node:url'
import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type { Context } from 'cordis'
import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tool-bash'
import { AppCLIEntry } from './app-cli-entry.ts'
// The shared core every `dsh` surface mounts, plus this surface's overlay over it.
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url))
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
const DSH_WEB_URL = 'DSH_WEB_URL' as const
const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
type WebMode = 'production' | 'development'
// Display-only mirror of the webserver schema's loopback host: the address the
// local URL always prints. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
const updateContract = mode === 'development'
? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. '
+ 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. '
+ 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. '
: 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. '
+ 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. '
return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. `
+ 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. '
+ 'The browser provides no implicit DOM, route, or screenshot context. '
+ updateContract
+ 'Starting another server does not update this GUI. '
+ 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. '
+ 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.'
}
/** Resolve the canonical loopback URL from the active Web server. */
function localWebUrl(ctx: Context): string {
const port = ctx.get('httpServer')?.port
if (port === undefined) throw new Error('dsh web: httpServer service missing while resolving Web runtime')
return `http://${LOOPBACK_HOST}:${String(port)}`
}
/**
* Register the launcher-owned prompt and shell runtime context before the
* shared config tree mounts. The earlier injections install the prompt
* sections and managed Bash contributor when their owning services activate;
* dynamic values read the bound server only when consumed.
* @param ctx - Web root context with Loader installed but no config tree mounted.
* @param sourceRoot - absolute checkout root resolved from the launcher module.
* @param mode - whether this process mounted the client-plugin HMR receiver.
*/
export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: WebMode): void {
ctx.inject(['systemPrompt'], (promptCtx) => {
addHarnessSourceSection(promptCtx, sourceRoot)
promptCtx.systemPrompt.section({
name: 'app:web-surface',
order: -98,
text: () => webSurfacePrompt(localWebUrl(promptCtx), mode),
})
})
ctx.inject(['bashEnv'], (runtimeCtx) => {
runtimeCtx.bashEnv.register({
name: 'web-runtime',
variables: {
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
},
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: mode }),
})
})
}
/**
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
* through only when the flag was given; absent, the shipped Web overlay value stands.
* @param host - the bind host, or `undefined` to keep the config default.
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
* @param dev - mount the client HMR receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles.
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
* @param config - an overlay of loader patches applied over the shipped web
@@ -38,17 +102,21 @@ export async function runWeb(
trustedHosts: string[] | undefined,
config?: string,
): Promise<void> {
const mode: WebMode = dev ? 'development' : 'production'
const entry = new AppCLIEntry({
configPath: BASE_CONFIG,
overlayPath: WEB_OVERLAY,
...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) },
dev,
prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) },
watchPersonalConfig: true,
...host !== undefined && { host },
...port !== undefined && { port },
...workspaceRoot !== undefined && { workspaceRoot },
...trustedHosts !== undefined && { trustedHosts },
})
const { ctx, port: boundPort } = await entry.run()
const resolvedLocalWebUrl = localWebUrl(ctx)
let exiting = false
const shutdown = (code: number): void => {
@@ -57,12 +125,13 @@ 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) })
console.log(`dsh web: ${resolvedLocalWebUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
}

View File

@@ -1,18 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseDshArgs } from '../src/args.ts'
const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
const parse = (argv: string[], experimentalEnv = false) => parseDshArgs(argv, '1.2.3', experimentalEnv)
/**
* `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets
* Commander print to the real streams; capture the exit code and mute output.
*/
function exitCode(argv: string[]): number {
function exitCode(argv: string[], experimentalEnv = false): number {
const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
parse(argv)
parse(argv, experimentalEnv)
throw new Error(`expected ${JSON.stringify(argv)} to exit`)
} catch {
return exit.mock.calls.at(-1)?.[0] as number
@@ -24,13 +24,15 @@ 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' })
// Experimental subcommands run under the per-invocation flag or the env opt-in.
expect(parse(['meta', '--experimental'])).toEqual({ mode: 'meta' })
expect(parse(['meta'], true)).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 +41,36 @@ 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(['upgrade', '--experimental'])).toEqual({ mode: 'upgrade' })
expect(parse(['upgrade'], true)).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(['meta', '--experimental', '--dump-config'])).toBe(1)
expect(exitCode(['upgrade', '--experimental', '--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 +91,32 @@ 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)
// `meta` fixes its own config tree and always starts fresh,
// so every default-surface option is rejected.
expect(exitCode(['meta', '--experimental', '--resume', 's'])).toBe(1)
expect(exitCode(['meta', '--experimental', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['meta', '--experimental', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['meta', '--experimental', '-p', 'task'])).toBe(1)
// `upgrade` takes no options beyond the gate: any leaked default-surface
// flag is a mistyped invocation, not a silently-dropped input.
expect(exitCode(['upgrade', '--experimental', '--resume', 's'])).toBe(1)
expect(exitCode(['upgrade', '--experimental', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'task', 'upgrade', '--experimental'])).toBe(1)
// The pre-release command names have no compatibility aliases.
expect(exitCode(['experimental-meta'])).toBe(1)
expect(exitCode(['experimental-upgrade'])).toBe(1)
})
it('gates experimental subcommands behind --experimental or the env opt-in', () => {
// Bare `meta`/`upgrade` without either opt-in must fail loud, not run.
expect(exitCode(['meta'])).toBe(1)
expect(exitCode(['upgrade'])).toBe(1)
// A leaked default-surface flag stays a typo even when the gate is passed
// by the environment alone.
expect(exitCode(['meta', '--resume', 's'], true)).toBe(1)
// The flag and the env opt-in may coexist.
expect(parse(['meta', '--experimental'], true)).toEqual({ mode: 'meta' })
expect(parse(['upgrade', '--experimental'], true)).toEqual({ mode: 'upgrade' })
})
it('exits 0 for --help (disclosing web) and --version', () => {

View File

@@ -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,93 @@ 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('experimental subcommand gate', () => {
// The gate has two halves: a per-invocation --experimental flag parsed by
// Commander and an env opt-in read by bin.ts as exactly '1'. Passing the
// gate is proven by reaching the NEXT failure — the TUI's piped-stdio
// refusal — instead of the gate diagnostic.
it('rejects bare `meta`/`upgrade` LOUD, naming both opt-ins', async () => {
for (const command of ['meta', 'upgrade']) {
const { code, stderr } = await runBuiltBin([command], { DSH_EXPERIMENTAL: '' })
expect(code).toBe(1)
expect(stderr).toContain(`${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`)
}
}, 30_000)
it('admits --experimental and DSH_EXPERIMENTAL=1, but not other env values', async () => {
const flagged = await runBuiltBin(['meta', '--experimental'], { DSH_EXPERIMENTAL: '' })
expect(flagged.stderr).toContain('requires stdin and stdout to be interactive TTYs')
const env = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '1' })
expect(env.stderr).toContain('requires stdin and stdout to be interactive TTYs')
// The env opt-in is exact: '0' (or any other value) does not enable.
const zero = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '0' })
expect(zero.code).toBe(1)
expect(zero.stderr).toContain('meta is experimental')
}, 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'")
expect(stdout).toContain([
'- id: tool-web',
" name: '@deepseek-ai/dsh-tool-web'",
' config:',
' fetch: false',
' searchTimeoutMs: 60000',
].join('\n'))
// 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)
})
})

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

View 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

View 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`)
})
}

View 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

View File

@@ -0,0 +1,10 @@
# An overlay whose `llm-pi-ai` config fails schema validation: `providers` is a
# dict keyed by provider name, and a list is the shape users reach for. The
# entry rejects while `ui-tui` — mounted concurrently by the Loader — already
# holds the terminal, which is the boot failure the fail-loud release hook
# exists for.
- id: llm-pi-ai
config:
providers:
- provider: openai
apiKey: keyless-invalid-shape

View File

@@ -0,0 +1,155 @@
import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
import { afterEach, describe, expect, it } from 'vitest'
const installer = fileURLToPath(new URL('../../../scripts/install.sh', import.meta.url))
const fixtures: string[] = []
const PTY_DRIVER = String.raw`
import errno, json, os, pty, select, signal, sys, time
script, cwd, env_json, actions_json = sys.argv[1:]
env = os.environ.copy()
env.update(json.loads(env_json))
actions = json.loads(actions_json)
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.execvpe("sh", ["sh", script], env)
output = bytearray()
action_index = 0
deadline = time.monotonic() + 15
status = None
while time.monotonic() < deadline:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
chunk = os.read(fd, 65536)
except OSError as error:
if error.errno != errno.EIO:
raise
chunk = b""
output.extend(chunk)
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
os.write(fd, actions[action_index]["send"].encode())
action_index += 1
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
status = candidate
break
if status is None:
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
sys.stdout.buffer.write(output)
if action_index != len(actions):
sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions\n")
sys.exit(124)
sys.exit(os.waitstatus_to_exitcode(status))
`
interface Action {
readonly waitFor: string
readonly send: string
}
interface Fixture {
readonly binDirectory: string
readonly launchLog: string
readonly pnpmLog: string
readonly root: string
readonly script: string
}
afterEach(async () => {
await Promise.all(fixtures.splice(0).map(async (fixture) => { await rm(fixture, { force: true, recursive: true }) }))
})
function executable(path: string, content: string): void {
writeFileSync(path, content)
chmodSync(path, 0o755)
}
async function createFixture(): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), 'dsh-install-'))
fixtures.push(root)
const checkoutDirectory = join(root, 'checkout')
const scriptsDirectory = join(checkoutDirectory, 'scripts')
const sourceBinDirectory = join(checkoutDirectory, 'bin')
const fakeBinDirectory = join(root, 'fake-bin')
const binDirectory = join(root, 'path-bin')
for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) {
mkdirSync(directory, { recursive: true })
}
const script = join(scriptsDirectory, 'install.sh')
copyFileSync(installer, script)
const launchLog = join(root, 'launch.log')
const pnpmLog = join(root, 'pnpm.log')
executable(join(sourceBinDirectory, 'dsh'), '#!/bin/sh\nprintf \'%s\\n\' "$*" >"$DSH_TEST_LAUNCH_LOG"\n')
executable(join(fakeBinDirectory, 'pnpm'), `#!/bin/sh
if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi
printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG"
`)
await execa('git', ['init', '-q'], { cwd: checkoutDirectory })
await execa('git', ['add', 'bin/dsh', 'scripts/install.sh'], { cwd: checkoutDirectory })
await execa('git', [
'-c', 'user.name=dsh-test',
'-c', 'user.email=dsh-test@example.invalid',
'commit', '-qm', 'fixture',
], { cwd: checkoutDirectory })
writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n')
return { binDirectory, launchLog, pnpmLog, root, script }
}
async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promise<string> {
const result = await execa('python3', [
'-c',
PTY_DRIVER,
fixture.script,
fixture.root,
JSON.stringify({
DSH_BIN_DIR: fixture.binDirectory,
DSH_HOME: join(fixture.root, 'home/.dsh'),
DSH_TEST_LAUNCH_LOG: fixture.launchLog,
DSH_TEST_PNPM_LOG: fixture.pnpmLog,
HOME: join(fixture.root, 'home'),
PATH: `${join(fixture.root, 'fake-bin')}:${fixture.binDirectory}:${process.env.PATH ?? ''}`,
}),
JSON.stringify(actions),
], { reject: false, stripFinalNewline: false, timeout: 20_000 })
expect(result.exitCode, result.stderr).toBe(0)
return result.stdout
}
describe.runIf(process.platform !== 'win32')('one-line installer interface choice', { timeout: 25_000 }, () => {
it('builds and launches the Web UI when the default choice is accepted', async () => {
const fixture = await createFixture()
const output = await runInstaller(fixture, [
{ waitFor: 'Replace it?', send: '\n' },
{ waitFor: 'Choose an interface [1/2]:', send: '\n' },
])
expect(output).toContain('launching Web UI')
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n')
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n')
})
it('rejects an unknown choice, then launches the TUI without building', async () => {
const fixture = await createFixture()
const output = await runInstaller(fixture, [
{ waitFor: 'Replace it?', send: '\n' },
{ waitFor: 'Choose an interface [1/2]:', send: 'terminal\n' },
{ waitFor: 'choose 1 for Web UI or 2 for TUI', send: '2\n' },
])
expect(output).toContain('launching TUI')
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\n')
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('\n')
})
})

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

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

View File

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

View File

@@ -0,0 +1,135 @@
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))
const PERMISSION_SUMMARY = 'current preset workspace-write (available: read-only, workspace-write, danger-full-access)'
// 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',
'list_agents',
'ralph',
'read',
'send_message',
'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>
/** Initial permission facts pinned by the shipped composition. */
permissionEvents: Array<[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 events = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean)
.map(line => JSON.parse(line) as SessionEvent)
const header = events.find(event => event.type === 'request/header')
if (header === undefined || header.type !== 'request/header') {
throw new Error(`session log ${logRelPath} has no request/header event`)
}
const tools = header.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 ?? {},
permissionEvents: events.flatMap(event =>
event.type === 'permission/preset' || event.type === 'sandbox/mode' || event.type === 'approval/policy'
? [[event.type, event.data] as [string, unknown]]
: []),
}
}
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: '/permission\r' },
{ waitFor: PERMISSION_SUMMARY, 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(output).toContain(PERMISSION_SUMMARY)
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)))
expect(observed?.bashArguments).toHaveProperty('sandbox_permissions')
expect(observed?.bashArguments).toHaveProperty('justification')
expect(observed?.permissionEvents).toEqual([
['permission/preset', { preset: 'workspace-write' }],
['sandbox/mode', { mode: 'workspace-write' }],
['approval/policy', { policy: 'ask' }],
])
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783352050755,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783352050756,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783352050756,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783352051421,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":5,"time0":1783352051422,"data":{"turn":1,"step":1,"index":0,"dt":[168,28,0,1,0,0,26,30,0,0,1,0,27,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."]}}
{"type":"assistant/chunk","seq":23,"time":1783352051790,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":56,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}}}
{"type":"assistant/chunk","seq":57,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}}}
{"type":"assistant/chunk","seq":58,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":59,"time":1783352052121,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"}
{"type":"assistant/message","seq":59,"time":1783352052121,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"}
{"type":"tool/call","seq":60,"time":1783352052121,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}
{"type":"tool/result","seq":61,"time":1783352052136,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"}
{"type":"step/end","seq":62,"time":1783352052137,"data":{"turn":1,"step":1}}
@@ -25,6 +25,6 @@
{"type":"assistant/chunk","seq":91,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":92,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}}}}
{"type":"assistant/chunk","seq":93,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":94,"time":1783352052987,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"}
{"type":"assistant/message","seq":94,"time":1783352052987,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"}
{"type":"step/end","seq":95,"time":1783352052987,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":96,"time":1783352052987,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -3,7 +3,7 @@
{"type":"user/message","seq":1,"time":1785052797818,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool exactly once with the command `seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'`, then return ONLY the number of lines in its output. Reply with just that number and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785052797825,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785052797826,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785052797827,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":4,"time":1785052797827,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785052798220,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785052798221,"data":{"turn":1,"step":1,"index":0,"dt":[170,30,0,0,0,30,1,0,0,28,0,0,0,29,30,0,30,0,30,0,0,0,30,0,0,0,0,0,30,30,1],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," run","_code"," program"," that"," calls"," bash"," exactly"," once"," with"," a"," specific"," command",","," then"," returns"," only"," the"," number"," of"," lines"," in"," its"," output","."]}}
{"type":"assistant/chunk","seq":38,"time":1785052798781,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -12,7 +12,7 @@
{"type":"assistant/chunk","seq":153,"time":1785052799793,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}}}}
{"type":"assistant/chunk","seq":154,"time":1785052799794,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}}}}
{"type":"assistant/chunk","seq":155,"time":1785052799794,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":156,"time":1785052799798,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that calls bash exactly once with a specific command, then returns only the number of lines in its output."},{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155],"surfaceOp":"append"}
{"type":"assistant/message","seq":156,"time":1785052799798,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that calls bash exactly once with a specific command, then returns only the number of lines in its output."},{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155],"surfaceOp":"append"}
{"type":"tool/call","seq":157,"time":1785052799799,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}}
{"type":"tool/code-dispatch-start","seq":158,"time":1785052799893,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"}}}
{"type":"tool/code-dispatch","seq":159,"time":1785052799923,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"},"isError":false,"content":[{"type":"text","text":"line 0001: the quick brown fox jumps over the lazy dog\nline 0002: the quick brown fox jumps over the lazy dog\nline 0003: the quick brown fox jumps over the lazy dog\nline 0004: the quick s over the lazy dog\nline 0198: the quick brown fox jumps over the lazy dog\nline 0199: the quick brown fox jumps over the lazy dog\nline 0200: the quick brown fox jumps over the lazy dog\n\n\n(Omitted 10629 bytes. Full formatted result stored at: {{cwd}}/.spill/session-2d2b9e84a250/825a63550249-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}]}}
@@ -27,6 +27,6 @@
{"type":"assistant/chunk","seq":187,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"200"}}}}
{"type":"assistant/chunk","seq":188,"time":1785052800732,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":33,"outputTokens":22,"cacheReadTokens":4224,"reasoningTokens":20}}}}
{"type":"assistant/chunk","seq":189,"time":1785052800732,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":190,"time":1785052800733,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The result is 200 lines. The user wants me to reply with just that number and stop."},{"type":"text","text":"200"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":33,"outputTokens":22,"cacheReadTokens":4224,"reasoningTokens":20}},"sourceEventSeqs":[163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"}
{"type":"assistant/message","seq":190,"time":1785052800733,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The result is 200 lines. The user wants me to reply with just that number and stop."},{"type":"text","text":"200"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":33,"outputTokens":22,"cacheReadTokens":4224,"reasoningTokens":20}},"sourceEventSeqs":[163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"}
{"type":"step/end","seq":191,"time":1785052800733,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":192,"time":1785052800733,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -3,7 +3,7 @@
{"type":"user/message","seq":1,"time":1785014512140,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785014512146,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785014512147,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785014512148,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":4,"time":1785014512148,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785014512526,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785014512527,"data":{"turn":1,"step":1,"index":0,"dt":[92,26,0,0,0,27,0,1,20,1,0,0,0,25,1,0,0,0,24,1,24,26,0,24,1,25,0,0,0,1,0,24,0,1,0,0,0,24,1,0,0,0,24,0,1,0,24,1,0,0,0,0,24,1,0,24,0,0,0,1,1,23,0,0,0,0,1,24,25,1,24,1,0,0,0,25,0,25,1,0,0,25,0,0,24,1,0,0,0,25,0,0,24,1,0,25,1,0,0,25,23,26,1,0,0,25,0,0,24,1,0,0,24,0,1,0,24,1,0,0,25,0,0,1,0,0,23,0,1,0,0,0,24,1,0,0,0,0,24,0,0,0,0,1,24,1,0,0,0,0,25,0,0,0,0,1,24,0,0,24,1,0,0,0,24,0,1,0,0,0,33,0,0,0,16,1,0,0,24,1,0,0,0,26,1,0,23,25,0,0,25,1,0,24,0,1,0,0,24,1,0],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Calls"," `","bash","`"," tool"," twice"," -"," first"," with"," `","echo"," CODE","_","ONE","`,"," then"," with"," `","echo"," CODE","_T","WO","`\n","2","."," `","console",".log","`"," exactly"," `","capt","ured"," output","`\n","3","."," Returns"," the"," two"," outputs"," joined"," with"," a"," plus"," sign","\n\n","Let"," me"," think"," about"," the"," structure","."," The"," `","bash","`"," tool"," returns"," an"," object"," with"," stdout","/st","derr","."," I"," need"," to"," extract"," the"," stdout"," text"," from"," each"," call",".\n\n","Looking"," at"," the"," bash"," output"," type",":\n","```\n","{\n"," "," kind",":"," \"","fore","ground","\";\n"," "," exit","Code",":"," number"," |"," null",";\n"," "," signal",":"," string"," |"," null",";\n"," "," timed","Out",":"," boolean",";\n"," "," ab","orted",":"," boolean",";\n"," "," timeout","Ms",":"," number",";\n"," "," stdout",":"," {\n"," "," text",":"," string",";\n"," "," truncated",":"," boolean",";\n"," "," spill","Path","?:"," string",";\n"," "," };\n"," "," st","derr",":"," {"," ..."," };\n"," "," sand","box","?:"," {"," ..."," };\n","}\n","```\n\n","So"," I"," need"," to"," access"," `.","std","out",".text","`"," from"," each"," result",".\n\n","Let"," me"," write"," the"," program","."]}}
{"type":"assistant/chunk","seq":208,"time":1785014513974,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -12,7 +12,7 @@
{"type":"assistant/chunk","seq":340,"time":1785014514830,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","arguments":"{\"description\": \"Call bash twice, log, join outputs\", \"code\": \"\\nconst r1 = await tools.bash({\\n command: \\\"echo CODE_ONE\\\",\\n description: \\\"Echo CODE_ONE\\\"\\n});\\n\\nconst r2 = await tools.bash({\\n command: \\\"echo CODE_TWO\\\",\\n description: \\\"Echo CODE_TWO\\\"\\n});\\n\\n// Extract stdout text and trim trailing newlines\\nconst out1 = r1.stdout.text.trim();\\nconst out2 = r2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}}}}
{"type":"assistant/chunk","seq":341,"time":1785014514830,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":85,"outputTokens":373,"cacheReadTokens":3968,"reasoningTokens":202}}}}
{"type":"assistant/chunk","seq":342,"time":1785014514830,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":343,"time":1785014514837,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Calls `bash` tool twice - first with `echo CODE_ONE`, then with `echo CODE_TWO`\n2. `console.log` exactly `captured output`\n3. Returns the two outputs joined with a plus sign\n\nLet me think about the structure. The `bash` tool returns an object with stdout/stderr. I need to extract the stdout text from each call.\n\nLooking at the bash output type:\n```\n{\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: { ... };\n sandbox?: { ... };\n}\n```\n\nSo I need to access `.stdout.text` from each result.\n\nLet me write the program."},{"type":"tool-call","id":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","arguments":"{\"description\": \"Call bash twice, log, join outputs\", \"code\": \"\\nconst r1 = await tools.bash({\\n command: \\\"echo CODE_ONE\\\",\\n description: \\\"Echo CODE_ONE\\\"\\n});\\n\\nconst r2 = await tools.bash({\\n command: \\\"echo CODE_TWO\\\",\\n description: \\\"Echo CODE_TWO\\\"\\n});\\n\\n// Extract stdout text and trim trailing newlines\\nconst out1 = r1.stdout.text.trim();\\nconst out2 = r2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":85,"outputTokens":373,"cacheReadTokens":3968,"reasoningTokens":202}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342],"surfaceOp":"append"}
{"type":"assistant/message","seq":343,"time":1785014514837,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Calls `bash` tool twice - first with `echo CODE_ONE`, then with `echo CODE_TWO`\n2. `console.log` exactly `captured output`\n3. Returns the two outputs joined with a plus sign\n\nLet me think about the structure. The `bash` tool returns an object with stdout/stderr. I need to extract the stdout text from each call.\n\nLooking at the bash output type:\n```\n{\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: { ... };\n sandbox?: { ... };\n}\n```\n\nSo I need to access `.stdout.text` from each result.\n\nLet me write the program."},{"type":"tool-call","id":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","arguments":"{\"description\": \"Call bash twice, log, join outputs\", \"code\": \"\\nconst r1 = await tools.bash({\\n command: \\\"echo CODE_ONE\\\",\\n description: \\\"Echo CODE_ONE\\\"\\n});\\n\\nconst r2 = await tools.bash({\\n command: \\\"echo CODE_TWO\\\",\\n description: \\\"Echo CODE_TWO\\\"\\n});\\n\\n// Extract stdout text and trim trailing newlines\\nconst out1 = r1.stdout.text.trim();\\nconst out2 = r2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":85,"outputTokens":373,"cacheReadTokens":3968,"reasoningTokens":202}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342],"surfaceOp":"append"}
{"type":"tool/call","seq":344,"time":1785014514839,"data":{"turn":1,"step":1,"callId":"call_00_D5QaUXWyA2cPRIFIT6o05977","name":"run_code","arguments":"{\"description\": \"Call bash twice, log, join outputs\", \"code\": \"\\nconst r1 = await tools.bash({\\n command: \\\"echo CODE_ONE\\\",\\n description: \\\"Echo CODE_ONE\\\"\\n});\\n\\nconst r2 = await tools.bash({\\n command: \\\"echo CODE_TWO\\\",\\n description: \\\"Echo CODE_TWO\\\"\\n});\\n\\n// Extract stdout text and trim trailing newlines\\nconst out1 = r1.stdout.text.trim();\\nconst out2 = r2.stdout.text.trim();\\n\\nconsole.log(\\\"captured output\\\");\\n\\nreturn out1 + \\\"+\\\" + out2;\\n\"}"}}
{"type":"tool/code-dispatch-start","seq":345,"time":1785014514956,"data":{"parentCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977","subCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE"}}}
{"type":"tool/code-dispatch","seq":346,"time":1785014514990,"data":{"parentCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977","subCallId":"call_00_D5QaUXWyA2cPRIFIT6o05977:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"Echo CODE_ONE"},"isError":false,"content":[{"type":"text","text":"CODE_ONE\n"}]}}
@@ -29,6 +29,6 @@
{"type":"assistant/chunk","seq":427,"time":1785014516200,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}}
{"type":"assistant/chunk","seq":428,"time":1785014516200,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":73,"cacheReadTokens":4352,"reasoningTokens":65}}}}
{"type":"assistant/chunk","seq":429,"time":1785014516200,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":430,"time":1785014516201,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. The output shows:\n- `captured output` (from console.log)\n- `CODE_ONE+CODE_TWO` (the returned joined string)\n\nThe user asked me to reply with that joined string only and stop. So I'll reply with just `CODE_ONE+CODE_TWO`."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":73,"cacheReadTokens":4352,"reasoningTokens":65}},"sourceEventSeqs":[352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429],"surfaceOp":"append"}
{"type":"assistant/message","seq":430,"time":1785014516201,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. The output shows:\n- `captured output` (from console.log)\n- `CODE_ONE+CODE_TWO` (the returned joined string)\n\nThe user asked me to reply with that joined string only and stop. So I'll reply with just `CODE_ONE+CODE_TWO`."},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":73,"cacheReadTokens":4352,"reasoningTokens":65}},"sourceEventSeqs":[352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428,429],"surfaceOp":"append"}
{"type":"step/end","seq":431,"time":1785014516202,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":432,"time":1785014516202,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,12 +2,12 @@
{"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}}
{"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}}
{"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"step/end","seq":10,"time":1783957884564,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":11,"time":1783957884564,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,12 +2,12 @@
{"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}}
{"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}}
{"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"step/end","seq":10,"time":1783957884701,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":11,"time":1783957884701,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,13 +2,13 @@
{"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}
{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}}
{"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}
{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}}
@@ -18,7 +18,7 @@
{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}}}}
{"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}}
{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}}
{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"}
@@ -29,7 +29,7 @@
{"type":"assistant/chunk","seq":27,"time":1783950000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}}
{"type":"assistant/chunk","seq":28,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":29,"time":1783950000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}
{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}
{"type":"tool/call","seq":31,"time":1783957884562,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
{"type":"tool/result","seq":32,"time":1783957884593,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"}
{"type":"step/end","seq":33,"time":1783957884593,"data":{"turn":1,"step":3}}
@@ -39,7 +39,7 @@
{"type":"assistant/chunk","seq":37,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}}
{"type":"assistant/chunk","seq":38,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":39,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}
{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}
{"type":"tool/call","seq":41,"time":1783957884594,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}
{"type":"tool/result","seq":42,"time":1783957884717,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"}
{"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}}
@@ -49,7 +49,7 @@
{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}
{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}
{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}
{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}
{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"}
{"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}}
@@ -59,6 +59,6 @@
{"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}}
{"type":"assistant/chunk","seq":58,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}
{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}
{"type":"step/end","seq":61,"time":1783957884721,"data":{"turn":1,"step":6}}
{"type":"turn/end","seq":62,"time":1783957884721,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783600636317,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783600636317,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":5,"time0":1783600638073,"data":{"turn":1,"step":1,"index":0,"dt":[100,16,0,0,0,0,24,0,0,0,0,29,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]}}
{"type":"assistant/chunk","seq":23,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
@@ -11,6 +11,6 @@
{"type":"assistant/chunk","seq":29,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}}
{"type":"assistant/chunk","seq":30,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}}
{"type":"assistant/chunk","seq":31,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":32,"time":1783600638281,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"}
{"type":"assistant/message","seq":32,"time":1783600638281,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"}
{"type":"step/end","seq":33,"time":1783600638281,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":34,"time":1783600638281,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783600631839,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783600631839,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":5,"time0":1783600634643,"data":{"turn":1,"step":1,"index":0,"dt":[991,0,0,0,0,0,0,0,0,0,1,0,0,0,108,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,8,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," workflow"," tool"," exactly"," once"," with"," specific"," parameters","."," Let"," me"," carefully"," follow"," the"," instructions",":\n\n","1","."," args"," omitted"," (","so"," I"," don","'t"," include"," it",")\n","2","."," meta"," ="," {"," \"","name","\":"," \"","sn","apshot","-flow","\","," \"","description","\":"," \"","one"," child"," for"," the"," snapshot","\""," }\n","3","."," script"," ="," as"," given"," verb","atim","\n","4","."," After"," it"," returns",","," reply"," with"," \"","WORK","FL","OW","_D","ONE","\"\n\n","Let"," me"," do"," exactly"," that","."]}}
{"type":"assistant/chunk","seq":93,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":156,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}}}
{"type":"assistant/chunk","seq":157,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}}}}
{"type":"assistant/chunk","seq":158,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":159,"time":1783600636247,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"}
{"type":"assistant/message","seq":159,"time":1783600636247,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"}
{"type":"tool/call","seq":160,"time":1783600636247,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}
{"type":"tool/result","seq":161,"time":1783600638304,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[160],"surfaceOp":"append"}
{"type":"step/end","seq":162,"time":1783600638304,"data":{"turn":1,"step":1}}
@@ -24,6 +24,6 @@
{"type":"assistant/chunk","seq":202,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}}
{"type":"assistant/chunk","seq":203,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}}
{"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":205,"time":1783600640865,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
{"type":"assistant/message","seq":205,"time":1783600640865,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
{"type":"step/end","seq":206,"time":1783600640865,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":207,"time":1783600640865,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783352113767,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783352113768,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783352113768,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783352114428,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":5,"time0":1783352114428,"data":{"turn":1,"step":1,"index":0,"dt":[114,28,1,0,0,1,28,1,1,0,0,1,24,1,29,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","ONE","\""," and"," use"," no"," tools","."]}}
{"type":"assistant/chunk","seq":23,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":26,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}}}
{"type":"assistant/chunk","seq":27,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}}}}
{"type":"assistant/chunk","seq":28,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":29,"time":1783352114690,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"}
{"type":"assistant/message","seq":29,"time":1783352114690,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"}
{"type":"step/end","seq":30,"time":1783352114690,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":31,"time":1783352114690,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"turn/start","seq":32,"time":1783352114699,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
@@ -26,6 +26,6 @@
{"type":"assistant/chunk","seq":58,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}}
{"type":"assistant/chunk","seq":59,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}}
{"type":"assistant/chunk","seq":60,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":61,"time":1783352115611,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"}
{"type":"assistant/message","seq":61,"time":1783352115611,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"}
{"type":"step/end","seq":62,"time":1783352115611,"data":{"turn":2,"step":1}}
{"type":"turn/end","seq":63,"time":1783352115611,"data":{"turn":2,"reason":{"kind":"completed"}}}

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}}}
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}}}
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"}
{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"}
{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}
{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}
{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","content":[{"type":"text","text":"<path>{{cwd}}/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[13],"surfaceOp":"append"}
@@ -23,6 +23,6 @@
{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":1}}}}
{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":26,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

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

View File

@@ -0,0 +1,30 @@
{"type":"session","version":0,"id":"main-session","createdAt":1784606400000,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":1784606400000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1784606400000,"data":{"content":[{"type":"text","text":"what's the workdir?"}],"source":{"kind":"user"},"role":"user","id":"3fdc2885-1bea-4c6c-b4af-dbd5af7594f8"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784606400000,"data":{"title":"what's the workdir?","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784606400000,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784606400000,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1784606400000,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," working"," directory","."," Let"," me"," check"," using"," p","wd","."]}}
{"type":"assistant/chunk","seq":23,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":24,"time0":1784606400000,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","args":["","{","\"","command","\"",": ","\"","p","wd","\"",", ","\"","description","\"",": ","\"","Print"," current"," working"," directory","\"","}"]}}
{"type":"assistant/chunk","seq":46,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."}}}}
{"type":"assistant/chunk","seq":47,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}}}}
{"type":"assistant/chunk","seq":48,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}}}}
{"type":"assistant/chunk","seq":49,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":50,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."},{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"063a9245-32c3-4551-9ace-d43f10ed5582"},"usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49],"surfaceOp":"append"}
{"type":"tool/call","seq":51,"time":1784606400000,"data":{"turn":1,"step":1,"callId":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}}
{"type":"tool/result","seq":52,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_AqoWTncquNel5ZHsJHOo7491"},"content":[{"type":"tool-result","toolCallId":"call_00_AqoWTncquNel5ZHsJHOo7491","content":[{"type":"text","text":"{{cwd}}\n"}],"isError":false}],"role":"user","id":"16086d3b-6dfa-4970-a06e-78561475af8c"}},"sourceEventSeqs":[51],"surfaceOp":"append"}
{"type":"step/end","seq":53,"time":1784606400000,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":54,"time":1784606400000,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":55,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":56,"time0":1784606400000,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is"," `/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","`."]}}
{"type":"assistant/chunk","seq":83,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"text-chunks","seq0":84,"time0":1784606400000,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is",":\n\n","```\n","/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","\n","```\n\n","Note"," that"," the"," Deep","Se","ek"," Har","ness"," SDK"," checkout"," is"," at"," `/","opt","/d","sh","-source","`"," —"," that","'s"," a"," separate"," location"," used"," for"," inspecting"," or"," extending"," D","SH"," itself",","," while"," the"," working"," directory"," above"," is"," the"," current"," working"," directory"," for"," this"," session","."]}}
{"type":"assistant/chunk","seq":161,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current working directory is `{{cwd}}`."}}}}
{"type":"assistant/chunk","seq":162,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}}}}
{"type":"assistant/chunk","seq":163,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}}}}
{"type":"assistant/chunk","seq":164,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":165,"time":1784606400000,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The current working directory is `{{cwd}}`."},{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"76c18b85-00aa-45f6-a32f-a2bd75412470"},"usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}},"sourceEventSeqs":[55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164],"surfaceOp":"append"}
{"type":"step/end","seq":166,"time":1784606400000,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":167,"time":1784606400000,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,67 @@
terminal 100x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "what's the workdir? — DSH TUI snapshot"
cursor hidden column=7 viewportRow=32 bufferRow=32
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
style 10-16 bold
1| " what's the workdir?"
style 1-19 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "You "
style 0-2 fg=bright-magenta bold underline
5| "what's the workdir? "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| "Reasoning "
style 0-8 dim italic
9| "The user is asking about the current working directory. Let me check using pwd. "
style 0-78 dim italic
10| <blank>
11| "● Tool / bash / Print current working directory"
style 0-46 fg=green
12| "$ pwd "
style 0-4 dim
13| "/workspace/project "
style 0-17 dim
14| "[exit 0] "
style 0-7 dim
15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
style 0-46 dim
16| <blank>
17| "Assistant "
style 0-8 fg=bright-magenta bold underline
18| "Reasoning "
style 0-8 dim italic
19| "The current working directory is /workspace/project. "
style 0-32 dim italic
style 33-84 fg=cyan
style 85-85 dim italic
20| "The current working directory is: "
21| " "
22| " "
23| " /workspace/project "
style 2-53 fg=cyan
24| " "
25| " "
26| "Note that the DeepSeek Harness SDK checkout is at /opt/dsh-source — that's a separate location used "
style 50-64 fg=cyan
27| "for inspecting or extending DSH itself, while the working directory above is the current working "
28| "directory for this session. "
29| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
style 0-46 dim
30| <blank>
31| "/workspace/project deepseek-v4-flash ↑3.4k ↓185 cache 49% 3% c"
style 0-51 fg=bright-magenta bold
style 54-70 dim
style 73-93 dim
style 96-99 dim
32| " dsh ◍ "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
33-35| <blank>

View File

@@ -2,7 +2,7 @@
{"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1783352057657,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":1783352057657,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/header","seq":3,"time":1783352057657,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":1783352058320,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":5,"time0":1783352058320,"data":{"turn":1,"step":1,"index":0,"dt":[106,40,1,0,0,0,17,0,0,0,1,26,1,1,0,0,1,26,0,31,1,25,0,0,0,29,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," todo","_write"," tool"," to"," record"," a"," plan"," with"," exactly"," three"," todos"," in"," the"," specified"," status","es",","," then"," reply"," with"," \"","D","ONE","\"."]}}
{"type":"assistant/chunk","seq":36,"time":1783352058717,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":92,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}}}}
{"type":"assistant/chunk","seq":93,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}}}}
{"type":"assistant/chunk","seq":94,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":95,"time":1783352059099,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"}
{"type":"assistant/message","seq":95,"time":1783352059099,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"}
{"type":"tool/call","seq":96,"time":1783352059099,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}}
{"type":"todo/write","seq":97,"time":1783352059100,"data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"write the fix","status":"pending"},{"content":"run the tests","status":"pending"}]}}
{"type":"tool/result","seq":98,"time":1783352059101,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","content":[{"type":"text","text":"Updated todo list: 2 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[96],"surfaceOp":"append"}
@@ -26,6 +26,6 @@
{"type":"assistant/chunk","seq":127,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":128,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}}}}
{"type":"assistant/chunk","seq":129,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":130,"time":1783352059981,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"}
{"type":"assistant/message","seq":130,"time":1783352059981,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"}
{"type":"step/end","seq":131,"time":1783352059981,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":132,"time":1783352059981,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { resolveTelemetryPatch } from '../src/app-cli-entry.ts'
describe('resolveTelemetryPatch', () => {
it('keeps telemetry enabled when the switch is unset or empty', () => {
expect(resolveTelemetryPatch(undefined, true)).toBeUndefined()
expect(resolveTelemetryPatch('', true)).toBeUndefined()
})
it('disables on ANY non-empty value, including falsy-looking ones', () => {
for (const value of ['1', '0', 'false', 'no']) {
expect(resolveTelemetryPatch(value, true)).toEqual({ id: 'telemetry-otel', disabled: true })
}
})
it('fails loud when the switch is set but the row is absent', () => {
expect(() => resolveTelemetryPatch('1', false)).toThrow('DSH_TELEMETRY_DISABLED is set but row "telemetry-otel" is not in this composition')
})
it('ignores a missing row while the switch is unset', () => {
expect(resolveTelemetryPatch(undefined, false)).toBeUndefined()
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,19 +1,43 @@
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
import { createHash } from 'node:crypto'
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'
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin'
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))
// An overlay whose `llm-pi-ai` config fails validation, so an entry rejects
// while the TUI already holds the terminal.
const invalidProviderConfigPath = fileURLToPath(new URL('./fixtures/tui-invalid-provider.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
@@ -47,6 +71,38 @@ function seedWorkspace(
}
}
/**
* Run the real `prepareDshPlugin` over an equivalent one-skill `.dsh-plugin`
* package and return the generated wrapper text, so the smoke's cache-seeded
* wrapper can never drift from the generator's template.
*/
async function generatePreparedWrapper(pluginName: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'dsh-smoke-wrapper-'))
try {
const plugin = join(root, '.dsh-plugin')
await mkdir(join(root, 'skills', 'config-only-repository'), { recursive: true })
await writeFile(join(root, 'skills', 'config-only-repository', 'SKILL.md'), [
'---',
'name: config-only-repository',
'description: Generator input; the seeded cache copy owns the visible text.',
'---',
'',
'Repository instructions.',
'',
].join('\n'))
await mkdir(plugin, { recursive: true })
await writeFile(join(plugin, 'package.json'), `${JSON.stringify({
name: pluginName,
version: '0.0.0',
dsh: { skills: ['../skills'] },
}, undefined, 2)}\n`)
await prepareDshPlugin(plugin)
return await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8')
} finally {
await rm(root, { recursive: true, force: true })
}
}
/** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */
async function seedResumeSession(cwd: string): Promise<void> {
const sessionCwd = realpathSync.native(cwd)
@@ -125,18 +181,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,
env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' },
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).
@@ -147,6 +262,138 @@ const SELECT_PRO_MODEL = [
const ANSWER_MULTI_WITH_CUSTOM = ' \tRelease notes\r'
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
@@ -169,6 +416,28 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
expect(output).not.toContain('╭')
expect(output).not.toContain('╮')
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
// The Loader mounts entries concurrently, so `ui-tui` can already own the
// terminal when a sibling entry rejects on its config. Exiting without the
// tree's own teardown left raw mode and bracketed paste set on the user's
// shell, and the pending Device Attributes reply landed there as literal
// text. The transactional mount must settle (an HMR initial-scan refresh
// once deadlocked its rollback into a silent exit 13) so `boot` disposes
// the tree — reaching the TUI's own shutdown — and rejects with the
// labelled diagnostic.
it('restores the terminal when a sibling entry fails to validate during boot', async () => {
const output = await smoke({
label: 'dsh invalid provider config',
tempDirPrefix: 'dsh-tui-invalid-config-',
configPath: invalidProviderConfigPath,
expectedExitCode: 1,
})
expect(output).toContain('dsh: plugin tree failed to load:')
expect(output).toContain('$.providers')
// Bracketed paste is disabled again, which only `ProcessTerminal.stop()`
// writes — proof the tree was disposed rather than exited out from under.
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
@@ -226,7 +495,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')`
@@ -262,7 +531,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 = [
@@ -292,7 +561,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({
@@ -317,11 +586,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',
@@ -341,7 +650,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({
@@ -356,36 +665,90 @@ 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 the tree and .env feeds its !!js', async () => {
// The whole personal-config chain in one boot: the personal .env supplies
// the variable, config.yaml patches the `tui` row — a row the SURFACE
// OVERLAY inserted, not one the base declares — with a `!!js` reference to
// it, and the banner renders the patched welcome verbatim. That proves a
// later patch list reaches a row an earlier one inserted.
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
// it deliberately excludes. config.yaml patches the `tui` row — a row the
// SURFACE OVERLAY inserted, not one the base declares — proving a later
// patch list reaches a row an earlier one inserted. The single `!!js`
// expression prefers the PERSONAL variable, so the welcome can only render
// the project value while the harness home's .env — the credential store
// of `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting
// it would make every stored key read as a read-only launch override on
// the next run and hand it to every subprocess the agent starts.
const output = await smoke({
label: 'dsh personal overlay',
tempDirPrefix: 'dsh-personal-overlay-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({
workspace: { '.env': 'DSH_PROJECT_WELCOME=PROJECT OVERLAY READY.\n' },
personal: {
'.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n',
'.env': 'DSH_PERSONAL_WELCOME=HOME ENV LEAKED.\n',
'config.yaml': [
'- id: workspace-context',
' disabled: true',
'- id: tui',
' config:',
" sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'",
' welcome: !!js process.env.DSH_PERSONAL_WELCOME',
' welcome: !!js process.env.DSH_PERSONAL_WELCOME ?? process.env.DSH_PROJECT_WELCOME',
'',
].join('\n'),
},
}),
actions: [{ waitFor: 'PERSONAL OVERLAY READY.', send: '/exit\r' }],
actions: [{ waitFor: 'PROJECT OVERLAY READY.', send: '/exit\r' }],
})
expect(output).toContain('PERSONAL OVERLAY READY.')
expect(output).toContain('PROJECT OVERLAY READY.')
expect(output).not.toContain('HOME ENV LEAKED.')
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('loads a cached repository Plugin from personal config alone', async () => {
const source = 'github:fixture/repository#fixed-ref'
const specifier = `${source}&path:/.dsh-plugin`
const key = createHash('sha256').update(specifier).digest('hex')
const packageRoot = `cache/repository-plugins/${key}/node_modules/repository`
// Produced by the real generator (prepareDshPlugin over an equivalent
// .dsh-plugin package) rather than hand-written, so a wrapper-template
// change cannot leave this smoke exercising a stale shape. The cache
// LAYOUT below (sha256 key, marker, node_modules/repository) remains a
// deliberate external pin of the durable on-disk format.
const wrapper = await generatePreparedWrapper('config-only-fixture')
const output = await smoke({
label: 'dsh personal repository Plugin',
tempDirPrefix: 'dsh-personal-repository-plugin-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({
personal: {
'config.yaml': [
'- id: repository-plugins',
" name: '@deepseek-ai/dsh-repository-plugin'",
' config:',
' repositories:',
` - '${source}'`,
'',
].join('\n'),
[`cache/repository-plugins/${key}/.repository-cache.json`]: `${JSON.stringify({ specifier })}\n`,
[`${packageRoot}/dsh-plugin.mjs`]: wrapper,
[`${packageRoot}/dsh-plugin-assets/skills/0/config-only-repository/SKILL.md`]: [
'---',
'name: config-only-repository',
'description: CONFIG_ONLY_REPOSITORY_SKILL',
'---',
'',
'Repository instructions.',
'',
].join('\n'),
},
}),
actions: [
{ waitFor: 'main-session-', send: '/skill:config-only' },
{ waitFor: 'CONFIG_ONLY_REPOSITORY_SKILL', send: '\x03/exit\r' },
],
})
expect(output).toContain('CONFIG_ONLY_REPOSITORY_SKILL')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
@@ -399,7 +762,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`,
@@ -414,7 +777,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
@@ -427,7 +790,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
@@ -448,7 +811,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
' config:',
' agents:',
' - id: main',
' provider: deepseek',
' provider: deepseek-official',
' model: deepseek-v4-flash',
' cwd: !!js process.cwd()',
'- id: tui',
@@ -462,7 +825,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
@@ -485,13 +848,14 @@ 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 () => {
it('distinguishes its source path from the current workdir and offers the bundled maintenance skills', async () => {
// The launcher resolves the checkout root three hops up from apps/cli/{src,lib};
// this test file sits an equal depth under the same root, so the same hop applies.
// The source-path line is a system-prompt section; the bundled skills reach the
// model through a durable user message, so each assertion targets its own field.
// The source-path line explicitly distinguishes that checkout from the current workdir;
// bundled skills reach the model through a durable user message, so each assertion
// targets its own field.
const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url))
let context: LoggedRequestContext = { system: '', skillCatalog: '' }
await smoke({
@@ -507,9 +871,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
],
inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) },
})
expect(context.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`)
expect(context.system).toContain(`The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`)
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)
})

View File

@@ -7,12 +7,17 @@ import { Context } from 'cordis'
import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
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'
@@ -36,7 +41,7 @@ import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-termin
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
// Keep pre-normalization layout widths identical across macOS and Linux.
const SNAPSHOT_TMP_ROOT = process.platform === 'win32' ? tmpdir() : '/tmp'
const PROVIDERS = [{ id: 'deepseek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
const PROVIDERS = [{ id: 'deepseek-official', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
type SnapshotMode = 'replay' | 'record' | 'refresh'
@@ -45,6 +50,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>
@@ -53,6 +60,10 @@ interface Scenario {
leavePlanModeAfterFirstTurn?: boolean
recorded: boolean
seedWorkspace?: boolean
/** Add the launcher's model-visible DSH source checkout at this fixed path. */
harnessSourceRoot?: string
/** Replace the real `pwd` result with a portable fixed-length workspace path. */
normalizePwdResult?: boolean
/**
* Load the opt-in `todo_write` tool for this scenario. The shipped TUI
* config omits it, so only the todo-plan scenario (the enabled-path proof)
@@ -68,6 +79,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 +98,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',
@@ -94,6 +120,14 @@ const SCENARIOS: Scenario[] = [
expectedTools: ['bash'],
recorded: true,
},
{
name: 'source-checkout-workdir',
composition: 'native',
expectedTools: ['bash'],
recorded: true,
harnessSourceRoot: '/opt/dsh-source',
normalizePwdResult: true,
},
{
name: 'parallel-file-reads',
composition: 'native',
@@ -149,6 +183,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 +233,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`),
)
}
@@ -187,6 +264,12 @@ function rawSessionLog(session: Session): string {
].join('\n')
}
async function materializeFixtureCwd(fixtureFile: string, cwd: string, replayRoot: string): Promise<string> {
const realized = join(replayRoot, basename(fixtureFile))
await writeFile(realized, (await readFile(fixtureFile, 'utf8')).split('{{cwd}}').join(cwd))
return realized
}
function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string {
return snapshot
.split(`/private${cwd}`).join('/workspace/project')
@@ -206,12 +289,31 @@ 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,
displayCwd: string,
fixtureFile: string,
childFiles: string[],
replayRoot: string | undefined,
): Promise<Context> {
class SnapshotLocalFileSystem extends LocalFileSystem {
override async resolve(
@@ -231,7 +333,11 @@ async function mountScenarioContext(
tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' },
skills: { local: { agentsHome: join(cwd, '.agents') } },
})
if (scenario.harnessSourceRoot !== undefined) addHarnessSourceSection(ctx, scenario.harnessSourceRoot)
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 +355,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.' })
}
@@ -263,7 +370,12 @@ async function mountScenarioContext(
if (MODE === 'record' && scenario.recorded) {
await ctx.plugin(LlmDeepSeek)
} else {
installLlmReplay(ctx, { file: fixtureFile, childFiles, providers: PROVIDERS })
if (replayRoot === undefined) throw new Error('replay mode requires an isolated fixture directory')
// Recorded model text may name the generated cwd. Realize the portable token
// outside that cwd so tools see only the scenario workspace during replay.
const replayFile = await materializeFixtureCwd(fixtureFile, cwd, replayRoot)
const replayChildFiles = await Promise.all(childFiles.map(file => materializeFixtureCwd(file, cwd, replayRoot)))
installLlmReplay(ctx, { file: replayFile, childFiles: replayChildFiles, providers: PROVIDERS })
}
return ctx
}
@@ -276,9 +388,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) {
@@ -287,15 +399,27 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`))
const displayCwd = `/tmp/${basename(cwd)}`
let replayRoot: string | undefined
let ctx: Context | undefined
let controller: ReturnType<typeof createTuiChat> | undefined
const terminal = new HeadlessTerminal(100, 36)
try {
if (!(MODE === 'record' && scenario.recorded)) {
replayRoot = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-replay-${scenario.name}-`))
}
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)
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles, replayRoot)
if (scenario.normalizePwdResult === true) {
ctx.on('tools/post-execute', async (exec, result, next) => {
const args = exec.arguments as { command?: unknown }
return exec.name === 'bash' && args.command === 'pwd' && !result.isError
? { kind: 'accept', content: [{ type: 'text', text: '/workspace/project\n' }] }
: next()
})
}
const disposedSessions: Session[] = []
ctx.on('session/disposed', (session) => { disposedSessions.push(session) })
const workflowEvents: string[] = []
@@ -305,9 +429,10 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const handle = await ctx.agents.create({
sessionId: SessionId('main-session'),
meta: { cwd },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
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 +505,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,16 +529,101 @@ 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)
.toContain(FILE_REFERENCE_PROMPT)
if (scenario.harnessSourceRoot !== undefined) {
expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
.toContain(`The DeepSeek Harness implementation checkout is at ${scenario.harnessSourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`)
}
expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools)
for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) {
expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count)
@@ -437,6 +655,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,
@@ -477,6 +776,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
await ctx?.fiber.dispose()
await terminal.dispose()
await rm(cwd, { recursive: true, force: true })
if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true })
clock.mockRestore()
}
}
@@ -514,7 +814,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 +838,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)
}
}

View File

@@ -0,0 +1,32 @@
import { sep } from 'node:path'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { HARNESS_SOURCE_SECTION } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { prepareWebRuntimeContext } from '../src/web.ts'
describe('prepareWebRuntimeContext', () => {
it('installs both sections before a later systemPrompt consumer activates', async () => {
const ctx = new Context()
const sourceRoot = `${sep}opt${sep}harness-src`
let observedSections: { name: string; text: string }[] | undefined
try {
prepareWebRuntimeContext(ctx, sourceRoot, 'production')
ctx.provide('httpServer', { port: 3080 } as Context['httpServer'])
const consumer = ctx.inject(['systemPrompt'], async (promptCtx) => {
const assembly = await promptCtx.systemPrompt.assemble()
observedSections = assembly.sections
})
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
await consumer
expect(observedSections?.map(section => section.name)).toContain(HARNESS_SOURCE_SECTION)
expect(observedSections?.find(section => section.name === 'app:web-surface')?.text)
.toContain('http://127.0.0.1:3080')
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -23,6 +23,9 @@
{
"path": "../../packages/ui/app-boot"
},
{
"path": "../../packages/bash/tool-bash"
},
{
"path": "../../packages/ui/tui"
},