Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md
#	docs/architecture.i18n.yaml
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/defensive-patterns.i18n.yaml
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/queue-store.spec.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/agent/tests/agent.spec.ts
#	packages/core/scope/src/scoped-events.generated.ts
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal-session/src/index.ts
#	packages/goal/goal-session/tests/goal-session.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/goal/goal/tests/projection.spec.ts
#	packages/goal/tool-goal/tests/tool-goal.spec.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/events.schema.ts
#	packages/host/apiproxy/src/api/events.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.zh.md
#	packages/llm/llm/src/index.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
#	scripts/gen-cordis-catalog.ts
#	scripts/type-equiv.manifest.json
This commit is contained in:
_Kerman
2026-07-30 14:04:53 +08:00
1175 files changed
+49683 -8452

No files matched your search

+1 -1
View File
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write examples/tui-agent/README.md
README.md: ea8695d37ea247a38644392a4572c1ea9855fd44
README.zh.md: b3f6dc18536b159379eac7433367ccf2cd8fcc53
README.zh.md: c6acd39d8713816d870c00fa8597754d0d09880a
+15 -15
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
全屏交互式编码 agent(智能体):DeepSeek V4、本地 bash 与文件系统工具、压缩(compaction)、subagent、工作流与新 agent Ralph 迭代、plan mode`/plan` 进入,`exit_plan_mode` 评审退出)、超时/溢出策略,以及通过 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 提供的 JSONL 持久化;该应用从 `cordis.yml` 加载。同级 [`headless-agent`](../headless-agent/README.md) 以适合单次管道的任务形式运行同一能力类,[`acp-agent`](../acp-agent/README.md) 则通过 JSON-RPC 提供该能力。
全屏交互式编码 agent(智能体):DeepSeek V4、本地 bash 与文件系统工具、压缩(compaction)、subagent、工作流与新 agent Ralph 迭代、plan mode`/plan` 进入,`exit_plan_mode` 评审退出)、超时/溢出策略,以及通过 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 提供的 JSONL 持久化;该应用从 `cordis.yml` 加载。同级 [`headless-agent`](../headless-agent/README.md) 以适合管道调用的单次任务形式运行同一能力类,[`acp-agent`](../acp-agent/README.md) 则通过 JSON-RPC 提供该能力。
## 运行
@@ -13,13 +13,13 @@
pnpm run demo:tui
```
演示脚本和可安装的 `dsh` CLI[`apps/cli`](../../apps/cli/README.md))都会作为已交付的默认配置启动此示例的 `cordis.yml``dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为 workspace
演示脚本和可安装的 `dsh` CLI命令行界面,见 [`apps/cli`](../../apps/cli/README.md))都会此示例的 `cordis.yml` 作为已交付的默认配置启动`dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为工作区
输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次操作都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。fs 工具和 bash 都会根据会话 workspace 解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。
输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次 bash 调用都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。文件系统工具和 bash 都会相对于会话工作区解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。
`todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。
TUI 渲染 Markdown 历史、推理、工具有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode`/plan <message>` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model <model>``/model <provider>/<model>` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。
TUI 渲染 Markdown 历史、推理reasoning、工具有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode`/plan <message>` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model <model>``/model <provider>/<model>` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。
### 恢复早先的会话
@@ -29,11 +29,11 @@ TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡
dsh --resume <prior-session-id>
```
`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久 goal 阶段和实时/已持久化状态。已安装的 `dsh` 宿主会刷新并释放当前应用,然后以 `dsh --resume <id>` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume <id>` 在启动上下文中提供 id`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。
`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久化目标阶段和实时/已持久化状态。已安装的 `dsh` 宿主会等待刷写完成,对当前应用执行 dispose(资源释放),然后以 `dsh --resume <id>` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume <id>` 在启动上下文中提供 id`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。
## Code Mode
[`code-mode.cordis.yml`](code-mode.cordis.yml) 在同一树上覆盖 worker 线程运行时和 `tools: { mode: code }`。模型会收到一个 `run_code` 传输工具,加上一份为可见工具生成的 TypeScript SDK;只有程序输出会返回模型上下文。使用 `mode: both` 可在 `run_code` 旁同时公开原生调用。执行契约详见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。
[`code-mode.cordis.yml`](code-mode.cordis.yml) 在同一树上覆盖 worker 线程运行时和 `tools: { mode: code }`。模型会收到一个 `run_code` 传输工具,加上一份为可见工具生成的 TypeScript SDK;只有程序输出会返回模型上下文。使用 `mode: both` 可在 `run_code` 旁同时公开原生调用。执行契约详见 [Code Mode Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。
```sh
pnpm run demo:code-mode # this overlay under the TUI (default UI)
@@ -54,27 +54,27 @@ pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay
|---|---|
| `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 |
| `llm-deepseek` | 默认原生适配器 |
| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 可替换一半。面向模型的 `bash` schema`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 |
| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 可替换的实现侧。面向模型的 `bash` schema`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 |
| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent |
| `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent |
| `tool-subagent`, `tool-subagent-fork` | 两次面向模型的 `dsh-tool-subagent` 加载,每次绑定不同提供方,并以不同工具名(`subagent``subagent_fork`)公开 |
| `workflow-workerthread`, `tool-workflow` | worker 线程工作流引擎及其面向模型的 `workflow` 工具,子调用通过 spawn 后端路由 |
| `plan-mode` | 插件拥有的 `/plan [message]` 进入命令和 `/plan off` 退出命令、plan-mode 提示词策略、工具限制,以及经评审的 `exit_plan_mode` 转换 |
| `fs-local`, `fs-policy`, `tool-fs` | 文件系统栈:本地 `ctx.fs` 提供方、先读后写/编辑策略门禁(位于 `fs/*` 事件门禁),以及面向模型的 `read`/`write`/`edit` 工具。相对路径根据会话 workspace 解析 |
| `fs-local`, `fs-policy`, `tool-fs` | 文件系统栈:本地 `ctx.fs` 提供方、先读后写/编辑策略门禁(位于 `fs/*` 事件门禁),以及面向模型的 `read`/`write`/`edit` 工具。相对路径相对于会话工作区解析 |
## 端到端测试(`pnpm run test:e2e`
与 UI 无关的带密钥套件通过 `tests/harness.ts` 以程序方式组装完整栈(无 PTY、无 Loader):
- `tests/full-loop.e2e.ts`:canary 测试:真实模型通过真实 bash 工具运行 `echo e2e-ok`;断言 `tool/call`/`tool/result` 会话事件和最终答案。
- `tests/coding-task.e2e.ts`:类 swebench 冒烟测试:临时目录包含 `add.js`(其中 `a - b` 写在本应是 `a + b` 的位置)和失败的 `add.test.js`;agent 必须修复错误并验证。测试会自行重新运行 `node add.test.js` 并检查文件,不信任 agent 的声称
- `tests/resume.e2e.ts`:跨进程持久连续性:第一次运行告诉真实模型一个密码并将轮次持久化到临时 JSONL 根目录,然后释放整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 id 并要求模型回忆密码。只有重新水化的日志能够提供该回忆。
- `tests/compaction.e2e.ts`:压缩冒烟测试:一项真实多步 bash 任务在故意设得很小的上下文窗口中运行,使自动压缩监听器在会话中途触发。测试验证外部状态:真实日志中出现 `compact/start…end` 对,表层缩减(替换节点遮蔽旧节点),且 agent 在压缩后仍给出正确最终答案。
- `tests/todo-write.e2e.ts`:加载选用 `todo_write` 工具,由真实模型驱动,测试验证产生的 `todo/write` 会话事件。
- `tests/code-mode.e2e.ts`:带密钥 Code Mode 证明:使用真实模型和双工具任务,断言线上工具列表精确为 `[run_code]``tool/code-dispatch` 事件位于父调用下,且筛选后的答案已返回。
- `tests/coding-task.e2e.ts`:类 swebench 冒烟测试:临时目录包含 `add.js`(其中 `a - b` 写在本应是 `a + b` 的位置)和失败的 `add.test.js`;agent 必须修复错误并验证。测试会自行重新运行 `node add.test.js` 并检查文件,不信任 agent 的说法
- `tests/resume.e2e.ts`:跨进程持久连续性:第一次运行告诉真实模型一个密码并将轮次持久化到临时 JSONL 根目录,然后 dispose 整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 id 并要求模型回忆密码。只有重新水化的日志能够提供该回忆。
- `tests/compaction.e2e.ts`:压缩冒烟测试:一项真实多步 bash 任务在故意设得很小的上下文窗口中运行,使自动压缩监听器在会话中途触发。测试验证外部状态:真实日志中出现 `compact/start…end` 对,模型可见内容缩减(一个替换节点遮蔽了较旧节点),且 agent 在压缩后仍给出正确最终答案。
- `tests/todo-write.e2e.ts`:加载选用 `todo_write` 工具,由真实模型驱动,测试验证产生的 `todo/write` 会话事件。
- `tests/code-mode.e2e.ts`:带密钥 Code Mode 证明:使用真实模型和双工具任务,断言协议层工具列表精确为 `[run_code]``tool/code-dispatch` 事件位于父调用下,且筛选后的答案已返回。
这些测试在没有 `DEEPSEEK_API_KEY` 时自行跳过。无密钥 `tests/tui-keyless-smoke.e2e.ts` 通过 PTY 启动真实 Loader 树(唯一获准的 PTY 界面):基础启动 + `/plan` + `/exit`,一次带问题对话框和工具往返的脚本 LLM 对话,Code Mode 覆盖欢迎行,以及恢复失败退出路径。
这些测试在没有 `DEEPSEEK_API_KEY` 时自行跳过。无密钥 `tests/tui-keyless-smoke.e2e.ts` 通过 PTY 启动真实 Loader 树(唯一获准的 PTY 界面):基础启动 + `/plan` + `/exit`,一次带问题对话框和工具往返的脚本 LLM(大语言模型)对话,Code Mode 覆盖配置的欢迎行,以及恢复失败退出路径。
## 快照测试
`tests/snapshots/<scenario>/session.jsonl` 提供已录制的用户提示词和模型分片;同级子日志驱动 subagent 和工作流。无密钥套件通过真实循环和工具实现执行这些脚本,然后比较可读的预期终端单元格/样式输出。使用 `pnpm run test:snapshot:refresh` 刷新仅展示变更;已录制模型程改变时,使用 DeepSeek 密钥运行 `pnpm run test:snapshot:record`。已实现的 [TUI 快照 Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) 拥有场景矩阵,以及已录制旅程、瞬时包快照与 PTY 覆盖之间的分工。
`tests/snapshots/<scenario>/session.jsonl` 提供已录制的用户提示词和模型分片;同级子日志驱动 subagent 和工作流。无密钥套件通过真实循环和工具实现执行这些脚本,然后比较可读的预期终端单元格/样式输出。对于仅涉及展示的变更,使用 `pnpm run test:snapshot:refresh`;已录制模型程改变时,使用 DeepSeek 密钥运行 `pnpm run test:snapshot:record`。已实现的 [TUI 快照 Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) 规定了场景矩阵,以及已录制旅程、包级瞬态快照与 PTY 覆盖之间的分工。
+30 -8
View File
@@ -1,6 +1,7 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { execa } from 'execa'
import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
@@ -36,7 +37,16 @@ while time.monotonic() < deadline:
if chunk:
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 = actions[action_index]
if "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:
handle.write(action["writeFile"]["content"])
if "send" in action:
os.write(fd, action["send"].encode())
else:
os.write(fd, action["send"].encode())
action_index += 1
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
@@ -56,11 +66,14 @@ if actual_exit != int(expected_exit):
sys.exit(125)
`
/** One terminal action sent after its marker has rendered. */
interface TuiPtyAction {
readonly waitFor: string
readonly send: string
}
/** One terminal input or workspace mutation performed after its marker renders. */
type TuiPtyAction =
| { readonly waitFor: string; readonly send: string }
| {
readonly waitFor: string
readonly writeFile: { readonly path: string; readonly content: string }
readonly send?: string
}
/** Inputs for a keyless real-Loader TUI process smoke. */
export interface TuiPtySmokeOptions {
@@ -157,7 +170,16 @@ async function runWindowsPtySmoke(
terminal.onData((chunk) => {
output += chunk
while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) {
terminal.write(actions[actionIndex]!.send)
const action = actions[actionIndex]!
if ('writeFile' in action) {
const target = join(cwd, action.writeFile.path)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, action.writeFile.content)
const input = action.send
if (input !== undefined) terminal.write(input)
} else {
terminal.write(action.send)
}
actionIndex += 1
}
})
@@ -0,0 +1,5 @@
{"type":"session","version":0,"id":"31f63cc0-0198-4ab2-bfde-79a4eb4f1867","createdAt":1783352180000,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"assistant/chunk","seq":0,"time":1783352180001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":1,"time":1783352180002,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"USER-ONLY SKILL LOADED"}}}
{"type":"assistant/chunk","seq":2,"time":1783352180003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"USER-ONLY SKILL LOADED"}}}}
{"type":"assistant/chunk","seq":3,"time":1783352180004,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
@@ -0,0 +1,157 @@
=== skill autocomplete ===
terminal 100x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH TUI snapshot"
cursor hidden column=13 viewportRow=5 bufferRow=5
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
style 10-16 bold
1| " Recorded replay: skill-invocation-policy"
style 1-40 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "/workspace/project deepseek-v4-flash ↑0 ↓0 0% context"
style 0-51 fg=bright-magenta bold
style 54-70 dim
style 73-77 dim
style 80-89 dim
5| " dsh > /skill "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 13-13 inverse
6| " → skill:user-only-skill (project) — User-only assembled snapshot skill. "
style 7-78 fg=bright-magenta
7-35| <blank>
=== loaded exact invocation ===
terminal 100x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "<skill name=\"user-only-skill\"> Reference — DSH TUI snapshot"
cursor hidden column=7 viewportRow=30 bufferRow=30
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
style 10-16 bold
1| " <skill name=\"user-only-skill\"> Reference"
style 1-40 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "You "
style 0-2 fg=bright-magenta bold underline
5| "<skill name=\"user-only-skill\"> "
6| "References in this skill are relative to "
7| "/workspace/project/.agents/skills/user-only-skill. "
8| " "
9| "USER-ONLY BODY "
10| "</skill> "
11| <blank>
12| "Context · dsh-tool-skill"
style 0-23 dim
13| "A skill is a reusable set of task-specific instructions. The following skills are available in this "
style 0-99 dim
14| "session: "
style 0-7 dim
15| " "
16| "<available_skills> "
style 0-17 dim
17| "- `model-only-skill`: Model-only assembled snapshot skill. "
style 0-57 dim
18| "</available_skills> "
style 0-18 dim
19| " "
20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool "
style 0-99 dim
21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their "
style 0-99 dim
22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's "
style 0-99 dim
23| "instructions until it has been loaded. "
style 0-37 dim
24| <blank>
25| "Assistant "
style 0-8 fg=bright-magenta bold underline
26| "USER-ONLY SKILL LOADED "
27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
style 0-46 dim
28| <blank>
29| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context"
style 0-51 fg=bright-magenta bold
style 54-70 dim
style 73-77 dim
style 80-89 dim
30| " dsh ◍ "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
31-35| <blank>
=== denied exact invocation ===
terminal 100x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "<skill name=\"user-only-skill\"> Reference — 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| " <skill name=\"user-only-skill\"> Reference"
style 1-40 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "You "
style 0-2 fg=bright-magenta bold underline
5| "<skill name=\"user-only-skill\"> "
6| "References in this skill are relative to "
7| "/workspace/project/.agents/skills/user-only-skill. "
8| " "
9| "USER-ONLY BODY "
10| "</skill> "
11| <blank>
12| "Context · dsh-tool-skill"
style 0-23 dim
13| "A skill is a reusable set of task-specific instructions. The following skills are available in this "
style 0-99 dim
14| "session: "
style 0-7 dim
15| " "
16| "<available_skills> "
style 0-17 dim
17| "- `model-only-skill`: Model-only assembled snapshot skill. "
style 0-57 dim
18| "</available_skills> "
style 0-18 dim
19| " "
20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool "
style 0-99 dim
21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their "
style 0-99 dim
22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's "
style 0-99 dim
23| "instructions until it has been loaded. "
style 0-37 dim
24| <blank>
25| "Assistant "
style 0-8 fg=bright-magenta bold underline
26| "USER-ONLY SKILL LOADED "
27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
style 0-46 dim
28| <blank>
29| "Skill \"model-only-skill\" is not available for user invocation. "
style 0-61 fg=yellow
30| <blank>
31| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context"
style 0-51 fg=bright-magenta bold
style 54-70 dim
style 73-77 dim
style 80-89 dim
32| " dsh ◍ "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
33-35| <blank>
@@ -0,0 +1,7 @@
---
name: model-only-skill
description: Model-only assembled snapshot skill.
user-invocable: false
---
MODEL-ONLY BODY MUST NOT LOAD
@@ -0,0 +1,7 @@
---
name: user-only-skill
description: User-only assembled snapshot skill.
disable-model-invocation: true
---
USER-ONLY BODY
@@ -217,11 +217,12 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
}, LOADER_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 manual-invocation path in one keyless boot: `ctx.get('skills')`
// The whole user-only invocation path in one keyless boot: `ctx.get('skills')`
// resolves in the shipped tree, the client-side `/skill:` command parses,
// the local provider loads `scripted-skill` from the agents home, and the
// rendered `<skill name="…">` block reaches the model — proven by the
// scripted adapter echoing the fixture's body marker only when it arrives.
// and the local provider admits a model-disabled skill by the omitted
// `user-invocable` default. The rendered `<skill name="…">` block reaches
// the model — proven by the scripted adapter echoing the fixture's body
// marker only when it arrives.
const output = await smoke({
label: 'tui-agent skill',
tempDirPrefix: 'tui-agent-skill-',
@@ -232,6 +233,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
'---',
'name: scripted-skill',
'description: Keyless PTY proof that the skill command loads a local skill into the conversation.',
'disable-model-invocation: true',
'---',
'',
'SCRIPTED SKILL BODY MARKER',
@@ -250,6 +252,36 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('adds a watched local skill to live /skill: autocomplete without restarting', async () => {
const skill = [
'---',
'name: hot-added-skill',
'description: HOT_ADDED_COMPLETION_MARKER',
'---',
'',
'Hot-added body.',
'',
].join('\n')
const output = await smoke({
label: 'tui-agent hot-added skill autocomplete',
tempDirPrefix: 'tui-agent-hot-skill-',
configPath: scriptedConfigPath,
actions: [
{
waitFor: 'scripted TUI ready.',
writeFile: {
path: '.agents/skills/hot-added-skill/SKILL.md',
content: skill,
},
send: '/skill:hot',
},
{ waitFor: 'HOT_ADDED_COMPLETION_MARKER', send: '\x03/exit\r' },
],
})
expect(output).toContain('HOT_ADDED_COMPLETION_MARKER')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('fuzzy-completes an @file path without reading or submitting the file', async () => {
const output = await smoke({
label: 'tui-agent file autocomplete',
+73 -4
View File
@@ -41,6 +41,7 @@ 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'
type Composition = 'native' | 'code' | 'advanced'
type ScenarioInteraction = 'skill-invocation-policy'
interface Scenario {
name: string
@@ -65,6 +66,8 @@ interface Scenario {
* preview + locator while the program value stays whole.
*/
spillMaxInlineBytes?: number
/** Run scenario-specific terminal input instead of replaying recorded user prompts. */
interaction?: ScenarioInteraction
}
const SCENARIOS: Scenario[] = [
@@ -98,6 +101,14 @@ const SCENARIOS: Scenario[] = [
recorded: true,
seedWorkspace: true,
},
{
name: 'skill-invocation-policy',
composition: 'native',
expectedTools: [],
recorded: false,
seedWorkspace: true,
interaction: 'skill-invocation-policy',
},
{
name: 'code-mode',
composition: 'code',
@@ -269,9 +280,10 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const dir = scenarioDir(scenario)
const fixtureFile = join(dir, 'session.jsonl')
const childFiles = childFixturePaths(scenario)
const fixture = await readFile(fixtureFile, 'utf8')
const prompts = userPrompts(fixture)
expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0)
const prompts = userPrompts(await readFile(fixtureFile, 'utf8'))
if (scenario.interaction === undefined) {
expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0)
}
const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`))
const displayCwd = `/tmp/${basename(cwd)}`
@@ -310,6 +322,63 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
})
await settleTerminal(terminal)
let interactionSnapshot: string | undefined
if (scenario.interaction === 'skill-invocation-policy') {
terminal.send('/skill')
await settleTerminal(terminal)
const discovery = normalizeTerminalSnapshot(
await terminal.snapshot({ includeScrollback: true }),
cwd,
displayCwd,
)
expect(discovery).toContain('user-only-skill')
expect(discovery).not.toContain('model-only-skill')
terminal.send('\x03')
await settleTerminal(terminal)
const skillContext = ctx
const skillTurnEnded = new Promise<void>((resolve) => {
const detach = skillContext.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'turn/end') return
detach()
resolve()
})
})
terminal.send('/skill:user-only-skill')
terminal.send('\r')
await skillTurnEnded
await agent.whenIdle()
await settleTerminal(terminal)
const loaded = normalizeTerminalSnapshot(
await terminal.snapshot({ includeScrollback: true }),
cwd,
displayCwd,
)
expect(loaded).toContain('USER-ONLY SKILL LOADED')
terminal.send('/skill:model-only-skill')
terminal.send('\r')
await settleTerminal(terminal)
const denied = normalizeTerminalSnapshot(
await terminal.snapshot({ includeScrollback: true }),
cwd,
displayCwd,
)
expect(denied).toContain('model-only-skill')
expect(denied).toContain('not available for user invocation.')
expect(denied).not.toContain('MODEL-ONLY BODY MUST NOT LOAD')
interactionSnapshot = [
'=== skill autocomplete ===',
discovery,
'',
'=== loaded exact invocation ===',
loaded,
'',
'=== denied exact invocation ===',
denied,
].join('\n')
}
let remainingPrompts = prompts
if (scenario.enterPlanMode === true) {
const firstPrompt = prompts[0]!
@@ -392,7 +461,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
}
expect(terminal.themeViolations(), `${scenario.name} must remain theme-agnostic`).toEqual([])
const snapshot = normalizeTerminalSnapshot(
const snapshot = interactionSnapshot ?? normalizeTerminalSnapshot(
await terminal.snapshot({ includeScrollback: true }),
cwd,
displayCwd,