Merge remote-tracking branch 'origin/master' into worktree/pr1018-todo-retarget-20260731

# Conflicts:
#	apps/cli/README.i18n.yaml
#	apps/cli/README.md
#	apps/cli/README.zh.md
This commit is contained in:
Tianyi Cui
2026-07-31 19:17:50 +08:00
293 changed files with 8581 additions and 2060 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: 3f260b5f5f16638d1dc2c28385eb3eccdf59c8e8
README.zh.md: e57b3c4e94da6e4fe86e2781075118139368f6e2
README.md: c3d6a359c81cb2dce13d43715b1d816cc010efff
README.zh.md: 9843738e015af1259a228e6023c6096ef624f4dd

View File

@@ -13,6 +13,7 @@ The TUI surface:
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`.
- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context.
- registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible.
`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session.
@@ -28,6 +29,14 @@ The Web/headless composition also registers only `web_search`. Search uses DeepS
`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).
[`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).
## Install (developer machine)

View File

@@ -13,6 +13,7 @@ TUI 界面:
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`
-`DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有overlay 不会写入会话事件或模型上下文。
- 注册裸 `/compact`agent 空闲时即使未达到自动压力也会摘要有效的较早历史该命令拒绝参数并只在独立替换标记对持久化后报告成功。压缩compaction期间提交的提示词保留其队列身份并在该检查点之后启动注入的上下文仍保持可见。
`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`
@@ -28,6 +29,14 @@ Web无头组合还只会注册 `web_search`。搜索使用 DeepSeek 的 Anthr
`DSH_TOOLS_MODE` 为整个 Web无头进程选择工具呈现模式可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seamLoader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
[`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)。
## 安装(开发机)

View File

@@ -76,6 +76,8 @@ flowchart LR
cfg --> plugin_tui_token_meter
plugin_tui_compact_basic["compact-basic<br/>@deepseek-ai/dsh-compact-basic"]
cfg --> plugin_tui_compact_basic
plugin_tui_command_compact["command-compact<br/>@deepseek-ai/dsh-command-compact"]
cfg --> plugin_tui_command_compact
plugin_tui_subagent["subagent<br/>@deepseek-ai/dsh-subagent"]
cfg --> plugin_tui_subagent
plugin_tui_subagent_spawn["subagent-spawn<br/>@deepseek-ai/dsh-subagent-spawn"]
@@ -144,6 +146,7 @@ flowchart LR
| `plan-mode` | `@deepseek-ai/dsh-plan-mode` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
| `compact-basic` | `@deepseek-ai/dsh-compact-basic` |
| `command-compact` | `@deepseek-ai/dsh-command-compact` |
| `subagent` | `@deepseek-ai/dsh-subagent` |
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |

View File

@@ -84,8 +84,7 @@
- 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; other surfaces repoint or disable the row in their
@@ -202,6 +201,11 @@
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
# Human `/compact`: one useful reduction below the automatic threshold. Backend
# independent, so it follows whichever compaction service this leaf mounts.
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
- id: subagent
name: '@deepseek-ai/dsh-subagent'

View File

@@ -0,0 +1,72 @@
# Opt-in two-tool profile over the shipped Web composition. The default native
# model surface is exactly persistent `bash` plus `str_replace_editor`; the
# Web host, browser shell, workspace, persistence, and permission stack remain.
# Disable every model-facing consumer in the base/Web tree. plan-mode owns the
# always-registered exit_plan_mode tool even while the session is not planning.
- id: tool-bash
disabled: true
- id: tool-tasks
disabled: true
- id: tool-fs
disabled: true
- id: tool-fs-search
disabled: true
- id: tool-web
disabled: true
- id: tool-skill
disabled: true
- id: plan-mode
disabled: true
- id: tool-subagent
disabled: true
- id: tool-subagent-fork
disabled: true
- id: tool-workflow
disabled: true
- id: tool-todo
disabled: true
# 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

@@ -119,8 +119,7 @@
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
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), 'storages') })()
root: !!js dshHomePath('storages')
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'

View File

@@ -48,6 +48,7 @@
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
"@deepseek-ai/dsh-command-compact": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
@@ -69,9 +70,12 @@
"@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-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
@@ -104,12 +108,14 @@
"@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-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",

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

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

@@ -11,8 +11,12 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker'
import CommandService from '@deepseek-ai/dsh-commands'
import * as CommandCompact from '@deepseek-ai/dsh-command-compact'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
@@ -45,6 +49,8 @@ type ScenarioInteraction = 'skill-invocation-policy'
interface Scenario {
name: string
/** Replay fixture owned by an earlier scenario, for a derived presentation case. */
fixture?: string
composition: Composition
expectedTools: string[]
expectedEventCounts?: Record<string, number>
@@ -68,6 +74,13 @@ interface Scenario {
spillMaxInlineBytes?: number
/** Run scenario-specific terminal input instead of replaying recorded user prompts. */
interaction?: ScenarioInteraction
/**
* Mount a deterministic compaction backend plus `/compact`, then run the
* human command with a held summary while a prompt and injected context
* arrive. Proves queued input waits for the standalone bracket's durability
* checkpoint instead of racing the replacement.
*/
manualCompact?: boolean
}
const SCENARIOS: Scenario[] = [
@@ -80,6 +93,14 @@ const SCENARIOS: Scenario[] = [
leavePlanModeAfterFirstTurn: true,
recorded: true,
},
{
name: 'queued-manual-compact',
fixture: 'multi-turn-conversation',
composition: 'native',
expectedTools: [],
recorded: false,
manualCompact: true,
},
{
name: 'todo-plan',
composition: 'native',
@@ -149,6 +170,44 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotMode {
const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT)
const observedScenarios = new Set<string>()
const workerState = Reflect.get(globalThis, '__vitest_worker__') as
| { readonly config?: { readonly testNamePattern?: RegExp } }
| undefined
// Worker argv omits the parent CLI's `-t`; the serialized runner config is the
// authoritative distinction between a focused replay and the full suite.
const TEST_NAME_FILTERED = workerState?.config?.testNamePattern !== undefined
/**
* Deterministic keyless summary that pauses so the scenario can submit a real
* prompt and inject context while manual compaction holds turn admission.
*/
class DeferredSnapshotCompactService extends BasicCompactService {
readonly summaryStarted = Promise.withResolvers<undefined>()
readonly releaseSummary = Promise.withResolvers<undefined>()
override async summarize(
_input: SummarizationInput,
_agent: Agent,
signal?: AbortSignal,
): Promise<{ summary: [{ type: 'text'; text: string }]; provider: string; model: string }> {
this.summaryStarted.resolve(undefined)
await this.releaseSummary.promise
signal?.throwIfAborted()
return {
summary: [{ type: 'text', text: 'Keyless manual compaction checkpoint.' }],
provider: 'snapshot',
model: 'snapshot-compactor',
}
}
}
/** Seed between-turn model-visible history without inventing a loop execution. */
function seedCompactableHistory(agent: Agent): void {
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'Older snapshot context. '.repeat(60) }],
source: { kind: 'plugin', plugin: 'snapshot-seed' },
}))
}
function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string {
const rel = relative(cwd, displayPath)
@@ -161,10 +220,15 @@ function scenarioDir(scenario: Scenario): string {
return join(SNAPSHOTS_DIR, scenario.name)
}
/** Directory owning the replay fixture: the scenario's own, or the one it derives from. */
function fixtureDir(scenario: Scenario): string {
return join(SNAPSHOTS_DIR, scenario.fixture ?? scenario.name)
}
function childFixturePaths(scenario: Scenario): string[] {
return Array.from(
{ length: scenario.childSessions ?? 0 },
(_, index) => join(scenarioDir(scenario), `session.${index + 1}.jsonl`),
(_, index) => join(fixtureDir(scenario), `session.${index + 1}.jsonl`),
)
}
@@ -206,6 +270,24 @@ async function settleTerminal(terminal: HeadlessTerminal): Promise<void> {
if (stable < 3) throw new Error('TUI frames did not quiesce within 200ms')
}
/** Bound deterministic in-process coordination waits with actionable state. */
async function snapshotDeadline<T>(
operation: Promise<T>,
detail: () => string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => { reject(new Error(detail())) }, 5_000)
}),
])
} finally {
if (timer !== undefined) clearTimeout(timer)
}
}
async function mountScenarioContext(
scenario: Scenario,
cwd: string,
@@ -232,6 +314,9 @@ async function mountScenarioContext(
skills: { local: { agentsHome: join(cwd, '.agents') } },
})
await ctx.plugin(TokenMeterService)
if (scenario.manualCompact === true) {
await ctx.plugin(DeferredSnapshotCompactService, { auto: false })
}
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' })
@@ -249,6 +334,7 @@ async function mountScenarioContext(
await ctx.plugin(ToolWorkflow)
await ctx.plugin(ToolRalph)
await ctx.plugin(CommandService)
if (scenario.manualCompact === true) await ctx.plugin(CommandCompact)
if (scenario.enterPlanMode === true) {
await ctx.plugin(PlanModeService, { section: 'Snapshot plan mode instructions.' })
}
@@ -277,8 +363,7 @@ 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 fixtureFile = join(fixtureDir(scenario), 'session.jsonl')
const childFiles = childFixturePaths(scenario)
const prompts = userPrompts(await readFile(fixtureFile, 'utf8'))
if (scenario.interaction === undefined) {
@@ -292,7 +377,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const terminal = new HeadlessTerminal(100, 36)
try {
if (scenario.seedWorkspace === true) {
const source = join(scenarioDir(scenario), 'workspace')
const source = join(fixtureDir(scenario), 'workspace')
await cp(source, cwd, { recursive: true })
}
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles)
@@ -308,6 +393,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
const agent: Agent = handle.agent
if (scenario.manualCompact === true) seedCompactableHistory(agent)
controller = createTuiChat(ctx, {
sessionId: 'main-session',
theme: { color: true },
@@ -380,6 +466,14 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
}
let remainingPrompts = prompts
let queuedPrompt: string | undefined
let manualOrder: string[] | undefined
let manualCommandId: string | undefined
if (scenario.manualCompact === true) {
expect(prompts.length, 'queued manual compaction needs a second replayed prompt').toBeGreaterThanOrEqual(2)
queuedPrompt = prompts.at(-1)
remainingPrompts = prompts.slice(0, -1)
}
if (scenario.enterPlanMode === true) {
const firstPrompt = prompts[0]!
terminal.send(`/plan ${firstPrompt}`)
@@ -396,12 +490,86 @@ 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(',')
}`)
// Real keystrokes: the prompt keeps its ordinary queue identity while
// admission is reserved, and an injection appends immediately.
terminal.send(queuedPrompt)
terminal.send('\r')
await terminal.flush()
await expect.poll(() => inbox.length).toBe(1)
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'Injected while compaction was running.' }],
source: { kind: 'plugin', plugin: 'snapshot-injector' },
}))
expect(inbox[0]).toMatch(/^enqueue:queued:/u)
expect(agent.status).toBe('idle')
expect(agent.session.events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'user'
&& event.data.content.some(block => block.type === 'text' && block.text === queuedPrompt))).toBe(false)
const idle = agent.whenIdle()
compact.releaseSummary.resolve(undefined)
await snapshotDeadline(idle, () =>
`manual compaction did not reach idle; status=${agent.status}; order=${manualOrder?.join(',') ?? ''}; tail=${
agent.session.events.slice(-12).map(event => event.type).join(',')
}`)
await settleTerminal(terminal)
expect(inbox).toEqual([inbox[0], `dequeue:${inbox[0]?.slice('enqueue:queued:'.length) ?? ''}`])
}
const events: SessionEvent[] = [...agent.session.events]
const firstHeader = events.find(event => event.type === 'request/header')
expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
@@ -437,6 +605,87 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content))
.toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }])
}
if (scenario.manualCompact === true) {
const compactStart = events.find(event => event.type === 'compact/start')
const compactSummary = events.find(event => event.type === 'compact/summary')
const compactCheckpoint = events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'compact')
const injectedEvent = events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'snapshot-injector')
const compactEnd = events.find(event => event.type === 'compact/end')
expect(compactStart?.data.turn).toBeNull()
expect(compactEnd?.data.turn).toBeNull()
expect(events.filter(event => event.type === 'compact/summary')).toHaveLength(1)
if (compactStart === undefined || compactSummary === undefined
|| compactCheckpoint === undefined || injectedEvent === undefined
|| compactEnd === undefined) {
throw new Error('manual compaction snapshot is missing its durable marker, summary, checkpoint, or injection')
}
// The markers are time points, not an exclusive container: unrelated
// idle injection is allowed between them while the selected span stays stable.
expect(compactStart.seq).toBeLessThan(injectedEvent.seq)
expect(injectedEvent.seq).toBeLessThan(compactSummary.seq)
expect(compactSummary.seq).toBeLessThan(compactCheckpoint.seq)
expect(compactCheckpoint.seq).toBeLessThan(compactEnd.seq)
const manualTimeline = manualOrder ?? []
const commandRunIndex = manualTimeline.indexOf('command/run')
const compactStartIndex = manualTimeline.indexOf('compact/start')
const compactEndIndex = manualTimeline.indexOf('compact/end')
const firstFlushIndex = manualTimeline.indexOf('flush')
const queuedTurnIndex = manualTimeline.indexOf('turn/start:message')
const commandDoneIndex = manualTimeline.indexOf('command/done')
expect(manualTimeline.filter(item => item === 'command/run')).toHaveLength(1)
expect(manualTimeline.filter(item => item === 'command/done')).toHaveLength(1)
expect(compactStartIndex).toBeGreaterThan(commandRunIndex)
expect(compactEndIndex).toBeGreaterThan(compactStartIndex)
expect(firstFlushIndex).toBeGreaterThan(compactEndIndex)
expect(queuedTurnIndex).toBeGreaterThan(firstFlushIndex)
expect(commandDoneIndex).toBeGreaterThan(firstFlushIndex)
const commandRun = events.find(event => event.type === 'command/run'
&& event.data.name === 'compact')
const commandRunId = commandRun?.type === 'command/run'
? commandRun.data.commandId
: undefined
const commandDone = events.find(event => event.type === 'command/done'
&& event.data.commandId === commandRunId)
expect(commandRun?.type === 'command/run' && commandRun.data).toEqual({
commandId: commandRunId,
name: 'compact',
args: '',
source: { kind: 'user' },
})
expect(commandDone?.type === 'command/done' && commandDone.data).toEqual({
commandId: commandRunId,
kind: 'success',
text: 'Compacted 2 history items (~387 tokens).',
})
expect(commandRun !== undefined && commandRun.seq < compactStart.seq).toBe(true)
expect(commandDone !== undefined && commandDone.seq > compactEnd.seq).toBe(true)
expect(agent.session.surface.nodes).not.toContain(commandRun?.seq)
expect(agent.session.surface.nodes).not.toContain(commandDone?.seq)
// The command line itself never becomes a prompt.
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'user'
&& event.data.content.some(block => block.type === 'text' && block.text.trim() === '/compact'))).toBe(false)
const derived = agent.session.deriveMessages().map(message => message.content
.map(block => block.type === 'text' ? block.text : '')
.join(''))
const checkpoint = derived.findIndex(text => text.includes('Keyless manual compaction checkpoint.'))
const injected = derived.findIndex(text => text.includes('Injected while compaction was running.'))
const queued = derived.findIndex(text => text === queuedPrompt)
expect(checkpoint).toBe(0)
expect(injected).toBeGreaterThan(checkpoint)
expect(queued).toBeGreaterThan(injected)
expect(derived).not.toContain('/compact')
expect(derived).not.toContain('Compacted 2 history items (~387 tokens).')
expect(derived.filter(text => text.includes('Injected while compaction was running.'))).toHaveLength(1)
expect(compactSummary.data.shadowedSeqs).not.toContain(injectedEvent.seq)
const queuedTurn = events.findLast(event => event.type === 'turn/start')
expect(queuedTurn !== undefined && compactEnd.seq < queuedTurn.seq).toBe(true)
}
if (scenario.spillMaxInlineBytes !== undefined) {
// The REAL pipeline ran (tools execute on replay too): the durable
// dispatch copy is bounded to a preview + locator under the run cwd,
@@ -514,7 +763,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 +787,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)
}
}