From 665c21693b927daef27b9e81b581d777909c7628 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:12:27 +0800 Subject: [PATCH 01/46] feat(tools): add persistent bash and str-replace editor --- ...rsistent-bash-str-replace-editor.i18n.yaml | 6 + ...7-29-persistent-bash-str-replace-editor.md | 33 ++ ...9-persistent-bash-str-replace-editor.zh.md | 33 ++ docs/config-catalog.md | 57 ++- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 12 + docs/tool-catalog.md | 95 +++++ packages/core/system-prompt/README.i18n.yaml | 6 +- packages/core/system-prompt/README.md | 5 +- packages/core/system-prompt/README.zh.md | 5 +- packages/core/system-prompt/src/index.ts | 15 +- .../system-prompt/tests/system-prompt.spec.ts | 12 + .../agent-spine-demo/README.i18n.yaml | 4 +- packages/examples/agent-spine-demo/README.md | 8 +- .../examples/agent-spine-demo/README.zh.md | 8 +- .../examples/agent-spine-demo/src/index.ts | 24 +- .../agent-spine-demo/tests/agent-core.spec.ts | 21 +- packages/fs/README.i18n.yaml | 6 +- packages/fs/README.md | 1 + packages/fs/README.zh.md | 1 + .../tool-str-replace-editor/README.i18n.yaml | 6 + packages/fs/tool-str-replace-editor/README.md | 54 +++ .../fs/tool-str-replace-editor/README.zh.md | 54 +++ .../fs/tool-str-replace-editor/package.json | 48 +++ .../fs/tool-str-replace-editor/src/index.ts | 403 ++++++++++++++++++ .../tool-str-replace-editor/src/invariant.ts | 30 ++ .../tests/tools.spec.ts | 313 ++++++++++++++ .../fs/tool-str-replace-editor/tsconfig.json | 14 + packages/pty/README.i18n.yaml | 6 +- packages/pty/README.md | 1 + packages/pty/README.zh.md | 1 + .../pty/tool-bash-persistent/README.i18n.yaml | 6 + packages/pty/tool-bash-persistent/README.md | 50 +++ .../pty/tool-bash-persistent/README.zh.md | 50 +++ .../pty/tool-bash-persistent/package.json | 55 +++ .../pty/tool-bash-persistent/src/index.ts | 380 +++++++++++++++++ .../pty/tool-bash-persistent/src/invariant.ts | 30 ++ .../tests/loader-composition.spec.ts | 156 +++++++ .../tool-bash-persistent/tests/tools.spec.ts | 385 +++++++++++++++++ .../pty/tool-bash-persistent/tsconfig.json | 16 + patches/node-pty@1.1.0.patch | 60 +++ pnpm-lock.yaml | 105 ++++- pnpm-workspace.yaml | 4 + python/README.i18n.yaml | 6 +- python/README.md | 2 +- python/README.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 6 +- python/sdk-runtime/README.md | 6 +- python/sdk-runtime/README.zh.md | 6 +- python/sdk-runtime/hatch_build.py | 16 +- python/sdk-runtime/package.json | 5 + .../src/deepseek_harness_runtime/__init__.py | 13 +- python/sdk/tests/test_release_version.py | 38 ++ scripts/build-exe-for-python-sdk.ts | 84 +++- scripts/build-python-release.py | 29 +- scripts/gen-tool-catalog.ts | 28 ++ scripts/smoke-python-runtime.py | 145 ++++++- tsconfig.host.json | 2 + 59 files changed, 2880 insertions(+), 91 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md create mode 100644 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md create mode 100644 packages/fs/tool-str-replace-editor/README.i18n.yaml create mode 100644 packages/fs/tool-str-replace-editor/README.md create mode 100644 packages/fs/tool-str-replace-editor/README.zh.md create mode 100644 packages/fs/tool-str-replace-editor/package.json create mode 100644 packages/fs/tool-str-replace-editor/src/index.ts create mode 100644 packages/fs/tool-str-replace-editor/src/invariant.ts create mode 100644 packages/fs/tool-str-replace-editor/tests/tools.spec.ts create mode 100644 packages/fs/tool-str-replace-editor/tsconfig.json create mode 100644 packages/pty/tool-bash-persistent/README.i18n.yaml create mode 100644 packages/pty/tool-bash-persistent/README.md create mode 100644 packages/pty/tool-bash-persistent/README.zh.md create mode 100644 packages/pty/tool-bash-persistent/package.json create mode 100644 packages/pty/tool-bash-persistent/src/index.ts create mode 100644 packages/pty/tool-bash-persistent/src/invariant.ts create mode 100644 packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts create mode 100644 packages/pty/tool-bash-persistent/tests/tools.spec.ts create mode 100644 packages/pty/tool-bash-persistent/tsconfig.json create mode 100644 patches/node-pty@1.1.0.patch diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml new file mode 100644 index 0000000000..e3b6121e19 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +2026-07-29-persistent-bash-str-replace-editor.md: 286a53c1c686cc515b65119ed4b1a01a57b0614b +2026-07-29-persistent-bash-str-replace-editor.zh.md: d2417708c8a1334e9f8930481f4218cbefc5a87b diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md new file mode 100644 index 0000000000..286a53c1c6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -0,0 +1,33 @@ +# Agent Note: Persistent Bash and string-replacement editor tools + +Status: implemented + +English | [中文](2026-07-29-persistent-bash-str-replace-editor.zh.md) + +## Problem + +Some deployments need a one-call Bash schema whose shell state survives across model turns, while others need a Claude-style `str_replace_editor` independent of their terminal choice. Bundling the two tools or naming them after one benchmark would prevent reuse and blur configuration ownership. + +## Decision + +`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. + +`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. The public schema and failures use only `old_str`; canonical mode requires absolute paths and expands tabs before mutations. Deployments with an intentional session-cwd contract can disable the absolute-path requirement. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. + +`dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. + +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present (or when `DSH_NODE_PTY_SPAWN_HELPER` explicitly selects one), preserving upstream lookup in ordinary Node runs; the executable and runtime-wheel builders fail before publication when the helper is absent, mismatched, or not executable. + +## Alternatives considered + +**One combined compatibility plugin.** Rejected because neither tool requires the other and the combined name would tie reusable capabilities to one benchmark. + +**Reuse one-shot Bash.** Rejected because `bash -c` cannot preserve cwd or environment state across calls. + +**Expose terminal management tools.** Rejected because open/send/read/close is a different model action space from one persistent `bash` call. + +**Modify native read/write/edit.** Rejected because it would distort their general-purpose contracts instead of adding an independently composable editor. + +## Consequences + +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation, but the wheel now contains a main executable plus its private native helper rather than one physical file. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md new file mode 100644 index 0000000000..d2417708c8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -0,0 +1,33 @@ +# Agent Note:持久 Bash 与字符串替换编辑器工具 + +状态:已实现 + +[English](2026-07-29-persistent-bash-str-replace-editor.md) | 中文 + +## 问题 + +部分部署需要只调用一次的 Bash schema,同时要求 shell 状态跨模型轮次保留;另一些部署需要与终端选择无关的 Claude 风格 `str_replace_editor`。把两个工具绑在一起或按某个基准命名,会阻碍复用并模糊配置归属。 + +## 决策 + +`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 + +`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。公开 schema 与错误只使用 `old_str`;规范模式要求绝对路径,并在变更前展开制表符。有明确 session-cwd 契约的部署可以关闭绝对路径要求。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 + +`dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 + +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它(也可由 `DSH_NODE_PTY_SPAWN_HELPER` 显式指定),普通 Node 运行仍保留上游查找方式;若 helper 缺失、架构不匹配或不可执行,可执行文件与 runtime wheel 构建会在发布前失败。 + +## 考虑过的替代方案 + +**单一组合兼容插件。** 被拒绝,因为两个工具互不依赖,组合命名还会把可复用能力绑定到某个基准。 + +**复用一次性 Bash。** 被拒绝,因为 `bash -c` 无法跨调用保留 cwd 或环境状态。 + +**暴露终端管理工具。** 被拒绝,因为 open/send/read/close 与单个持久 `bash` 调用是不同的模型动作空间。 + +**修改原生 read/write/edit。** 被拒绝,因为这会扭曲其通用契约,而不是增加一个可独立组合的编辑器。 + +## 后果 + +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。runtime wheel 的使用者仍不需要安装 Node,但 wheel 现在包含主可执行文件及其私有原生 helper,而不是单个物理文件。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 68343a6a19..585ea18b84 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -116,9 +116,9 @@ Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loo /** * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP - * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt - * plugin (the deployment's persona section and the explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * bridge, simply omits it), `includeHarnessIdentity`, `persona`, and `toolOrder` + * to the system-prompt plugin (the fixed opener, deployment persona, and explicit + * model-facing tool order), the `tools` object to the tool registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -131,13 +131,16 @@ Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loo * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their - * own config. + * own config. Set `toolBash: false` when another plugin owns the model-facing + * `bash` name. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] /** Agent-loop concurrency cap; `1` is serial. */ maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] + /** Whether the system prompt includes the fixed Harness identity (default true). */ + includeHarnessIdentity?: SystemPromptConfig['includeHarnessIdentity'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -152,8 +155,8 @@ export interface Config { workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig - /** Model-facing bash tool config, including this producer's background opt-in. */ - toolBash?: toolBash.Config + /** Model-facing bash tool config, or false when another plugin owns `bash`. */ + toolBash?: toolBash.Config | false /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -185,7 +188,7 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:88`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:89`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -1509,6 +1512,8 @@ Source: [`packages/subagent/subagent-spawn/src/index.ts:20`](../packages/subagen ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */ export interface Config { + /** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */ + includeHarnessIdentity?: boolean /** * Deployment-wide order-0 persona template. A scoped section named * `deployment:persona` shadows it; `{{variable}}` references are strict. @@ -1566,6 +1571,26 @@ export interface Config { Source: [`packages/bash/tool-bash/src/index.ts:41`](../packages/bash/tool-bash/src/index.ts) +## `@deepseek-ai/dsh-tool-bash-persistent` + +Requires: `tools` · `pty` + +```ts config-catalog +/** Configuration for the persistent Bash tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +Source: [`packages/pty/tool-bash-persistent/src/index.ts:340`](../packages/pty/tool-bash-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-cordis` Requires: `tools` @@ -1724,6 +1749,24 @@ export interface Config { Source: [`packages/skill/tool-skill/src/index.ts:21`](../packages/skill/tool-skill/src/index.ts) +## `@deepseek-ai/dsh-tool-str-replace-editor` + +Requires: `tools` · `fs` + +```ts config-catalog +/** Configuration for the string-replacement editor tool. */ +export interface Config { + /** Maximum returned view characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description. */ + description?: string + /** Require local absolute paths like the canonical editor contract (default true). */ + requireAbsolutePath?: boolean +} +``` + +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:373`](../packages/fs/tool-str-replace-editor/src/index.ts) + ## `@deepseek-ai/dsh-tool-subagent` Requires: `tools` · `subagents` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e0dd17fa02..c564cf53a5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1870,7 +1870,7 @@ async assemble(context: AssembleContext = {}): Promise Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md) -Source: [`packages/core/system-prompt/src/index.ts:246`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:248`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tasks` — `TaskService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9d66b52bfb..919f891685 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 703f4c8abf..b8fe4a23c7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -48,6 +48,7 @@ flowchart TD pkg_fs_sandbox["fs-sandbox"] pkg_tool_fs["tool-fs"] pkg_tool_fs_search["tool-fs-search"] + pkg_tool_str_replace_editor["tool-str-replace-editor"] end subgraph group_skill["packages/skill"] pkg_skill["skill"] @@ -202,6 +203,7 @@ flowchart TD subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] + pkg_tool_bash_persistent["tool-bash-persistent"] pkg_tool_pty["tool-pty"] end subgraph group_sandbox["packages/sandbox"] @@ -663,6 +665,9 @@ flowchart TD pkg_tool_fs_search --> pkg_spill pkg_tool_fs_search --> pkg_system_prompt pkg_tool_fs_search --> pkg_tools + pkg_tool_str_replace_editor --> pkg_fs + pkg_tool_str_replace_editor --> pkg_invariants + pkg_tool_str_replace_editor --> pkg_tools pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_llm @@ -777,6 +782,11 @@ flowchart TD pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_subprocess pkg_mcp_client --> pkg_tools + pkg_tool_bash_persistent --> pkg_agent + pkg_tool_bash_persistent --> pkg_invariants + pkg_tool_bash_persistent --> pkg_pty + pkg_tool_bash_persistent --> pkg_timeout + pkg_tool_bash_persistent --> pkg_tools pkg_tool_pty --> pkg_agent pkg_tool_pty --> pkg_invariants pkg_tool_pty --> pkg_llm @@ -1077,6 +1087,7 @@ flowchart TD | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | @@ -1098,6 +1109,7 @@ flowchart TD | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | +| [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 995a8c669d..bed13d1a2d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,6 +20,8 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | +| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | +| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | @@ -279,6 +281,99 @@ Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cor Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. +## `@deepseek-ai/dsh-tool-bash-persistent` + +### `bash` + +Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +Source: [`packages/pty/tool-bash-persistent/src/index.ts`](../packages/pty/tool-bash-persistent/src/index.ts) + +One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. + +## `@deepseek-ai/dsh-tool-str-replace-editor` + +### `str_replace_editor` + +Custom editing tool for viewing, creating and editing files +* State is persistent across command calls and discussions with the user +* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep +* The `create` command cannot be used if the specified `path` already exists as a file +* If a `command` generates a long output, it will be truncated and marked with `` + +Notes for using the `str_replace` command: +* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces! +* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique +* The `new_str` parameter should contain the edited lines that should replace the `old_str` + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] +} +``` + +Source: [`packages/fs/tool-str-replace-editor/src/index.ts`](../packages/fs/tool-str-replace-editor/src/index.ts) + +Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. + ## `@deepseek-ai/dsh-tool-fs` ### `edit` diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index 9d82f19bfd..cea643baa7 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 79badba0b84b27c01f25e9c31b5df78c556411ea -README.zh.md: 1d983e44721dbc637efc10824065aa3b88087e1d +# pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md +README.md: 23bc0e8177ad2a778df9522e254bfd5e03a9871f +README.zh.md: 94c4cef4e289b94e7db79a8ad4815f7d4038cf80 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 79badba0b8..23bc0e8177 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -8,6 +8,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem | Key | Default | Meaning | |---|---|---| +| `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by the DeepSeek Harness SDK.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. | | `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | | `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). | @@ -48,7 +49,7 @@ Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/imple #### What the model sees -Every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. +By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener for a deployment that owns the complete compatibility persona. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. ##### Harness identity @@ -58,7 +59,7 @@ You are an AI agent powered by the DeepSeek Harness SDK. #### Token effect -Identity is a fixed per-request cost. Persona and plugin text are repeated per request and scale with their rendered content. +Identity is a fixed per-request cost when enabled. Persona and plugin text are repeated per request and scale with their rendered content. #### KV Cache effect diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 1d983e4472..94c4cef4e2 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -8,6 +8,7 @@ | 键 | 默认值 | 含义 | |---|---|---| +| `includeHarnessIdentity` | `true` | 是否包含固定的 `You are an AI agent powered by the DeepSeek Harness SDK.`、顺序为 −100 的开场白。仅当兼容部署拥有完整系统提示词时设为 false。 | | `persona` | `''` | 全局部署 persona 默认值:唯一由配置创作的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(已交付循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 | | `toolOrder` | 无 | 显式的面向模型工具顺序:一个 `ToolSchema.name` 列表,包含一个 `''` 其余项(`TOOL_ORDER_REST`)。已列工具占据列出的位置;未列工具按名称字典序落在其余项位置。缺席 ⇒ 直接按名称字典序排列。在 `system-prompt/assemble` waterfall 之前应用于已收集工具;与段的 `order` 排序一样,它会规范化注册表贡献的内容(注册顺序是插件加载工件),而修改列表的 waterfall 监听器拥有其输出的确定性。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在已交付循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md)。 | @@ -48,7 +49,7 @@ #### 模型所见 -每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。 +默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅为拥有完整兼容 persona 的部署省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。 ##### Harness 身份 @@ -58,7 +59,7 @@ You are an AI agent powered by the DeepSeek Harness SDK. #### Token 影响 -身份是每次请求的固定成本。Persona 与插件文本在每次请求中重复,成本随渲染内容增长。 +启用时,身份是每次请求的固定成本。Persona 与插件文本在每次请求中重复,成本随渲染内容增长。 #### KV Cache 影响 diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index c46c38515c..2e4e5e65b7 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -145,6 +145,8 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number { /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */ export interface Config { + /** Include the fixed DeepSeek Harness identity before the deployment persona (default true). */ + includeHarnessIdentity?: boolean /** * Deployment-wide order-0 persona template. A scoped section named * `deployment:persona` shadows it; `{{variable}}` references are strict. @@ -245,6 +247,7 @@ class PromptLayer implements ScopeLayer { /** Registry service for the prompt inputs assembled before each model step. */ export class SystemPrompt extends Service { static Config: z = z.object({ + includeHarnessIdentity: z.boolean().default(true), persona: z.string().default(''), // Preserve omission because an explicit empty order lacks the rest marker. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), @@ -260,11 +263,13 @@ export class SystemPrompt extends Service { super(ctx, 'systemPrompt') this.toolOrder = validateToolOrder(config.toolOrder) // Keep harness-owned openers independent of the selected loop plugin. - this.section({ - name: 'harness:identity', - order: -100, - text: 'You are an AI agent powered by the DeepSeek Harness SDK.', - }) + if (config.includeHarnessIdentity ?? true) { + this.section({ + name: 'harness:identity', + order: -100, + text: 'You are an AI agent powered by the DeepSeek Harness SDK.', + }) + } this.section({ name: 'deployment:persona', order: 0, diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index d4fdbdd684..02ac58889d 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -37,6 +37,18 @@ describe('SystemPrompt', () => { expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(IDENTITY) }) + it('can omit the harness identity for a deployment that owns the complete persona', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, { + includeHarnessIdentity: false, + persona: 'You are a helpful software engineer assistant.', + }) + + const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.map(section => section.name)).toEqual(['deployment:persona']) + expect(renderPrompt(assembly)).toBe('You are a helpful software engineer assistant.') + }) + it('tolerates a schema-bypassing direct construction (persona omitted)', async () => { // ctx.plugin validates + defaults the config first; a direct construction // skips the schema, so the ctor's `?? ''` narrowing is what fires. diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index a7c71523b2..fd6d54918e 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -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 packages/examples/agent-spine-demo/README.md -README.md: 359e7153be2f480ba3fea4b06782acdc9f89ebb9 -README.zh.md: 57fec3f32f5bbc8f3d82ff8971d36d376d722753 +README.md: 6bbd99217bcdce0e8a0e8fd22a8d39d0c64224b9 +README.zh.md: b02b035685f1902dc9b2bd1056606495e212aa4f diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 359e7153be..6bbd99217b 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -31,7 +31,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-scope/invariant @deepseek-ai/dsh-agent-loop/invariant package-owned relational checks -@deepseek-ai/dsh-tool-bash the model-facing bash schema +@deepseek-ai/dsh-tool-bash the model-facing bash schema (unless toolBash=false) @deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader @deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema @deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices @@ -55,11 +55,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `includeHarnessIdentity`, `persona`, and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It always mounts `dsh-llm-retry`, while each leaf adapter owns its nested `retryPolicy`. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, set `toolBash: false` when another plugin owns the `bash` tool name, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bundled bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. @@ -79,5 +79,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit bundled goals, skills, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. +- **Most of the spine set is fixed in code** — `apply()` always mounts the core services; config can omit bundled goals, skills, bash, and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. - **The invariant seam and companions remain fixed members** — `invariants.enabled: false` or package filters suppress checks but do not remove the service or companion registrations; Session's always-on validation and freezing are separate. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 57fec3f32f..b02b035685 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -31,7 +31,7 @@ @deepseek-ai/dsh-scope/invariant @deepseek-ai/dsh-agent-loop/invariant package-owned relational checks -@deepseek-ai/dsh-tool-bash the model-facing bash schema +@deepseek-ai/dsh-tool-bash the model-facing bash schema (unless toolBash=false) @deepseek-ai/dsh-workspace-context AGENTS.md/CLAUDE.md workspace context loader @deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema @deepseek-ai/dsh-tool-tasks task_output/task_list/task_kill schemas + completion notices @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 +组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`includeHarnessIdentity`、`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;当另一个插件拥有 `bash` 工具名时设置 `toolBash: false`;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制内置 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 @@ -79,5 +79,5 @@ YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默 ## 已知限制与延后工作 -- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务与 `tool-bash`;配置可以省略组合包内的目标、skill 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个 bundle。 +- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务;配置可以省略组合包内的目标、skill、bash 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个 bundle。 - **不变式 seam 与配套插件仍是固定成员**:`invariants.enabled: false` 或包筛选器会抑制检查,但不会移除服务或配套插件注册;Session 始终启用的校验与冻结是另一套机制。 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 92434f91da..cfa5ac3ccd 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -68,9 +68,9 @@ export interface GoalConfig { /** * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP - * bridge, simply omits it), `persona` and `toolOrder` to the system-prompt - * plugin (the deployment's persona section and the explicit model-facing tool - * order), the `tools` object to the tool registry (its presentation `mode`), + * bridge, simply omits it), `includeHarnessIdentity`, `persona`, and `toolOrder` + * to the system-prompt plugin (the fixed opener, deployment persona, and explicit + * model-facing tool order), the `tools` object to the tool registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the @@ -83,13 +83,16 @@ export interface GoalConfig { * workspace context instead requires an explicit byte budget or `false` because * it changes model-visible input. Producer opt-in stays producer-local: * `toolBash` configures bash only; independently composed producers keep their - * own config. + * own config. Set `toolBash: false` when another plugin owns the model-facing + * `bash` name. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] /** Agent-loop concurrency cap; `1` is serial. */ maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] + /** Whether the system prompt includes the fixed Harness identity (default true). */ + includeHarnessIdentity?: SystemPromptConfig['includeHarnessIdentity'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -104,8 +107,8 @@ export interface Config { workspaceContext: workspaceContext.Config | false /** Skill registry, local provider, and model-facing consumer config. */ skills?: SkillConfig - /** Model-facing bash tool config, including this producer's background opt-in. */ - toolBash?: toolBash.Config + /** Model-facing bash tool config, or false when another plugin owns `bash`. */ + toolBash?: toolBash.Config | false /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -127,7 +130,8 @@ export const SessionTitleConfigSchema: z = SessionTitleServi .default(EXAMPLE_SESSION_TITLE_CONFIG) /** The bash-tool config schema exported for app packages that forward `toolBash`. */ -export const ToolBashConfigSchema: z = toolBash.Config +export const ToolBashConfigSchema: z = + z.union([z.const(false), toolBash.Config]) /** The task-control-tool config schema exported for app packages that forward `toolTasks`. */ export const ToolTasksConfigSchema: z = toolTasks.Config @@ -163,6 +167,7 @@ export const Config = z.intersect([ export function pickSpineConfig(config: Omit): Omit { return { ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, + ...config.includeHarnessIdentity !== undefined ? { includeHarnessIdentity: config.includeHarnessIdentity } : {}, ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, ...config.tools !== undefined ? { tools: config.tools } : {}, @@ -201,6 +206,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SessionTitleService, config.sessionTitle ?? EXAMPLE_SESSION_TITLE_CONFIG) // Owner schemas resolve defaults; forward toolOrder only when explicitly set. ctx.plugin(SystemPrompt, { + includeHarnessIdentity: config.includeHarnessIdentity ?? true, persona: config.persona ?? '', ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, }) @@ -223,7 +229,9 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(agentInvariant) ctx.plugin(scopeInvariant) ctx.plugin(agentLoopInvariant) - ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome })) + if (config.toolBash !== false) { + ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome })) + } if (config.workspaceContext !== false) { ctx.plugin(workspaceContext, config.workspaceContext) } diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 5a6f89525c..f32e04acf8 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' +import { renderPrompt, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' @@ -515,9 +515,27 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('can omit the bundled bash tool and Harness identity for a compatibility deployment', async () => { + const ctx = await mount({ + includeHarnessIdentity: false, + persona: 'You are a helpful software engineer assistant.', + workspaceContext: false, + skills: { enabled: false }, + toolBash: false, + toolTasks: false, + }, true) + + expect(ctx.tools.schemas()).toEqual([]) + expect(renderPrompt(await ctx.systemPrompt.assemble())) + .toBe('You are a helpful software engineer assistant.') + + await ctx.fiber.dispose() + }) + it('picks shared spine config without leaking front-door fields', () => { const appConfig = { model: 'front-door-only', + includeHarnessIdentity: false, persona: 'You are merged.', toolOrder: ['zulu'], tools: { mode: 'native' as const }, @@ -531,6 +549,7 @@ describe('dsh-agent-spine-demo bundle', () => { } expect(agentCore.pickSpineConfig(appConfig)).toEqual({ + includeHarnessIdentity: appConfig.includeHarnessIdentity, persona: appConfig.persona, toolOrder: appConfig.toolOrder, tools: appConfig.tools, diff --git a/packages/fs/README.i18n.yaml b/packages/fs/README.i18n.yaml index 1ead814be1..ecfa2e134a 100644 --- a/packages/fs/README.i18n.yaml +++ b/packages/fs/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 4d954455ea920be4882530bcfe90b48a364c29b5 -README.zh.md: e818210abaded987edbb8bf38c6d1b43d40ad9c7 +# pnpm run verify-translation-pairing --write packages/fs/README.md +README.md: b5e0ac9d1c0c550eb372b8a66fc6358711fddc07 +README.zh.md: c5c00f76715f97b43c673b85b3e9055a500d1b5a diff --git a/packages/fs/README.md b/packages/fs/README.md index 4d954455ea..b5e0ac9d1c 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -12,6 +12,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona | `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) | | `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) | +| `tool-str-replace-editor/` | Model-facing `str_replace_editor` with view/create/unique literal replace/line insert operations over `ctx.fs` | (registers on `ctx.tools`) | The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents). diff --git a/packages/fs/README.zh.md b/packages/fs/README.zh.md index e818210aba..c5c00f7671 100644 --- a/packages/fs/README.zh.md +++ b/packages/fs/README.zh.md @@ -12,6 +12,7 @@ | `fs-policy/` | 政策门禁插件:通过 `fs/*` 事件门禁提供已观察状态、编辑前读取和版本防护的写入/编辑 | (无服务,仅有 `fs/*` 监听器) | | `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升级字段 | (注册到 `ctx.tools`) | | `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools`) | +| `tool-str-replace-editor/` | 基于 `ctx.fs` 提供查看/创建/唯一字面量替换/按行插入的模型可见 `str_replace_editor` | (注册到 `ctx.tools`) | 接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、政策门禁或面向模型的工具 schema;`fs-sandbox` 是第一个这样的替代实现(基于共享沙箱模式的进程内路径围栏;见[跨能力族 fs 沙箱 Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md))。政策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam:搜索是在 bash 执行器上运行 `rg`、由进程支持的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。 diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml new file mode 100644 index 0000000000..1f72b1a211 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/fs/tool-str-replace-editor/README.md +README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b +README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md new file mode 100644 index 0000000000..2d98b51d56 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/README.md @@ -0,0 +1,54 @@ +# @deepseek-ai/dsh-tool-str-replace-editor + +English | [中文](README.zh.md) + +Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed with persistent Bash, one-shot Bash, sandboxed Bash, or another terminal surface. + +## Config + +| Key | Default | Meaning | +|---|---:|---| +| `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. | +| `description` | Editor command guide | Model-facing tool description. | +| `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. | + +## Tool + +The schema provides `view`, `create`, `str_replace`, and `insert`. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. + +## Model Experience + +### Tool schema + +#### What the model sees + +The generated [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor), including the configured `description`. The plugin contributes no standalone system-prompt section. + +#### Token effect + +Fixed schema cost while `str_replace_editor` is visible. + +#### KV Cache effect + +Prefix-stable while the configured description and schema remain unchanged. + +### Tool results + +#### What the model sees + +Views return numbered text or a shallow directory listing. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice. + +#### Token effect + +Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice. + +#### KV Cache effect + +Append-only tool results follow the reusable request prefix. + +## Known Limitations and Deferred Work + +- Operations target UTF-8 text; binary files are unsupported. +- `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument. +- Canonical mode expands tabs before replacement or insertion, matching the reference string-replacement editor. +- The package delegates security and read-before-edit policy to the mounted filesystem and policy plugins. diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md new file mode 100644 index 0000000000..a2ee8f1e36 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -0,0 +1,54 @@ +# @deepseek-ai/dsh-tool-str-replace-editor + +[English](README.md) | 中文 + +基于 `ctx.fs` 的独立模型可见 `str_replace_editor`。它可与持久 Bash、一次性 Bash、沙箱 Bash 或其他终端表面组合。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 | +| `description` | 编辑器命令指南 | 面向模型的工具描述。 | +| `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 | + +## 工具 + +Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。 + +## 模型体验 + +### 工具 schema + +#### 模型所见 + +生成的 [`str_replace_editor` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-str-replace-editor),其中包含配置的 `description`。本插件不贡献独立系统提示词段。 + +#### Token 影响 + +`str_replace_editor` 可见时产生固定的 schema 成本。 + +#### KV Cache 影响 + +配置的描述与 schema 不变时前缀稳定。 + +### 工具结果 + +#### 模型所见 + +查看操作返回带行号文本或浅层目录列表。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。 + +#### Token 影响 + +随数据变化,并受 `maxOutputChars` 与固定截断提示约束。 + +#### KV Cache 影响 + +工具结果以追加方式位于可复用请求前缀之后。 + +## 已知限制与延后工作 + +- 操作面向 UTF-8 文本,不支持二进制文件。 +- `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。 +- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。 +- 安全与先读后改策略委托给挂载的文件系统和策略插件。 diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json new file mode 100644 index 0000000000..88d3af8d53 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-tool-str-replace-editor", + "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts new file mode 100644 index 0000000000..c4a16e5437 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -0,0 +1,403 @@ +/** + * Model-facing `str_replace_editor` over the Harness filesystem seam. + * @module @deepseek-ai/dsh-tool-str-replace-editor + */ + +import { isAbsolute } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' + +const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' + +const DEFAULT_DESCRIPTION = ` +Custom editing tool for viewing, creating and editing files +* State is persistent across command calls and discussions with the user +* If \`path\` is a file, \`view\` displays the result of applying \`cat -n\`. If \`path\` is a directory, \`view\` lists non-hidden files and directories up to 2 levels deep +* The \`create\` command cannot be used if the specified \`path\` already exists as a file +* If a \`command\` generates a long output, it will be truncated and marked with \`\` + +Notes for using the \`str_replace\` command: +* The \`old_str\` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces! +* If the \`old_str\` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in \`old_str\` to make it unique +* The \`new_str\` parameter should contain the edited lines that should replace the \`old_str\` +`.trim() + +function maybeTruncate(content: string, maxOutputChars: number): string { + return content.length <= maxOutputChars + ? content + : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE +} + +function expandTabs(content: string, tabSize = 8): string { + let column = 0 + let result = '' + for (const character of content) { + if (character === '\t') { + const spaces = tabSize - (column % tabSize) + result += ' '.repeat(spaces) + column += spaces + continue + } + result += character + if (character === '\n' || character === '\r') column = 0 + else column += 1 + } + return result +} + +async function resolveTarget( + ctx: Context, + path: string, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + if (path.trim().length === 0) throw new Error('path must be a non-empty string') + if (requireAbsolutePath && !isAbsolute(path)) { + throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) + } + const cwd = exec.agent?.session.header.cwd + return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal }) +} + +async function statExisting( + ctx: Context, + target: FsTarget, + command: 'view' | 'str_replace' | 'insert', + exec: ToolRunContext, +): Promise { + const info = await ctx.fs.stat(target, exec.signal) + if (info === undefined) { + throw new FsError( + `The path ${target.displayPath} does not exist. Please provide a valid path.`, + 'FS_NOT_FOUND', + ) + } + if (info.type === 'directory' && command !== 'view') { + throw new FsError( + `The path ${target.displayPath} is a directory and only the \`view\` command can be used on directories`, + 'FS_NOT_REGULAR_FILE', + ) + } + return info +} + +function requiredForCommand( + value: string | undefined, + parameter: string, + command: string, + allowEmpty = true, +): string { + if (value === undefined) throw new Error(`Parameter \`${parameter}\` is required for command: ${command}`) + if (!allowEmpty && value.length === 0) { + throw new Error(`Parameter \`${parameter}\` is empty for command: ${command}`) + } + return value +} + +function formatFileView( + path: string, + content: string, + maxOutputChars: number, + viewRange?: number[], +): string { + const allLines = content.split('\n') + let lines = allLines + let initialLine = 1 + let finalLine: number | undefined + let prompt = `Here's the content of ${path} with line numbers (which has a total of ${allLines.length} lines)` + if (viewRange !== undefined) { + const [requestedInitialLine, requestedFinalLine] = viewRange + if ( + viewRange.length !== 2 + || requestedInitialLine === undefined + || requestedFinalLine === undefined + || !viewRange.every(Number.isInteger) + ) { + throw new Error('Invalid `view_range`. It should be a list of two integers.') + } + initialLine = requestedInitialLine + finalLine = requestedFinalLine + if (initialLine < 1 || initialLine > allLines.length) { + throw new Error( + `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its first element \`${initialLine}\` should be within the range of lines of the file: [1, ${allLines.length}]`, + ) + } + if (finalLine > allLines.length) { + throw new Error( + `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be smaller than the number of lines in the file: \`${allLines.length}\``, + ) + } + if (finalLine !== -1 && finalLine < initialLine) { + throw new Error( + `Invalid \`view_range\`: [${viewRange.join(', ')}]. Its second element \`${finalLine}\` should be larger or equal than its first \`${initialLine}\``, + ) + } + lines = finalLine === -1 + ? allLines.slice(initialLine - 1) + : allLines.slice(initialLine - 1, finalLine) + prompt += ` with view_range=[${initialLine}, ${finalLine}]` + } + const numbered = expandTabs(lines + .map((line, index) => `${String(initialLine + index).padStart(6, ' ')}\t${line}`) + .join('\n')) + return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars) +} + +async function listDirectory( + ctx: Context, + target: FsTarget, + maxOutputChars: number, + exec: ToolRunContext, +): Promise { + async function visit(dir: FsTarget, depth: number): Promise { + const entries = await ctx.fs.listDir(dir, exec.signal) + const rows: string[] = [] + for (const entry of entries.filter(candidate => + !candidate.name.startsWith('.') + && !candidate.name.startsWith('node_modules') + && !candidate.name.startsWith('__pycache__'))) { + const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?' + rows.push(`${type}\t${entry.target.displayPath}`) + if (entry.type === 'directory' && depth < 2) { + rows.push(...await visit(entry.target, depth + 1)) + } + } + return rows + } + const rows = [`d\t${target.displayPath}`, ...await visit(target, 1)] + rows.sort((left, right) => { + const leftPath = left.slice(left.indexOf('\t') + 1) + const rightPath = right.slice(right.indexOf('\t') + 1) + return leftPath.localeCompare(rightPath) + }) + const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars) + return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n` +} + +async function viewPath( + ctx: Context, + path: string, + viewRange: number[] | undefined, + maxOutputChars: number, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const info = await statExisting(ctx, target, 'view', exec) + if (info.type === 'directory') { + if (viewRange !== undefined) { + throw new Error('The `view_range` parameter is not allowed when `path` points to a directory.') + } + return listDirectory(ctx, target, maxOutputChars, exec) + } + if (info.type !== 'file') { + throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, 'FS_NOT_REGULAR_FILE') + } + const content = await ctx.fs.readText(target, exec.signal) + ctx.emit('fs/observed', target, info.version, exec) + return formatFileView(target.displayPath, content, maxOutputChars, viewRange) +} + +async function createFile( + ctx: Context, + path: string, + fileText: string | undefined, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + const content = requiredForCommand(fileText, 'file_text', 'create') + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + if (await ctx.fs.stat(target, exec.signal) !== undefined) { + throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) + } + const outcome = await ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, exec.signal) + ctx.emit('fs/observed', target, outcome.version, exec) + return `New file created successfully at: ${target.displayPath}` +} + +async function replaceInFile( + ctx: Context, + path: string, + oldStr: string | undefined, + newStr: string | undefined, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const oldValue = expandTabs(requiredForCommand(oldStr, 'old_str', 'str_replace', false)) + const newValue = expandTabs(newStr ?? '') + const info = await statExisting(ctx, target, 'str_replace', exec) + if (info.type !== 'file') { + throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + const before = expandTabs(await ctx.fs.readText(target, exec.signal)) + const occurrences = before.split(oldValue).length - 1 + if (occurrences === 0) { + throw new FsError( + `No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`, + 'FS_EDIT_NOT_FOUND', + ) + } + if (occurrences > 1) { + const lines = before.split('\n') + .flatMap((line, index) => line.includes(oldValue) ? [index + 1] : []) + throw new FsError( + `No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`, + 'FS_AMBIGUOUS_EDIT', + ) + } + const outcome = await ctx.fs.writeText( + target, + before.replace(oldValue, newValue), + { kind: 'replaceIfVersion', version: info.version }, + exec.signal, + ) + ctx.emit('fs/observed', target, outcome.version, exec) + return `The file ${target.displayPath} has been edited successfully.` +} + +async function insertInFile( + ctx: Context, + path: string, + insertLine: number | undefined, + newStr: string | undefined, + requireAbsolutePath: boolean, + exec: ToolRunContext, +): Promise { + if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') + const value = expandTabs(requiredForCommand(newStr, 'new_str', 'insert')) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const info = await statExisting(ctx, target, 'insert', exec) + if (info.type !== 'file') { + throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + const before = expandTabs(await ctx.fs.readText(target, exec.signal)) + const lines = before.split('\n') + if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { + throw new Error( + `Invalid \`insert_line\` parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`, + ) + } + const after = [ + ...lines.slice(0, insertLine), + ...value.split('\n'), + ...lines.slice(insertLine), + ].join('\n') + const outcome = await ctx.fs.writeText( + target, + after, + { kind: 'replaceIfVersion', version: info.version }, + exec.signal, + ) + ctx.emit('fs/observed', target, outcome.version, exec) + return `The file ${target.displayPath} has been edited successfully.` +} + +interface ResolvedConfig { + maxOutputChars: number + description: string + requireAbsolutePath: boolean +} + +/** Register the model-facing `str_replace_editor` tool. */ +function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { + ctx.tools.register(defineTool({ + name: 'str_replace_editor', + description: config.description, + parameters: { + command: { + type: 'string', + required: true, + enum: ['view', 'create', 'str_replace', 'insert'], + description: 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.', + }, + path: { + type: 'string', + required: true, + description: 'Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.', + }, + file_text: { + type: 'string', + description: 'Required parameter of `create` command, with the content of the file to be created.', + }, + insert_line: { + type: 'integer', + description: 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.', + }, + new_str: { + type: 'string', + description: 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.', + }, + old_str: { + type: 'string', + description: 'Required parameter of `str_replace` command containing the string in `path` to replace.', + }, + view_range: { + type: 'array', + items: { type: 'integer' }, + description: 'Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args, exec) { + switch (args.command) { + case 'view': + return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec) + case 'create': + return createFile(ctx, args.path, args.file_text, config.requireAbsolutePath, exec) + case 'str_replace': + return replaceInFile(ctx, args.path, args.old_str, args.new_str, config.requireAbsolutePath, exec) + case 'insert': + return insertInFile(ctx, args.path, args.insert_line, args.new_str, config.requireAbsolutePath, exec) + } + }, + presentCall: args => ({ + card: 'generic', + title: `${args.command} ${args.path}`, + kind: args.command === 'view' ? 'read' : 'edit', + }), + })) +} + +export const name = 'tool-str-replace-editor' +export const inject = ['tools', 'fs'] + +/** Configuration for the string-replacement editor tool. */ +export interface Config { + /** Maximum returned view characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description. */ + description?: string + /** Require local absolute paths like the canonical editor contract (default true). */ + requireAbsolutePath?: boolean +} + +/** Runtime configuration schema for the string-replacement editor tool. */ +export const Config: z = z.object({ + maxOutputChars: z.number().default(16_000), + description: z.string().default(DEFAULT_DESCRIPTION), + requireAbsolutePath: z.boolean().default(true), +}) + +/** Register one `str_replace_editor` tool over `ctx.fs`. */ +export function apply(ctx: Context, config: Config): void { + const resolved: ResolvedConfig = { + maxOutputChars: config.maxOutputChars ?? 16_000, + description: config.description ?? DEFAULT_DESCRIPTION, + requireAbsolutePath: config.requireAbsolutePath ?? true, + } + if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { + throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') + } + if (resolved.description.trim().length === 0) { + throw new Error('tool-str-replace-editor: description must be non-empty') + } + registerStrReplaceEditor(ctx, resolved) +} diff --git a/packages/fs/tool-str-replace-editor/src/invariant.ts b/packages/fs/tool-str-replace-editor/src/invariant.ts new file mode 100644 index 0000000000..99547c02ee --- /dev/null +++ b/packages/fs/tool-str-replace-editor/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-str-replace-editor`. + * @module @deepseek-ai/dsh-tool-str-replace-editor/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-str-replace-editor' + +/** Cordis companion plugin name. */ +export const name = 'tool-str-replace-editor-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the tool adapter owns no independent durable state; + * filesystem mutation relations stay with the provider and policy plugins. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts new file mode 100644 index 0000000000..7cf9ba5212 --- /dev/null +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -0,0 +1,313 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FsVersion } from '@deepseek-ai/dsh-fs' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' + +const contexts: Context[] = [] +const roots: string[] = [] +let callNumber = 0 + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +function agent(ctx: Context, cwd: string): Agent { + const id = SessionId(`str-replace-editor-owner-${callNumber}`) + const scope = ctx.plugin(() => {}) + const value: Agent = { + id, + options: {}, + session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }), + status: 'idle', + acceptsNextStep: false, + ctx: scope.ctx, + followup: () => {}, + steer: () => {}, + inject: () => {}, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +function call(ctx: Context, owner: Agent | undefined, args: unknown) { + return ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId(`str-replace-editor-${++callNumber}`), + name: 'str_replace_editor', + arguments: args, + ...owner === undefined ? {} : { agent: owner }, + }) +} + +async function setup(config: ToolStrReplaceEditor.Config = {}) { + const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-')) + roots.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileSystem, { cwd: root }) + await ctx.plugin(ToolStrReplaceEditor, config) + return { ctx, root, owner: agent(ctx, root) } +} + +describe('tool-str-replace-editor', () => { + it('registers the standalone schema and configurable description', async () => { + const { ctx } = await setup({ description: 'custom editor description' }) + const schema = ctx.tools.schemas()[0] + expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor']) + expect(schema?.description).toBe('custom editor description') + const properties = (schema?.parameters as { + properties: Record + }).properties + expect(properties).not.toHaveProperty('replace_all') + expect(properties.insert_line?.type).toBe('integer') + expect(properties.view_range?.items?.type).toBe('integer') + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'view', + path: '/workspace/a.txt', + })).toMatchObject({ card: 'generic', kind: 'read' }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'insert', + path: '/workspace/a.txt', + insert_line: 0, + new_str: 'x', + })).toMatchObject({ card: 'generic', kind: 'edit' }) + }) + + it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { + const { ctx, root, owner } = await setup() + const sample = join(root, 'sample.txt') + expect(text(await call(ctx, owner, { + command: 'create', + path: sample, + file_text: 'one\ntwo\nthree\n', + }))).toBe(`New file created successfully at: ${sample}`) + + expect(text(await call(ctx, owner, { + command: 'view', + path: sample, + view_range: [2, -1], + }))).toBe([ + `Here's the content of ${sample} with line numbers (which has a total of 4 lines) with view_range=[2, -1]:`, + ' 2 two', + ' 3 three', + ' 4 ', + '', + ].join('\n')) + + expect(text(await call(ctx, owner, { + command: 'str_replace', + path: sample, + old_str: 'two', + new_str: 'TWO', + }))).toBe(`The file ${sample} has been edited successfully.`) + expect(text(await call(ctx, owner, { + command: 'str_replace', + path: sample, + old_str: 'TWO', + }))).toBe(`The file ${sample} has been edited successfully.`) + expect(text(await call(ctx, owner, { + command: 'insert', + path: sample, + insert_line: 1, + new_str: 'between', + }))).toBe(`The file ${sample} has been edited successfully.`) + expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n') + }) + + it('lists visible entries to depth two and clips at the configured view limit', async () => { + const { ctx, root, owner } = await setup({ maxOutputChars: 10 }) + await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true }) + await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true }) + await mkdir(join(root, 'dir', '__pycache__'), { recursive: true }) + await writeFile(join(root, 'dir', 'visible.txt'), 'ok') + await writeFile(join(root, 'dir', '.hidden'), 'hidden') + await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child') + await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep') + await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency') + await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache') + const listDir = ctx.fs.listDir.bind(ctx.fs) + const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other')) + ctx.fs.listDir = async (target, signal) => { + const entries = await listDir(target, signal) + return target.displayPath === join(root, 'dir') + ? [...entries, { name: 'other', type: 'other', target: otherTarget }] + : entries + } + + const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') })) + expect(listing).toContain('') + expect(listing).not.toContain('.hidden') + expect(listing).not.toContain('too-deep.txt') + expect(listing).not.toContain('index.js') + expect(listing).not.toContain('module.pyc') + + await writeFile(join(root, 'large.txt'), 'x'.repeat(100)) + expect(text(await call(ctx, owner, { command: 'view', path: join(root, 'large.txt') }))) + .toContain('') + }) + + it('matches canonical empty-line, range, and end-insert behavior', async () => { + const { ctx, root, owner } = await setup() + const empty = join(root, 'empty.txt') + const newline = join(root, 'newline.txt') + const plain = join(root, 'plain.txt') + await writeFile(empty, '') + await writeFile(newline, '\n') + await writeFile(plain, 'one\ntwo') + + expect(text(await call(ctx, owner, { command: 'view', path: empty }))) + .toContain('(which has a total of 1 lines):\n 1 \n') + expect(text(await call(ctx, owner, { command: 'view', path: newline }))) + .toContain('(which has a total of 2 lines):\n 1 \n 2 \n') + expect(text(await call(ctx, owner, { + command: 'view', + path: plain, + view_range: [1, 2], + }))).toContain(' 2 two') + expect(text(await call(ctx, undefined, { + command: 'view', + path: plain, + }))).toContain(' 1 one') + + await call(ctx, owner, { + command: 'insert', + path: plain, + insert_line: 2, + new_str: 'three', + }) + expect(await readFile(plain, 'utf8')).toBe('one\ntwo\nthree') + + await writeFile(newline, 'one\n') + await call(ctx, owner, { + command: 'insert', + path: newline, + insert_line: 2, + new_str: 'three', + }) + expect(await readFile(newline, 'utf8')).toBe('one\n\nthree') + }) + + it('uses old_str-only replacement failures and rejects relative paths', async () => { + const { ctx, root, owner } = await setup() + const ambiguous = join(root, 'ambiguous.txt') + await writeFile(ambiguous, 'same\nother\nsame') + + const missing = await call(ctx, owner, { + command: 'str_replace', + path: ambiguous, + old_str: 'absent', + new_str: 'x', + }) + expect(missing.isError).toBe(true) + expect(text(missing)).toContain(`old_str \`absent\` did not appear verbatim in ${ambiguous}`) + expect(text(missing)).not.toContain('old_string') + + const repeated = await call(ctx, owner, { + command: 'str_replace', + path: ambiguous, + old_str: 'same', + new_str: 'x', + }) + expect(repeated.isError).toBe(true) + expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]') + expect(text(repeated)).not.toContain('replace_all') + + const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) + expect(relative.isError).toBe(true) + expect(text(relative)).toContain('is not an absolute path') + expect(await readFile(ambiguous, 'utf8')).toBe('same\nother\nsame') + }) + + it('reports invalid commands or arguments without mutating files', async () => { + const { ctx, root, owner } = await setup() + const ambiguous = join(root, 'ambiguous.txt') + const empty = join(root, 'empty.txt') + const trailingNewline = join(root, 'trailing-newline.txt') + const threeLines = join(root, 'three-lines.txt') + const directory = join(root, 'directory') + await writeFile(ambiguous, 'same same') + await writeFile(empty, '') + await writeFile(trailingNewline, 'one\n') + await writeFile(threeLines, 'one\ntwo\nthree') + await mkdir(directory) + + const cases = [ + { command: 'view', path: '' }, + { command: 'view', path: join(root, 'missing.txt') }, + { command: 'view', path: ambiguous, view_range: [1] }, + { command: 'view', path: ambiguous, view_range: [0, 1] }, + { command: 'view', path: ambiguous, view_range: [1.5, 2] }, + { command: 'view', path: threeLines, view_range: [1, 99] }, + { command: 'view', path: threeLines, view_range: [2, 1] }, + { command: 'view', path: directory, view_range: [1, 1] }, + { command: 'create', path: join(root, 'new.txt') }, + { command: 'create', path: ambiguous, file_text: 'overwrite' }, + { command: 'str_replace', path: ambiguous, new_str: 'x' }, + { command: 'str_replace', path: ambiguous, old_str: '', new_str: 'x' }, + { command: 'insert', path: ambiguous, new_str: 'x' }, + { command: 'insert', path: ambiguous, insert_line: -1, new_str: 'x' }, + { command: 'insert', path: ambiguous, insert_line: 1.5, new_str: 'x' }, + { command: 'insert', path: ambiguous, insert_line: 99, new_str: 'x' }, + { command: 'insert', path: empty, insert_line: 2, new_str: 'x' }, + { command: 'insert', path: directory, insert_line: 0, new_str: 'x' }, + ] + for (const args of cases) { + expect((await call(ctx, owner, args)).isError).toBe(true) + } + expect(await readFile(ambiguous, 'utf8')).toBe('same same') + + ctx.fs.stat = async () => ({ version: FsVersion('special'), type: 'other' }) + const special = await call(ctx, owner, { command: 'view', path: join(root, 'special') }) + expect(special.isError).toBe(true) + expect(special.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) + expect((await call(ctx, owner, { + command: 'str_replace', + path: join(root, 'special'), + old_str: 'x', + new_str: 'y', + })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) + expect((await call(ctx, owner, { + command: 'insert', + path: join(root, 'special'), + insert_line: 0, + new_str: 'x', + })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) + }) + + it('can opt into session-relative paths for non-canonical deployments', async () => { + const { ctx, root, owner } = await setup({ requireAbsolutePath: false }) + await writeFile(join(root, 'relative.txt'), 'relative') + expect(text(await call(ctx, owner, { command: 'view', path: 'relative.txt' }))) + .toContain("Here's the content of") + }) + + it('rejects invalid plugin config', () => { + expect(() => { + ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 }) + }).toThrow('maxOutputChars must be a positive safe integer') + expect(() => { + ToolStrReplaceEditor.apply(new Context(), { description: ' ' }) + }).toThrow('description must be non-empty') + }) +}) diff --git a/packages/fs/tool-str-replace-editor/tsconfig.json b/packages/fs/tool-str-replace-editor/tsconfig.json new file mode 100644 index 0000000000..2c6eb3688c --- /dev/null +++ b/packages/fs/tool-str-replace-editor/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../core/tools" }, + { "path": "../fs" }, + { "path": "../../support/invariants" } + ] +} diff --git a/packages/pty/README.i18n.yaml b/packages/pty/README.i18n.yaml index ef4c7b5c13..f986146eae 100644 --- a/packages/pty/README.i18n.yaml +++ b/packages/pty/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: a9121455519a5f83a63a005cb857fec0f0e06b92 -README.zh.md: 9fc262787b960d5bf03a59cd01bf36bd5c76614b +# pnpm run verify-translation-pairing --write packages/pty/README.md +README.md: e54dcf64db083665f37b7dc19a7a92e21494442b +README.zh.md: e06ea496e389d329671c358fbde5c0849aa03cf8 diff --git a/packages/pty/README.md b/packages/pty/README.md index a912145551..e54dcf64db 100644 --- a/packages/pty/README.md +++ b/packages/pty/README.md @@ -9,5 +9,6 @@ English | [中文](README.zh.md) | [`pty`](pty/README.md) (`@deepseek-ai/dsh-pty`) | Backend registry, branded ids, exact-Agent ownership, session operations, and awaited cleanup | `ctx.pty` | | `pty-local` (`@deepseek-ai/dsh-pty-local`) | Local `node-pty` backend, readiness detection, bounded terminal state, sandboxing, and process-session supervision | registers on `ctx.pty` | | `tool-pty` (`@deepseek-ai/dsh-tool-pty`) | Six model-facing tools and generic task integration for background sends | registers on `ctx.tools` | +| `tool-bash-persistent` (`@deepseek-ai/dsh-tool-bash-persistent`) | One model-facing `bash` backed by an owner-scoped reusable PTY shell | consumes `ctx.pty`, registers on `ctx.tools` | The design and deferred boundaries live in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md). diff --git a/packages/pty/README.zh.md b/packages/pty/README.zh.md index 9fc262787b..e06ea496e3 100644 --- a/packages/pty/README.zh.md +++ b/packages/pty/README.zh.md @@ -9,5 +9,6 @@ | [`pty`](pty/README.md)(`@deepseek-ai/dsh-pty`) | 后端注册表、品牌化 id、精确的 Agent 所有权、会话操作与等待完成的清理 | `ctx.pty` | | `pty-local`(`@deepseek-ai/dsh-pty-local`) | 本地 `node-pty` 后端、就绪检测、有界终端状态、沙箱与进程会话监管 | 注册到 `ctx.pty` | | `tool-pty`(`@deepseek-ai/dsh-tool-pty`) | 6 个面向模型的工具,并为后台发送集成通用任务 | 注册到 `ctx.tools` | +| `tool-bash-persistent`(`@deepseek-ai/dsh-tool-bash-persistent`) | 一个由所有者隔离可复用 PTY shell 支撑的模型可见 `bash` | 消费 `ctx.pty`,注册到 `ctx.tools` | 设计与暂缓边界记录在[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中。 diff --git a/packages/pty/tool-bash-persistent/README.i18n.yaml b/packages/pty/tool-bash-persistent/README.i18n.yaml new file mode 100644 index 0000000000..2f15d109c1 --- /dev/null +++ b/packages/pty/tool-bash-persistent/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/pty/tool-bash-persistent/README.md +README.md: 04c714d5489dbae8572e9339a4387a148450a0e9 +README.zh.md: adfb38b10409174d9558b963f5a2359cf819f04b diff --git a/packages/pty/tool-bash-persistent/README.md b/packages/pty/tool-bash-persistent/README.md new file mode 100644 index 0000000000..04c714d548 --- /dev/null +++ b/packages/pty/tool-bash-persistent/README.md @@ -0,0 +1,50 @@ +# @deepseek-ai/dsh-tool-bash-persistent + +English | [中文](README.zh.md) + +Model-facing `bash(command)` backed by one owner-scoped `ctx.pty` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend and sandbox policy. + +## Config + +| Key | Default | Meaning | +|---|---:|---| +| `backendType` | `shell` | Registered PTY backend used for each Agent shell. | +| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | +| `maxOutputChars` | `16000` | Prefix characters retained before the clipping notice. | +| `description` | Persistent-shell description | Model-facing environment contract. | + +## Model Experience + +### Tool schema + +#### What the model sees + +The generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance. + +#### Token effect + +Fixed schema cost while `bash` is visible. + +#### KV Cache effect + +Prefix-stable while the configured description and schema remain unchanged. + +### Tool results + +#### What the model sees + +Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and tells the model that the next call starts fresh. + +#### Token effect + +Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice. + +#### KV Cache effect + +Append-only tool results follow the reusable request prefix. + +## Known Limitations and Deferred Work + +- The tool requires an owning Agent and a real PTY backend. +- Explicit `exit`, timeout, or cancellation discards shell state; the next call starts a fresh shell. +- Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default. diff --git a/packages/pty/tool-bash-persistent/README.zh.md b/packages/pty/tool-bash-persistent/README.zh.md new file mode 100644 index 0000000000..adfb38b104 --- /dev/null +++ b/packages/pty/tool-bash-persistent/README.zh.md @@ -0,0 +1,50 @@ +# @deepseek-ai/dsh-tool-bash-persistent + +[English](README.md) | 中文 + +模型可见的 `bash(command)`,底层复用一个按所有者隔离的 `ctx.pty` shell。该包拥有工具契约和 shell 复用;PTY 后端与沙箱策略由部署选择。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY 后端。 | +| `timeoutMs` | `300000` | 单条命令的墙钟时间上限;超时会关闭 shell。 | +| `maxOutputChars` | `16000` | 截断提示前保留的前缀字符数。 | +| `description` | 持久 shell 描述 | 面向模型的环境契约。 | + +## 模型体验 + +### 工具 schema + +#### 模型所见 + +生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent),其中包含配置的 `description`。本插件不贡献独立系统提示词段;persona 与环境指导由部署负责。 + +#### Token 影响 + +`bash` 可见时产生固定的 schema 成本。 + +#### KV Cache 影响 + +配置的描述与 schema 不变时前缀稳定。 + +### 工具结果 + +#### 模型所见 + +每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并告知模型下次调用从新 shell 开始。 + +#### Token 影响 + +随数据变化,并受 `maxOutputChars` 与固定截断提示约束。 + +#### KV Cache 影响 + +工具结果以追加方式位于可复用请求前缀之后。 + +## 已知限制与延后工作 + +- 工具需要拥有它的 Agent 和真实 PTY 后端。 +- 显式 `exit`、超时或取消会丢弃 shell 状态;下次调用创建新 shell。 +- 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。 diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json new file mode 100644 index 0000000000..5022026733 --- /dev/null +++ b/packages/pty/tool-bash-persistent/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-tool-bash-persistent", + "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-pty": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts new file mode 100644 index 0000000000..b812fe8992 --- /dev/null +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -0,0 +1,380 @@ +/** + * Model-facing persistent `bash` tool over the owner-scoped PTY seam. + * @module @deepseek-ai/dsh-tool-bash-persistent + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from 'cordis' +import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/dsh-pty' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { defineTool } from '@deepseek-ai/dsh-tools' + +const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' +const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' +const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' +const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ ' +const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' +const SCROLLBACK_PAGE_LINES = 1_000 + +const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.' + +interface ResolvedConfig { + backendType: string + timeoutMs: number + maxOutputChars: number + description: string +} + +interface CommandMarkers { + start: string + end: string +} + +interface RetainedOutput { + text: string + truncated: boolean +} + +interface CapturedOutput { + text: string + incomplete: boolean +} + +interface PersistentShells { + get(owner: Agent, signal: AbortSignal): Promise + reset(owner: Agent, reason: string): Promise +} + +function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string { + if (content.length <= maxOutputChars && !incomplete) return content + return content.length <= maxOutputChars + ? content + TRUNCATED_MESSAGE + : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE +} + +function markers(): CommandMarkers { + const nonce = randomUUID() + return { + start: `__DSH_PERSISTENT_BASH_START_${nonce}__`, + end: `__DSH_PERSISTENT_BASH_END_${nonce}:`, + } +} + +function quoteForBash(value: string): string { + return `$'${value + .replaceAll('\\', '\\\\') + .replaceAll("'", "\\'") + .replaceAll('\r', '\\r') + .replaceAll('\n', '\\n')}'` +} + +function wrapCommand(command: string, marker: CommandMarkers): string { + // Keep the wrapper on one physical line. An interactive bash prints PS2 for + // embedded newlines before executing the buffer, which would leak terminal + // prompts and marker source text into the model-facing result. + return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __dsh_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__dsh_persistent_bash_status"` +} + +function stripPrompt(text: string): string { + let result = text + while (result.endsWith(`${SHELL_PROMPT}\r\n`) || result.endsWith(`${SHELL_PROMPT}\n`)) { + result = result.slice(0, result.endsWith('\r\n') + ? -SHELL_PROMPT.length - 2 + : -SHELL_PROMPT.length - 1) + } + while (result.endsWith(SHELL_PROMPT)) { + result = result.slice(0, -SHELL_PROMPT.length) + } + return result.endsWith('\n') ? result.slice(0, -1) : result +} + +function commandOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, +): CapturedOutput | undefined { + const text = snapshot.text + const end = text.lastIndexOf(marker.end) + if (end < 0) return undefined + const startMarker = text.lastIndexOf(marker.start, end) + const start = startMarker < 0 ? 0 : startMarker + marker.start.length + return { + text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), + incomplete: startMarker < 0 || snapshot.truncated, + } +} + +function promptCompleted(result: PtySendResult): boolean { + return result.viewport.endsWith(SHELL_PROMPT) + || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`) + || result.viewport.endsWith(`${SHELL_PROMPT}\n`) +} + +function partialOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, + fallback: string, +): CapturedOutput { + const startMarker = snapshot.text.lastIndexOf(marker.start) + if (startMarker >= 0) { + return { + text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), + incomplete: snapshot.truncated, + } + } + return { + text: stripPrompt(fallback), + incomplete: snapshot.truncated, + } +} + +async function pause(): Promise { + await new Promise(resolve => setTimeout(resolve, 25)) +} + +function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { + if (page.text.length === 0 || page.lineEnd <= offset) return undefined + return page.lineEnd +} + +function retainedScrollback( + ctx: Context, + owner: Agent, + id: PtySessionId, +): RetainedOutput { + const pages: string[] = [] + let offset = 0 + let truncated = false + while (true) { + const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) + truncated ||= page.truncated + if (page.text.length > 0) pages.unshift(page.text) + const next = nextScrollbackOffset(page, offset) + if (next === undefined || next >= page.totalLines) break + offset = next + } + return { text: pages.join('\n'), truncated } +} + +function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { + const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) + return output.incomplete && output.text.length > 0 + ? LOST_PREFIX_MESSAGE + rendered + : rendered +} + +function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { + const pending = new WeakMap>() + const live = new Map() + const ownerCleanupInstalled = new WeakSet() + + const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { + if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return + await ctx.pty.kill(owner, id, reason) + } + + ctx.effect(() => async () => { + const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') }) + await Promise.all(closing) + live.clear() + }, 'tool-bash-persistent shell cleanup') + + const reset = async (owner: Agent, reason: string): Promise => { + pending.delete(owner) + const id = live.get(owner) + live.delete(owner) + if (id !== undefined) await close(owner, id, reason) + } + + const get = (owner: Agent, signal: AbortSignal): Promise => { + const existing = pending.get(owner) + if (existing !== undefined) return existing + const creating = (async () => { + try { + const cwd = owner.session.header.cwd + const spawned = await ctx.pty.spawn(owner, { + type: config.backendType, + ...cwd === undefined ? {} : { cwd }, + }, signal) + live.set(owner, spawned.sessionId) + if (!ownerCleanupInstalled.has(owner)) { + ownerCleanupInstalled.add(owner) + owner.ctx.effect(() => () => { + pending.delete(owner) + live.delete(owner) + }, 'tool-bash-persistent owner cache cleanup') + } + const setup = ctx.pty.startSend(owner, spawned.sessionId, { + text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`, + submit: true, + signal, + }) + const result = await setup.done + if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { + throw new Error('persistent bash shell did not accept initialization') + } + return spawned.sessionId + } catch (error: unknown) { + await reset(owner, 'persistent bash initialization failed') + throw error + } + })() + pending.set(owner, creating) + return creating + } + + return { get, reset } +} + +async function executeCommand( + ctx: Context, + shells: PersistentShells, + owner: Agent, + command: string, + config: ResolvedConfig, + upstream: AbortSignal, +): Promise { + using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE) + const id = await shells.get(owner, commandDeadline.signal) + const marker = markers() + const wrapped = wrapCommand(command, marker) + let first = true + let fallback = '' + + while (true) { + const operation = ctx.pty.startSend(owner, id, { + text: first ? wrapped : '', + submit: first, + signal: commandDeadline.signal, + }) + first = false + const result = await operation.done + fallback += result.viewport + const snapshot = retainedScrollback(ctx, owner, id) + const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) + if (timedOut !== undefined) { + const partial = renderCaptured( + partialOutput(snapshot, marker, fallback), + config.maxOutputChars, + ) + await shells.reset(owner, 'persistent bash command timed out') + return [ + `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`, + partial, + SHELL_RESET_MESSAGE, + ].join('\n') + } + const complete = commandOutput(snapshot, marker) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + if (result.sessionStatus.kind === 'exited') { + await shells.reset(owner, 'persistent bash shell exited') + return [ + renderCaptured(partialOutput(snapshot, marker, fallback), config.maxOutputChars), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') + } + if (commandDeadline.signal.aborted) { + await shells.reset(owner, 'persistent bash command aborted') + commandDeadline.signal.throwIfAborted() + } + if (promptCompleted(result)) { + return maybeTruncate(stripPrompt(fallback), config.maxOutputChars, result.truncated) + } + await pause() + } +} + +/** + * Register the model-facing persistent `bash` tool. + * @param ctx - plugin context carrying tools and the owner-scoped PTY service. + * @param config - selected PTY backend and command deadline. + */ +function registerPersistentBash(ctx: Context, config: ResolvedConfig): void { + const shells = persistentShells(ctx, config) + const queues = new WeakMap>() + + const serialized = async (owner: Agent, operation: () => Promise): Promise => { + const prior = queues.get(owner) ?? Promise.resolve() + const run = prior.then(operation, operation) + const tail = run.then(() => undefined, () => undefined) + queues.set(owner, tail) + try { + return await run + } finally { + if (queues.get(owner) === tail) queues.delete(owner) + } + } + + ctx.tools.register(defineTool({ + name: 'bash', + description: config.description, + parameters: { + command: { + type: 'string', + required: true, + description: 'The bash command to run. Relative path is preferred in the command.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args, exec) { + if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + const owner = exec.agent + if (owner === undefined) throw new Error('bash requires an owning agent session') + return serialized(owner, async () => { + exec.signal.throwIfAborted() + return executeCommand(ctx, shells, owner, args.command, config, exec.signal) + }) + }, + presentCall: args => ({ card: 'terminal', title: args.command }), + })) +} + +export const name = 'tool-bash-persistent' +export const inject = ['tools', 'pty'] + +/** Configuration for the persistent Bash tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} + +/** Runtime configuration schema for the persistent Bash tool. */ +export const Config: z = z.object({ + backendType: z.string().default('shell'), + timeoutMs: z.number().default(300_000), + maxOutputChars: z.number().default(16_000), + description: z.string().default(DEFAULT_DESCRIPTION), +}) + +/** Register one owner-scoped persistent `bash` tool. */ +export function apply(ctx: Context, config: Config): void { + const resolved: ResolvedConfig = { + backendType: config.backendType ?? 'shell', + timeoutMs: config.timeoutMs ?? 300_000, + maxOutputChars: config.maxOutputChars ?? 16_000, + description: config.description ?? DEFAULT_DESCRIPTION, + } + if (resolved.backendType.trim().length === 0) { + throw new Error('tool-bash-persistent: backendType must be non-empty') + } + if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) { + throw new Error('tool-bash-persistent: timeoutMs must be a positive safe integer') + } + if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { + throw new Error('tool-bash-persistent: maxOutputChars must be a positive safe integer') + } + if (resolved.description.trim().length === 0) { + throw new Error('tool-bash-persistent: description must be non-empty') + } + registerPersistentBash(ctx, resolved) +} diff --git a/packages/pty/tool-bash-persistent/src/invariant.ts b/packages/pty/tool-bash-persistent/src/invariant.ts new file mode 100644 index 0000000000..f6b5acfbc7 --- /dev/null +++ b/packages/pty/tool-bash-persistent/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash-persistent`. + * @module @deepseek-ai/dsh-tool-bash-persistent/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash-persistent' + +/** Cordis companion plugin name. */ +export const name = 'tool-bash-persistent-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the tool adapter owns no independent durable state; + * PTY ownership and filesystem mutation relations stay with their services. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..b8586161f2 --- /dev/null +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -0,0 +1,156 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import * as PtyLocal from '@deepseek-ai/dsh-pty-local' +import SandboxProvider from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +class PassthroughSandbox extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] } + } +} + +function agent(ctx: Context, cwd: string): Agent { + const id = SessionId('persistent-bash-loader-agent') + const scope = ctx.plugin(() => {}) + const value: Agent = { + id, + options: {}, + session: new Session(id, [], { version: 0, id, createdAt: 0, cwd }), + status: 'idle', + acceptsNextStep: false, + ctx: scope.ctx, + followup: () => {}, + steer: () => {}, + inject: () => {}, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip + +suite('persistent Bash through a real cordis.yml Loader composition', () => { + it('preserves cwd and environment across calls', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-persistent-bash-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-system-prompt'", + "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-pty'", + "- name: '@deepseek-ai/dsh-test-sandbox'", + "- name: '@deepseek-ai/dsh-sandbox-policy'", + ' config:', + ' mode: danger-full-access', + ` workspaceRoot: ${JSON.stringify(root)}`, + "- name: '@deepseek-ai/dsh-pty-local'", + ' config:', + ' pollIntervalMs: 10', + ' exactProbeAfterMs: 20', + ' idleSilenceMs: 100', + ' handoffGraceMs: 100', + ' scrollbackLines: 20000', + ' timeoutMs: 2000', + ' disposeGraceMs: 500', + "- name: '@deepseek-ai/dsh-tool-bash-persistent'", + ' config:', + ' timeoutMs: 5000', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-tools', ToolRegistry], + ['@deepseek-ai/dsh-pty', PtyService], + ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], + ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], + ['@deepseek-ai/dsh-pty-local', PtyLocal], + ['@deepseek-ai/dsh-tool-bash-persistent', ToolBashPersistent], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await context.loader.await() + + const owner = agent(context, root) + const signal = new AbortController().signal + const execute = (id: string, command: string) => context!.tools.execute({ + signal, + callId: CallId(id), + name: 'bash', + arguments: { command }, + agent: owner, + }) + + expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash']) + await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested') + const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"')) + expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`) + expect(observed).not.toContain('DSH_PERSISTENT_BASH') + + const multiline = text(await execute( + 'multiline', + 'value="line one"\nprintf "%s:%s\\n" "$value" "it\'s fine"', + )) + expect(multiline).toBe("line one:it's fine") + expect(multiline).not.toContain('DSH_PERSISTENT_BASH') + + const heredoc = text(await execute( + 'heredoc', + "cat <<'EOF'\nalpha\nbeta\nEOF", + )) + expect(heredoc).toBe('alpha\nbeta') + + const large = text(await execute('large-output', 'seq 1 12050')) + expect(large.startsWith('1\n2\n3\n')).toBe(true) + expect(large).toContain('') + expect(large).not.toContain('beginning of this command output was dropped') + + const exited = text(await execute('exit', 'exit')) + expect(exited).toContain('next bash call starts from the workspace') + expect(text(await execute('after-exit', 'printf "%s\\n" "$PWD"'))).toBe(root) + }, 20_000) +}) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts new file mode 100644 index 0000000000..f757d6d1f8 --- /dev/null +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -0,0 +1,385 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import type { + PtyBackend, + PtyBackendSession, + PtyReadRequest, + PtySendOperation, + PtySendRequest, + PtySessionStatus, + PtySignal, + PtyWaitReason, +} from '@deepseek-ai/dsh-pty' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' + +const contexts: Context[] = [] +let callNumber = 0 + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +function agent(ctx: Context, cwd: string | undefined): Agent { + const id = SessionId(`persistent-bash-owner-${callNumber}`) + const scope = ctx.plugin(() => {}) + const value: Agent = { + id, + options: {}, + session: new Session(id, [], { + version: 0, + id, + createdAt: 0, + ...cwd === undefined ? {} : { cwd }, + }), + status: 'idle', + acceptsNextStep: false, + ctx: scope.ctx, + followup: () => {}, + steer: () => {}, + inject: () => {}, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +function call( + ctx: Context, + owner: Agent | undefined, + command: string, + signal = new AbortController().signal, +) { + return ctx.tools.execute({ + signal, + callId: CallId(`persistent-bash-${++callNumber}`), + name: 'bash', + arguments: { command }, + ...owner === undefined ? {} : { agent: owner }, + }) +} + +type StubMode = + | 'normal' + | 'prompt-only' + | 'prompt-crlf' + | 'empty-read' + | 'stalled-read' + | 'exit' + | 'wait-for-abort' + | 'idle-then-normal' + | 'large' + | 'end-only' + | 'init-exit' + | 'init-timeout' + | 'spawn-error' + +class StubPtySession implements PtyBackendSession { + readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' + readonly pid = 123 + statusValue: PtySessionStatus = { kind: 'running' } + scrollback = this.motd + closed: string[] = [] + mode: StubMode + sends = 0 + pendingText = '' + + constructor(mode: StubMode) { + this.mode = mode + } + + startSend(request: PtySendRequest): PtySendOperation { + this.sends += 1 + if (request.text.startsWith('stty -echo')) { + if (this.mode === 'init-exit') { + this.statusValue = { kind: 'exited', exitCode: 1, signal: null } + return this.operation(Promise.resolve(this.result('', 'session_exit'))) + } + if (this.mode === 'init-timeout') { + return this.operation(Promise.resolve(this.result('', 'timeout'))) + } + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) + } + if (this.mode === 'wait-for-abort') { + const done = new Promise>((resolve) => { + request.signal?.addEventListener('abort', () => { + this.scrollback += 'partial output' + resolve(this.result('partial output', 'stdin_read')) + }, { once: true }) + }) + return this.operation(done) + } + if (this.mode === 'idle-then-normal') { + this.mode = 'normal' + this.pendingText = request.text + return this.operation(Promise.resolve(this.result('', 'inferred_idle'))) + } + if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') { + const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n' + const output = `bash: syntax error${newline}${this.motd}${newline}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const sent = request.text.length > 0 ? request.text : this.pendingText + this.pendingText = '' + const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0] + const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0] + if (this.mode === 'end-only') { + const output = `recovered output\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const commandOutput = this.mode === 'large' ? 'x'.repeat(100) : 'hello from stub' + const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + if (this.mode === 'exit') { + const exitedOutput = `${start ?? ''}\nhello from stub\n` + this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) + } + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + + read(_request: PtyReadRequest) { + if (this.mode === 'empty-read') { + return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } + } + if (this.mode === 'stalled-read') { + return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false } + } + const lines = this.scrollback.split('\n') + return { + text: this.scrollback, + totalLines: lines.length, + lineBegin: 0, + lineEnd: lines.length, + truncated: false, + } + } + + signal(_signal: PtySignal) { + return Promise.resolve({ delivered: true as const, targetPgid: 123 }) + } + + status() { + return this.statusValue + } + + async close(reason: string) { + this.closed.push(reason) + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + } + + private result(viewport: string, waitReason: PtyWaitReason) { + return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } + } + + private operation(done: Promise>): PtySendOperation { + return { + done, + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + } +} + +function stubBackend(initialMode: StubMode = 'normal') { + const sessions: StubPtySession[] = [] + const backend: PtyBackend = { + type: 'stub', + async spawn() { + if (initialMode === 'spawn-error') throw new Error('stub spawn failed') + const session = new StubPtySession(initialMode) + sessions.push(session) + return session + }, + } + return { backend, sessions } +} + +async function setup( + config: ToolBashPersistent.Config = { backendType: 'stub' }, + initialMode: StubMode = 'normal', +) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const stub = stubBackend(initialMode) + ctx.pty.registerBackend(stub.backend) + const fiber = await ctx.plugin(ToolBashPersistent, config) + return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } +} + +describe('tool-bash-persistent', () => { + it('registers a configurable schema and reuses one owner shell', async () => { + const { ctx, owner, stub } = await setup({ + backendType: 'stub', + description: 'deployment-specific persistent shell', + }) + const schema = ctx.tools.schemas()[0] + expect(ctx.tools.schemas().map(item => item.name)).toEqual(['bash']) + expect(schema?.description).toBe('deployment-specific persistent shell') + expect(schema?.parameters).toMatchObject({ + required: ['command'], + properties: { command: { type: 'string' } }, + }) + expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd' })) + .toEqual({ card: 'terminal', title: 'pwd' }) + + expect(text(await call(ctx, owner, 'echo one'))).toBe('hello from stub') + expect(text(await call(ctx, owner, 'echo two'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(1) + expect(stub.sessions[0]?.sends).toBe(3) + + const ownerWithoutCwd = agent(ctx, undefined) + expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + + it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { + const { ctx, owner, stub, fiber } = await setup({ + backendType: 'stub', + maxOutputChars: 10, + }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'idle-then-normal' + expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from') + + session.mode = 'prompt-only' + const promptFallback = text(await call(ctx, owner, 'bad {')) + expect(promptFallback).toContain('bash: synt') + expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT') + + session.mode = 'prompt-crlf' + session.scrollback = '' + const crlfPromptFallback = text(await call(ctx, owner, 'bad {')) + expect(crlfPromptFallback).toContain('bash: synt') + expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT') + + session.mode = 'end-only' + session.scrollback = '' + const missingStart = text(await call(ctx, owner, 'recover marker')) + expect(missingStart).toContain('recovered') + expect(missingStart).toContain('beginning of this command output was dropped') + expect(missingStart).toContain('') + + session.mode = 'large' + expect(text(await call(ctx, owner, 'large'))).toContain('') + + session.mode = 'exit' + const exited = text(await call(ctx, owner, 'exit')) + expect(exited).toContain('hello from') + expect(exited).toContain('next bash call starts from the workspace') + expect(session.closed).toContain('persistent bash shell exited') + + await call(ctx, owner, 'new shell') + expect(stub.sessions).toHaveLength(2) + const externallyClosed = ctx.pty.list(owner)[0]?.sessionId + expect(externallyClosed).toBeDefined() + await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') + await fiber.dispose() + expect(stub.sessions[1]?.closed).toEqual(['external cleanup']) + }) + + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'end-only' + session.scrollback = '' + expect(text(await call(ctx, owner, 'missing start'))) + .toContain('beginning of this command output was dropped') + + session.mode = 'empty-read' + expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub') + + session.mode = 'stalled-read' + expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') + }) + + it('closes a timed-out shell and reports bounded partial output', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'wait-for-abort' + const result = await call(ctx, owner, 'hang') + expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error') + expect(text(result)).toContain('partial output') + expect(text(result)).toContain('next bash call starts from the workspace') + expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out') + }) + + it('cancels in-flight work, resets the shell, and releases a queued call', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'wait-for-abort' + const controller = new AbortController() + const cancelled = call(ctx, owner, 'hang', controller.signal) + const queued = call(ctx, owner, 'after cancellation') + setTimeout(() => { + controller.abort(new Error('caller stopped')) + }, 5) + + expect((await cancelled).isError).toBe(true) + expect(text(await queued)).toBe('hello from stub') + expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted') + expect(stub.sessions).toHaveLength(2) + }) + + it.each(['init-exit', 'init-timeout'] as const)( + 'fails initialization and closes the unusable shell for %s', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode) + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed') + }, + ) + + it('clears a failed spawn without trying to close an unpublished shell', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error') + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + }) + + it('rejects invalid config and invalid calls', async () => { + const { ctx, owner, stub } = await setup() + expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) + expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + + const controller = new AbortController() + controller.abort(new Error('caller stopped')) + expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + + expect(() => { + ToolBashPersistent.apply(new Context(), { backendType: '' }) + }).toThrow('backendType must be non-empty') + expect(() => { + ToolBashPersistent.apply(new Context(), { timeoutMs: 0 }) + }).toThrow('timeoutMs must be a positive safe integer') + expect(() => { + ToolBashPersistent.apply(new Context(), { maxOutputChars: 0 }) + }).toThrow('maxOutputChars must be a positive safe integer') + expect(() => { + ToolBashPersistent.apply(new Context(), { description: ' ' }) + }).toThrow('description must be non-empty') + }) +}) diff --git a/packages/pty/tool-bash-persistent/tsconfig.json b/packages/pty/tool-bash-persistent/tsconfig.json new file mode 100644 index 0000000000..b1baf9db9a --- /dev/null +++ b/packages/pty/tool-bash-persistent/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../core/agent" }, + { "path": "../../core/tools" }, + { "path": "../pty" }, + { "path": "../../support/invariants" }, + { "path": "../../util/timeout" } + ] +} diff --git a/patches/node-pty@1.1.0.patch b/patches/node-pty@1.1.0.patch new file mode 100644 index 0000000000..f0de7b9054 --- /dev/null +++ b/patches/node-pty@1.1.0.patch @@ -0,0 +1,60 @@ +diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js +index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf988cf94c5 100644 +--- a/lib/unixTerminal.js ++++ b/lib/unixTerminal.js +@@ -26,10 +26,22 @@ var terminal_1 = require("./terminal"); + var utils_1 = require("./utils"); + var native = utils_1.loadNativeModule('pty'); + var pty = native.module; +-var helperPath = native.dir + '/spawn-helper'; +-helperPath = path.resolve(__dirname, helperPath); +-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++var helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; ++if (helperPath) { ++ helperPath = path.resolve(helperPath); ++} ++else { ++ var executableSibling = process.execPath + '-spawn-helper'; ++ if (fs.existsSync(executableSibling)) { ++ helperPath = executableSibling; ++ } ++ else { ++ helperPath = native.dir + '/spawn-helper'; ++ helperPath = path.resolve(__dirname, helperPath); ++ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); ++ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++ } ++} + var DEFAULT_FILE = 'sh'; + var DEFAULT_NAME = 'xterm'; + var DESTROY_SOCKET_TIMEOUT_MS = 200; +diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts +index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf9771220392d17 100644 +--- a/src/unixTerminal.ts ++++ b/src/unixTerminal.ts +@@ -14,10 +14,20 @@ import { assign, loadNativeModule } from './utils'; + + const native = loadNativeModule('pty'); + const pty: IUnixNative = native.module; +-let helperPath = native.dir + '/spawn-helper'; +-helperPath = path.resolve(__dirname, helperPath); +-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++let helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; ++if (helperPath) { ++ helperPath = path.resolve(helperPath); ++} else { ++ const executableSibling = process.execPath + '-spawn-helper'; ++ if (fs.existsSync(executableSibling)) { ++ helperPath = executableSibling; ++ } else { ++ helperPath = native.dir + '/spawn-helper'; ++ helperPath = path.resolve(__dirname, helperPath); ++ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); ++ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++ } ++} + + const DEFAULT_FILE = 'sh'; + const DEFAULT_NAME = 'xterm'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18cb1ec0d1..0fc60b40cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: patchedDependencies: '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 + node-pty@1.1.0: fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985 importers: @@ -631,7 +632,7 @@ importers: devDependencies: node-pty: specifier: 1.1.0 - version: 1.1.0 + version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) packages/acp/acp: dependencies: @@ -2535,6 +2536,40 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/fs/tool-str-replace-editor: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../fs-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/goal/command-goal: devDependencies: '@cordisjs/plugin-loader': @@ -3307,7 +3342,7 @@ importers: dependencies: node-pty: specifier: ^1.1.0 - version: 1.1.0 + version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -3337,6 +3372,55 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/pty/tool-bash-persistent: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-pty': + specifier: workspace:^ + version: link:../pty + '@deepseek-ai/dsh-pty-local': + specifier: workspace:^ + version: link:../pty-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/pty/tool-pty: dependencies: schemastery: @@ -5520,6 +5604,12 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-pty': + specifier: workspace:^ + version: link:../../packages/pty/pty + '@deepseek-ai/dsh-pty-local': + specifier: workspace:^ + version: link:../../packages/pty/pty-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard @@ -5529,6 +5619,9 @@ importers: '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../packages/sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-local '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../packages/sandbox/sandbox-policy @@ -5619,6 +5712,9 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../packages/bash/tool-bash + '@deepseek-ai/dsh-tool-bash-persistent': + specifier: workspace:^ + version: link:../../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis @@ -5631,6 +5727,9 @@ importers: '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:^ + version: link:../../packages/fs/tool-str-replace-editor '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent @@ -15119,7 +15218,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-pty@1.1.0: + node-pty@1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985): dependencies: node-addon-api: 7.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 40716f3403..8aad3a1f3d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,6 +39,9 @@ allowBuilds: node-addon-require-builtin: false # JSONL durability calls MoveFileExW with write-through publication on Windows. koffi: true + # The Python runtime deploy includes the reviewed workspace postinstall that + # places node-pty's spawn helper beside the compiled PTY backend. + '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true # The Landlock launcher family is our own sibling-repo release, consumed # fresh (hours old at each coordinated bump) — the release-age quarantine @@ -58,3 +61,4 @@ minimumReleaseAgeExclude: patchedDependencies: '@earendil-works/pi-tui@0.80.7': patches/@earendil-works__pi-tui@0.80.7.patch + node-pty@1.1.0: patches/node-pty@1.1.0.patch diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 5df1ca291d..f0d6c67967 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: d2b6a1cfe9897026d567b2def301799069c350fb -README.zh.md: 2ffccef922d52923f7c6e373a01ed8b19d9a55c1 +# pnpm run verify-translation-pairing --write python/README.md +README.md: aee682e25fc33287c49131d0f5b92b136ed16bae +README.zh.md: 4404114fcdab78468991769a4657370f85997a88 diff --git a/python/README.md b/python/README.md index d2b6a1cfe9..aee682e25f 100644 --- a/python/README.md +++ b/python/README.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -Products land in `dist-exe/` and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the executable with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same binaries. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). +Products land in `dist-exe/` and are synced into this package as `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` plus the matching `-spawn-helper` required by `node-pty` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the runtime with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same products. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). ## Validating the SDK against the executable diff --git a/python/README.zh.md b/python/README.zh.md index 2ffccef922..4404114fcd 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到可执行文件。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的二进制。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 +产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` 及 `node-pty` 所需的同名 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到运行时。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的产物。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 ## 用可执行文件验证 SDK diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 90433c4f5f..44fcf94b60 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: f2ccd8939e497d10359aafe8b1bd8b364875ed98 -README.zh.md: 30bdf46fee03c38a1f4b6e8b2b39d87e8174a3e0 +# pnpm run verify-translation-pairing --write python/sdk-runtime/README.md +README.md: 977bce41191d6c7716548dafde81d2c7ec14dec7 +README.zh.md: ade8455c56c27fcbe3e43a68abeaad98421cf720 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index f2ccd8939e..977bce4119 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,19 +8,19 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — single-file executables `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). No Node installation needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built and validated as one runtime product. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. -Each wheel contains exactly one executable. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple executables, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. +Each wheel contains exactly one runtime executable and its matching native spawn helper. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. ## Resolution API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. -- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; the node carrier has no single-path equivalent and launches via the argv tuple above). +- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; it validates that the required sibling `-spawn-helper` is also installed). The node carrier has no single-path equivalent and launches via the argv tuple above. - `bundled_default_config_path() -> Path` — the checked-in default config (see below). - `bundled_package_dir() -> Path` — the installed package data root. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 30bdf46fee..ade8455c56 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,19 +8,19 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;构建与校验会把两者视作同一个运行时产物。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个可执行文件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 ## 解析 API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,`node` 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 `node` 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 -- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体;`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动)。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体,并会校验必要的 `-spawn-helper` 伴随文件也已安装)。`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动。 - `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 - `bundled_package_dir() -> Path`——已安装包的数据根目录。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 1c5b22e11a..108e77cf2c 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -13,6 +13,7 @@ _PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } +_SPAWN_HELPER_SUFFIX = "-spawn-helper" def _host_platform_tag() -> str: @@ -46,14 +47,23 @@ class RuntimeBuildHook(BuildHookInterface): ) expected_executable = matches[0][1] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" - executables = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) + runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) + executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)] + helpers = [path for path in runtime_files if path.name.endswith(_SPAWN_HELPER_SUFFIX)] if [path.name for path in executables] != [expected_executable]: found = ", ".join(path.name for path in executables) or "none" raise RuntimeError( f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}" ) - if executables[0].stat().st_mode & stat.S_IXUSR == 0: - raise RuntimeError(f"runtime executable is not executable: {executables[0]}") + expected_helper = f"{expected_executable}{_SPAWN_HELPER_SUFFIX}" + if [path.name for path in helpers] != [expected_helper]: + found = ", ".join(path.name for path in helpers) or "none" + raise RuntimeError( + f"runtime wheel {platform_tag} must contain only {expected_helper}; found {found}" + ) + for executable in [executables[0], helpers[0]]: + if executable.stat().st_mode & stat.S_IXUSR == 0: + raise RuntimeError(f"runtime executable is not executable: {executable}") build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a1d8728d4c..cbec3a4923 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -42,6 +42,8 @@ "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", @@ -49,6 +51,7 @@ "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -75,7 +78,9 @@ "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index b38df5a211..9228281ab2 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -3,9 +3,10 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's ``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git): -- **exe (production)**: single-file executables named +- **exe (production)**: single-file Node executables named ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in - {x64, arm64}); the target machine needs no Node installation. + {x64, arm64}) plus a sibling ``-spawn-helper`` used by ``node-pty``; the + target machine needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`` on a @@ -27,6 +28,7 @@ import sys from pathlib import Path PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" +SPAWN_HELPER_SUFFIX = "-spawn-helper" RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" @@ -82,6 +84,12 @@ def bundled_runtime_path() -> Path: f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + _EXE_ACQUISITION_HINT ) + helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") + if not helper.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " + + _EXE_ACQUISITION_HINT + ) return path @@ -144,6 +152,7 @@ def _node_launch_args() -> tuple[str, str]: __all__ = [ "PACKAGE_METADATA_FILENAME", "RUNTIME_MODE_ENV_VAR", + "SPAWN_HELPER_SUFFIX", "bundled_default_config_path", "bundled_package_dir", "bundled_runtime_path", diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 38cb40862d..7b7c8254b1 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import runpy +import stat from pathlib import Path from types import SimpleNamespace @@ -37,3 +38,40 @@ def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: with pytest.raises(ValueError, match="must be stable X.Y.Z"): build_python_release.repository_version(tmp_path) + + +def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(b"helper") + spawn_helper.chmod(0o751) + destination = tmp_path / "staging" + + build_python_release.stage_runtime( + destination, + "1.2.3", + executable, + executable.name, + ) + + runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" + assert (runtime_dir / executable.name).read_bytes() == b"runtime" + copied_helper = runtime_dir / spawn_helper.name + assert copied_helper.read_bytes() == b"helper" + assert copied_helper.stat().st_mode & stat.S_IXUSR + + +def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + + with pytest.raises(FileNotFoundError, match="spawn helper"): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + executable.name, + ) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index b72f73671e..03c82b25b0 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,7 +8,7 @@ import { spawn } from 'node:child_process' import { existsSync, mkdirSync, statSync } from 'node:fs' -import { copyFile, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises' import { basename, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -19,6 +19,7 @@ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' /** The app entry inside the deployed closure. */ const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js' const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' +const SPAWN_HELPER_SUFFIX = '-spawn-helper' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' /** Pinned for reproducible builds. */ @@ -52,6 +53,11 @@ const ARCHES = ['x64', 'arm64'] as const type Platform = (typeof PLATFORMS)[number] type Arch = (typeof ARCHES)[number] +interface RuntimeProduct { + executable: string + spawnHelper: string +} + function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -254,6 +260,8 @@ class SingleExeBuild { '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', + // The production closure intentionally omits patched dev-only packages. + '--config.allow-unused-patches=true', this.staging, ]) if (this.cli.dryRun) { @@ -287,8 +295,9 @@ class SingleExeBuild { * @param target - the pkg target triple to build. * @returns the canonical product path `/dsh-jsonrpc-agent-pkg--`. */ - async pack(target: Target): Promise { + async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) await this.run(`pkg ${target.spec}`, pnpmBin(), [ 'dlx', @@ -303,22 +312,59 @@ class SingleExeBuild { if (!this.cli.dryRun && !existsSync(product)) { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } - return product + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) + } else { + const source = this.resolveSpawnHelper(target) + await copyFile(source, spawnHelper) + await chmod(spawnHelper, statSync(source).mode & 0o777) + } + return { executable: product, spawnHelper } + } + + /** + * Resolve the node-pty helper that matches a pkg target. + * @param target - the pkg target whose helper must be shipped. + * @returns a physical executable outside pkg's virtual snapshot. + */ + private resolveSpawnHelper(target: Target): string { + const nodePtyRoot = join(this.staging, 'node_modules', 'node-pty') + const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' + const candidates = [ + join(nodePtyRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'spawn-helper'), + ] + const hostPlatform = process.platform === 'darwin' ? 'macos' : process.platform + const hostArch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined + if (target.platform === hostPlatform && target.arch === hostArch) { + candidates.push(join(nodePtyRoot, 'build', 'Release', 'spawn-helper')) + } + const helper = candidates.find(candidate => existsSync(candidate)) + if (helper === undefined) { + throw new Error( + `build-exe-for-python-sdk: node-pty spawn-helper for ${target.platform}-${target.arch} is missing; ` + + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, + ) + } + if (statSync(helper).mode & 0o111) return helper + throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) } /** * Print each product path and, outside dry-run mode, its size. * @param products - the product paths returned by {@link pack}. */ - printProducts(products: string[]): void { + printProducts(products: RuntimeProduct[]): void { console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:') for (const product of products) { if (this.cli.dryRun) { - console.log(` ${product}`) + console.log(` ${product.executable}`) + console.log(` ${product.spawnHelper}`) continue } - const megabytes = statSync(product).size / (1024 * 1024) - console.log(` ${product} (${megabytes.toFixed(1)} MB)`) + for (const path of [product.executable, product.spawnHelper]) { + const megabytes = statSync(path).size / (1024 * 1024) + console.log(` ${path} (${megabytes.toFixed(1)} MB)`) + } } } @@ -327,19 +373,24 @@ class SingleExeBuild { * carrier is already in place, and `dist-exe/` retains upload copies. * @param products - the product paths returned by {@link pack}. */ - async syncToPythonRuntime(products: string[]): Promise { + async syncToPythonRuntime(products: RuntimeProduct[]): Promise { const destDir = resolve(root, PYTHON_RUNTIME_DIR) if (this.cli.dryRun) { for (const product of products) { - console.log(`build-exe-for-python-sdk: [dry-run] cp ${product} ${join(destDir, basename(product))}`) + for (const path of [product.executable, product.spawnHelper]) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) + } } return } mkdirSync(destDir, { recursive: true }) for (const product of products) { - const destination = join(destDir, basename(product)) - await copyFile(product, destination) - console.log(`build-exe-for-python-sdk: synced ${destination}`) + for (const path of [product.executable, product.spawnHelper]) { + const destination = join(destDir, basename(path)) + await copyFile(path, destination) + await chmod(destination, statSync(path).mode & 0o777) + console.log(`build-exe-for-python-sdk: synced ${destination}`) + } } } @@ -358,7 +409,12 @@ class SingleExeBuild { } console.log(`build-exe-for-python-sdk: ${label}: ${printable}`) await new Promise((resolvePromise, reject) => { - const child = spawn(command, args, { cwd: root, stdio: 'inherit' }) + const child = spawn(command, args, { + cwd: root, + stdio: 'inherit', + // Artifact builds must not mutate or validate a developer's Git hooks. + env: { ...process.env, CI: 'true' }, + }) child.once('error', (error) => { reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`)) }) @@ -383,7 +439,7 @@ async function main(): Promise { await pipeline.build() await pipeline.deployStaging() await pipeline.injectPkgConfig() - const products: string[] = [] + const products: RuntimeProduct[] = [] for (const target of cli.targets) products.push(await pipeline.pack(target)) pipeline.printProducts(products) await pipeline.syncToPythonRuntime(products) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index e0e90aa818..915968b30d 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -22,6 +22,7 @@ PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } +SPAWN_HELPER_SUFFIX = "-spawn-helper" def main() -> None: @@ -136,6 +137,11 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ raise FileNotFoundError(f"runtime executable does not exist: {executable}") if executable.stat().st_mode & stat.S_IXUSR == 0: raise PermissionError(f"runtime executable is not executable: {executable}") + spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}") + if not spawn_helper.is_file(): + raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") + if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -143,6 +149,9 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ destination_executable = runtime_dir / executable_name shutil.copyfile(executable, destination_executable) destination_executable.chmod(executable.stat().st_mode & 0o777) + destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" + shutil.copyfile(spawn_helper, destination_helper) + destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) def verify_wheel( @@ -161,16 +170,24 @@ def verify_wheel( raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}") if metadata.get("Version") != version: raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}") - executables = [name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name] + runtime_files = [ + name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name + ] + helpers = [name for name in runtime_files if name.endswith(SPAWN_HELPER_SUFFIX)] + executables = [name for name in runtime_files if not name.endswith(SPAWN_HELPER_SUFFIX)] if package == "runtime": assert platform is not None if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"): raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}") - mode = archive.getinfo(executables[0]).external_attr >> 16 - if mode & stat.S_IXUSR == 0: - raise RuntimeError(f"{wheel} runtime executable lost its executable bit") - elif executables: - raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {executables}") + expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}" + if len(helpers) != 1 or not helpers[0].endswith(f"/runtime/{expected_helper}"): + raise RuntimeError(f"{wheel} must contain exactly {expected_helper}, found {helpers}") + for executable in [executables[0], helpers[0]]: + mode = archive.getinfo(executable).external_attr >> 16 + if mode & stat.S_IXUSR == 0: + raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") + elif runtime_files: + raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": requirements = metadata.get_all("Requires-Dist") or [] expected_requirement = f"deepseek-harness-runtime-bin=={version}" diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 7bdc8b68a8..dab261d11e 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -33,9 +33,11 @@ import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' import PtyService from '@deepseek-ai/dsh-pty' import * as ToolPty from '@deepseek-ai/dsh-tool-pty' import * as ToolGoal from '@deepseek-ai/dsh-tool-goal' @@ -217,6 +219,32 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.', }, + { + pkg: '@deepseek-ai/dsh-tool-bash-persistent', + dir: 'tool-bash-persistent', + source: 'packages/pty/tool-bash-persistent/src/index.ts', + requires: ['ctx.tools', 'ctx.pty', 'an owning Agent at execution time'], + writes: ['tool/call', 'PTY shell state', 'tool/result'], + async mount(ctx) { + await ctx.plugin(PtyService) + await ctx.plugin(ToolBashPersistent) + }, + note: + 'One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.', + }, + { + pkg: '@deepseek-ai/dsh-tool-str-replace-editor', + dir: 'tool-str-replace-editor', + source: 'packages/fs/tool-str-replace-editor/src/index.ts', + requires: ['ctx.tools', 'ctx.fs'], + writes: ['tool/call', 'fs/observed after successful file operations', 'tool/result'], + async mount(ctx) { + await ctx.plugin(LocalFileSystem) + await ctx.plugin(ToolStrReplaceEditor) + }, + note: + 'Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface.', + }, { pkg: '@deepseek-ai/dsh-tool-fs', dir: 'tool-fs', diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 0019654fdc..11500b5ec7 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -25,6 +25,14 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." CODE_WORKER_TEXT = "code worker smoke ok" WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" +PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor." +PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok" +PERSISTENT_EDITOR_PATH: str | None = None +PERSISTENT_BASH_COMMAND = ( + "counter=$(( ${counter:-0} + 1 )); export counter; " + "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " + "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" +) SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario." SNAPSHOT_SESSION_ID = "advanced-executable" SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else." @@ -96,6 +104,49 @@ CUSTOM_CORDIS = """\ - id: cordis-tool name: '@deepseek-ai/dsh-tool-cordis' """ +PERSISTENT_TOOLS_CORDIS = """\ +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' +- id: llm + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.env.DSH_CWD +- id: pty + name: '@deepseek-ai/dsh-pty' +- id: pty-local + name: '@deepseek-ai/dsh-pty-local' +- id: fs + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.env.DSH_CWD +- id: agent-core + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + persona: 'You are a helpful software engineer assistant.' + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT + compression: 'none' +- id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' +""" class MockModelHandler(BaseHTTPRequestHandler): @@ -132,6 +183,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) + persistent = persistent_tool_followup(body, call_id, tool_name, tool_text) + if persistent is not None: + return persistent advanced = advanced_tool_followup(body, call_id, tool_name, tool_text) if advanced is not None: return advanced @@ -144,6 +198,15 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: raise AssertionError(f"unexpected tool follow-up: {tool_name}") prompt = message_text(latest.get("content")) + if prompt == PERSISTENT_TOOLS_PROMPT: + names = advertised_tool_names(body) + if names != {"bash", "str_replace_editor"}: + raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}") + return tool_call_chunks( + "persistent-bash-1", + "bash", + {"command": PERSISTENT_BASH_COMMAND}, + ) if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT: return text_chunks("DIRECT_CHILD_OK") if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: @@ -178,6 +241,44 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(EXPECTED_TEXT) +def persistent_tool_followup( + body: dict[str, object], + call_id: str, + tool_name: str, + tool_text: str, +) -> list[dict[str, object]] | None: + """Verify packaged PTY persistence, then invoke the packaged editor.""" + if not call_id.startswith("persistent-"): + return None + if call_id == "persistent-bash-1" and tool_name == "bash": + if "COUNT=1" not in tool_text: + raise AssertionError(f"first persistent bash call lost its output: {tool_text}") + return tool_call_chunks( + "persistent-bash-2", + "bash", + {"command": PERSISTENT_BASH_COMMAND}, + ) + if call_id == "persistent-bash-2" and tool_name == "bash": + if "COUNT=2 CWD=/tmp" not in tool_text: + raise AssertionError(f"persistent bash did not retain state: {tool_text}") + if PERSISTENT_EDITOR_PATH is None: + raise AssertionError("persistent editor smoke path was not initialized") + return tool_call_chunks( + "persistent-editor", + "str_replace_editor", + { + "command": "create", + "path": PERSISTENT_EDITOR_PATH, + "file_text": "created by packaged editor\n", + }, + ) + if call_id == "persistent-editor" and tool_name == "str_replace_editor": + if "New file created successfully" not in tool_text: + raise AssertionError(f"packaged editor did not create its file: {tool_text}") + return text_chunks(PERSISTENT_TOOLS_TEXT) + raise AssertionError(f"unexpected persistent-tools follow-up: {call_id} {tool_name}: {tool_text}") + + def advanced_tool_followup( body: dict[str, object], call_id: str, @@ -357,14 +458,14 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"), default="all", ) parser.add_argument("--exe", type=Path) parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "sdk-snapshot", "direct"} and args.exe is None: - parser.error("--exe is required for custom, snapshot, and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"} and args.exe is None: + parser.error("--exe is required for custom, persistent, snapshot, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}: parser.error("--update-snapshots requires --scenario sdk-snapshot or all") if args.exe is not None and not args.exe.is_file(): @@ -376,6 +477,9 @@ def main() -> None: if args.scenario in {"all", "sdk-custom"}: assert args.exe is not None smoke_sdk_custom(model.url, args.exe.resolve()) + if args.scenario in {"all", "sdk-persistent"}: + assert args.exe is not None + smoke_sdk_persistent_tools(model.url, args.exe.resolve()) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -439,6 +543,41 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT) +def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: + """Exercise native PTY state and the editor through the packaged executable.""" + global PERSISTENT_EDITOR_PATH + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary: + root = Path(temporary).resolve() + PERSISTENT_EDITOR_PATH = str(root / "created.txt") + sessions = root / "sessions" + cordis = root / "cordis.yml" + cordis.write_text(PERSISTENT_TOOLS_CORDIS) + with DeepSeekHarness( + provider="deepseek", + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + cordis=str(cordis), + runtime_bin=str(executable), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + result = harness.run(PERSISTENT_TOOLS_PROMPT, session_id="persistent-tools-smoke") + + assert result.status == "ok", result + event_text = json.dumps(result.events) + if PERSISTENT_TOOLS_TEXT not in event_text: + raise AssertionError(f"packaged tools run emitted no final response: {result.events}") + created = root / "created.txt" + if created.read_text() != "created by packaged editor\n": + raise AssertionError(f"packaged editor wrote unexpected content: {created.read_text()!r}") + assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") + PERSISTENT_EDITOR_PATH = None + + def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: """Drive and compare the advanced SDK/executable behavioral snapshot.""" from deepseek_harness import DeepSeekHarness diff --git a/tsconfig.host.json b/tsconfig.host.json index e2112b7f6a..254afc9535 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -103,6 +103,7 @@ { "path": "./packages/bash/bash" }, { "path": "./packages/pty/pty" }, { "path": "./packages/pty/pty-local" }, + { "path": "./packages/pty/tool-bash-persistent" }, { "path": "./packages/pty/tool-pty" }, { "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/code-runtime/code-runtime-worker" }, @@ -120,6 +121,7 @@ { "path": "./packages/fs/fs-sandbox" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/fs/tool-fs-search" }, + { "path": "./packages/fs/tool-str-replace-editor" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, { "path": "./packages/compact/compact-tool-result-prune" }, From d5e7212b31c0546ddb17eab466d7932c5e29fc13 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:23:34 +0800 Subject: [PATCH 02/46] fix(build): refresh node-pty patch hash --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b271ff848b..4c57d106a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 - node-pty@1.1.0: fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985 + node-pty@1.1.0: 4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15 importers: @@ -650,7 +650,7 @@ importers: devDependencies: node-pty: specifier: 1.1.0 - version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) + version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) packages/acp/acp: dependencies: @@ -3384,7 +3384,7 @@ importers: dependencies: node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985) + version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -15269,7 +15269,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-pty@1.1.0(patch_hash=fa5e4a788317512778f95ef1573fec565f9b601ce10383bbf103234db3e61985): + node-pty@1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15): dependencies: node-addon-api: 7.1.1 From dbdfb8d3b7de697a66c449172310868fb69a8f4b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 14:29:16 +0800 Subject: [PATCH 03/46] test(tools): include persistent tool schemas --- packages/core/tools/tests/gen-tool-catalog.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 3754595f56..1ab8bc2730 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { From 260ea24594613a00460c1e38a0dabf37579d8ba8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 15:21:56 +0800 Subject: [PATCH 04/46] fix(tools): harden persistent tool integrations --- docs/config-catalog.md | 12 +- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 4 +- examples/jsonrpc-agent/README.i18n.yaml | 6 +- examples/jsonrpc-agent/README.md | 13 + examples/jsonrpc-agent/README.zh.md | 13 + .../jsonrpc-agent/persistent-tools.cordis.yml | 58 ++++ .../tests/persistent-tools.snapshot.spec.ts | 214 +++++++++++++++ .../persistent-tools/behavior.expected.json | 57 ++++ examples/package.json | 2 + .../examples/agent-spine-demo/src/index.ts | 6 +- .../tool-str-replace-editor/README.i18n.yaml | 4 +- packages/fs/tool-str-replace-editor/README.md | 7 +- .../fs/tool-str-replace-editor/README.zh.md | 7 +- .../fs/tool-str-replace-editor/package.json | 6 + .../fs/tool-str-replace-editor/src/index.ts | 250 +++++++++++++++--- .../tests/tools.spec.ts | 131 ++++++++- .../fs/tool-str-replace-editor/tsconfig.json | 2 + .../pty/tool-bash-persistent/src/index.ts | 96 +++++-- .../tool-bash-persistent/tests/tools.spec.ts | 82 +++++- pnpm-lock.yaml | 18 ++ python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- scripts/build-exe-for-python-sdk.ts | 4 +- scripts/smoke-python-runtime.py | 35 ++- 26 files changed, 927 insertions(+), 112 deletions(-) create mode 100644 examples/jsonrpc-agent/persistent-tools.cordis.yml create mode 100644 examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1e3ed7b6ea..0e92e4e06e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -153,7 +153,11 @@ export interface Config { sessionTitle?: SessionTitleConfig /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ workspaceContext: workspaceContext.Config | false - /** Skill registry, local provider, and model-facing consumer config. */ + /** + * Skill registry, local provider, and model-facing consumer config. + * Skills use `enabled` because one nested config controls a provider stack; + * single model-tool plugins use `Config | false` to disable that one consumer. + */ skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false @@ -1589,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:340`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:382`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1762,10 +1766,12 @@ export interface Config { description?: string /** Require local absolute paths like the canonical editor contract (default true). */ requireAbsolutePath?: boolean + /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ + expandTabsOnMutation?: boolean } ``` -Source: [`packages/fs/tool-str-replace-editor/src/index.ts:373`](../packages/fs/tool-str-replace-editor/src/index.ts) +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:539`](../packages/fs/tool-str-replace-editor/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 63741d22a0..ba952ea8bb 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -26,9 +26,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 0c3b1f6280..ec2fbb2ccc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -670,6 +670,8 @@ flowchart TD pkg_tool_fs_search --> pkg_tools pkg_tool_str_replace_editor --> pkg_fs pkg_tool_str_replace_editor --> pkg_invariants + pkg_tool_str_replace_editor --> pkg_sandbox + pkg_tool_str_replace_editor --> pkg_sandbox_policy pkg_tool_str_replace_editor --> pkg_tools pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_invariants @@ -1095,7 +1097,7 @@ flowchart TD | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index 59c18e0131..1ff308b60f 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -README.md: 6ee4e9d824315bde76b7a534679f018df9a6d3e8 -README.zh.md: dc9b6233e7074e7a9b13bf10bcd2f310b0ad7bf3 +# pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md +README.md: 9a4c715e8988f52b647dbbd6b14a478cc1357d92 +README.zh.md: fa792f9cf6bdad4f32a980f478a131f19de97611 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index 6ee4e9d824..9a4c715e89 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -25,3 +25,16 @@ The surrounding runtime also loads JSONL session persistence and automatic conte | `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. + +## Persistent tools variant + +[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) is a minimal runnable variant whose model-facing surface is exactly: + +- owner-scoped persistent `bash` +- `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` + +It composes the real local PTY, filesystem intent policy, and session sandbox policy. The keyless behavior snapshot drives the shipped JSON-RPC runtime through both tools and proves that shell cwd/environment survive across calls: + +```bash +pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +``` diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index dc9b6233e7..fa792f9cf6 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -25,3 +25,16 @@ | `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | 通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件命名的每个插件;目标机器无需 Node.js。 + +## 持久工具变体 + +[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) 是一个最小可运行变体,面向模型的能力严格只有: + +- agent 独占、状态持久的 `bash` +- 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` + +它组合真实本地 PTY、文件系统 intent 策略与 session 沙箱策略。无密钥行为快照会通过正式 JSON-RPC runtime 驱动这两个工具,并验证 shell 的 cwd 与环境变量能跨调用保留: + +```bash +pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +``` diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml new file mode 100644 index 0000000000..b5ae81b100 --- /dev/null +++ b/examples/jsonrpc-agent/persistent-tools.cordis.yml @@ -0,0 +1,58 @@ +# Minimal unattended composition for the persistent Bash and string-replace +# editor. It is runnable through the JSON-RPC example runtime and intentionally +# keeps the model-facing surface to exactly these two tools. + +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() + +- id: pty + name: '@deepseek-ai/dsh-pty' + +- id: pty-local + name: '@deepseek-ai/dsh-pty-local' + +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + persona: 'You are a helpful software engineer assistant.' + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false + +- id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + compression: none diff --git a/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts b/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts new file mode 100644 index 0000000000..18d1454837 --- /dev/null +++ b/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts @@ -0,0 +1,214 @@ +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' + +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) +const configPath = fileURLToPath(new URL('../persistent-tools.cordis.yml', import.meta.url)) +const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const expectedPath = fileURLToPath(new URL('./snapshots/persistent-tools/behavior.expected.json', import.meta.url)) + +interface ModelRequest { + messages?: Array> + tools?: Array<{ function?: { name?: string; parameters?: { required?: string[] } } }> +} + +function sseToolCall(id: string, name: string, args: Record): string[] { + return [ + 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', + `data: ${JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id, + type: 'function', + function: { name, arguments: JSON.stringify(args) }, + }], + }, + }], + })}\n\n`, + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', + 'data: [DONE]\n\n', + ] +} + +function sseText(text: string): string[] { + return [ + 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', + `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`, + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', + 'data: [DONE]\n\n', + ] +} + +function messageText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content.flatMap((block) => { + if (typeof block !== 'object' || block === null) return [] + const text = (block as { text?: unknown }).text + return typeof text === 'string' ? [text] : [] + }).join('') +} + +function latestToolCall(messages: Array>): { id: string; name: string } { + for (const message of messages.toReversed()) { + const calls = message.tool_calls + if (!Array.isArray(calls)) continue + const call = (calls as unknown[]).at(-1) + if (typeof call !== 'object' || call === null) continue + const id = (call as { id?: unknown }).id + const fn = (call as { function?: { name?: unknown } }).function + if (typeof id === 'string' && typeof fn?.name === 'string') return { id, name: fn.name } + } + throw new Error('model request has no preceding tool call') +} + +function normalize(value: string, cwd: string): string { + return value.replaceAll(cwd, '{{cwd}}') +} + +describe('jsonrpc persistent tools snapshot', () => { + it('runs persistent shell state and editor mutations keylessly', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-persistent-tools-')) + const sessionRoot = join(cwd, '.sessions') + const target = join(cwd, 'note.txt') + const requests: ModelRequest[] = [] + const modelServer = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + const parsed = JSON.parse(body) as ModelRequest + requests.push(parsed) + const messages = parsed.messages ?? [] + const latest = messages.at(-1) + if (latest === undefined) throw new Error('model request has no messages') + let chunks: string[] + if (latest.role !== 'tool') { + chunks = sseToolCall('bash-1', 'bash', { + command: 'cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', + }) + } else { + const call = latestToolCall(messages) + const toolText = messageText(latest.content) + if (call.id === 'bash-1') { + expect(toolText).toContain('COUNT=1 CWD=/tmp') + chunks = sseToolCall('bash-2', 'bash', { + command: 'DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', + }) + } else if (call.id === 'bash-2') { + expect(toolText).toContain('COUNT=2 CWD=/tmp') + chunks = sseToolCall('editor-create', 'str_replace_editor', { + command: 'create', + path: target, + file_text: 'alpha\n', + }) + } else if (call.id === 'editor-create') { + expect(toolText).toContain('New file created successfully') + chunks = sseToolCall('editor-replace', 'str_replace_editor', { + command: 'str_replace', + path: target, + old_str: 'alpha', + new_str: 'beta', + }) + } else if (call.id === 'editor-replace') { + expect(toolText).toContain('has been edited successfully') + chunks = sseText('PERSISTENT_TOOLS_OK') + } else { + throw new Error(`unexpected tool call ${call.id}`) + } + } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const chunk of chunks) response.write(chunk) + response.end() + }) + }) + await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) + const address = modelServer.address() + if (address === null || typeof address === 'string') throw new Error('model server did not bind') + const launch = resolveExampleLaunch({ + srcBin: runtimeBin, + configArgs: [], + tsconfigPath: repoTsconfig, + }) + const harness = new DeepSeekHarness({ + launch: { + command: launch.command, + args: launch.args, + cwd: repoRoot, + env: { + ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, + ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, + DSH_CORDIS_CONFIG: configPath, + DSH_CWD: cwd, + DSH_SESSION_ROOT: sessionRoot, + DEEPSEEK_API_KEY: 'keyless-local-mock', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + requestTimeoutMs: 60_000, + }, + cwd, + provider: 'deepseek', + model: 'deepseek-v4-flash', + }) + + try { + const result = await harness.run( + 'Prove that bash state persists, then create and edit note.txt.', + { sessionId: 'persistent-tools-snapshot' }, + ) + const calls = result.events.flatMap((event) => { + if (event.type !== 'tool/call') return [] + return [{ + name: event.data.name, + arguments: normalize(event.data.arguments, cwd), + }] + }) + const results = result.events.flatMap((event) => { + if (event.type !== 'tool/result') return [] + return event.data.message.content.flatMap((block) => { + if (block.type !== 'tool-result') return [] + return block.content.flatMap(content => + content.type === 'text' + ? [{ text: normalize(content.text, cwd) }] + : []) + }) + }) + const tools = (requests[0]?.tools ?? []).map(tool => ({ + name: tool.function?.name, + required: tool.function?.parameters?.required ?? [], + })).sort((left, right) => { + const leftName = String(left.name) + const rightName = String(right.name) + return leftName < rightName ? -1 : leftName > rightName ? 1 : 0 + }) + const behavior = { + tools, + calls, + results, + final: { + status: result.status, + reason: result.reason, + response: result.finalResponse, + file: await readFile(target, 'utf8'), + }, + } + if (process.env.DSH_SNAPSHOT === 'refresh') { + await writeFile(expectedPath, `${JSON.stringify(behavior, null, 2)}\n`) + } + expect(behavior).toEqual(JSON.parse(await readFile(expectedPath, 'utf8'))) + } finally { + await harness.close() + await new Promise(resolve => modelServer.close(() => { resolve() })) + await rm(cwd, { recursive: true, force: true }) + } + }, 75_000) +}) diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json new file mode 100644 index 0000000000..18b54e9312 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json @@ -0,0 +1,57 @@ +{ + "tools": [ + { + "name": "bash", + "required": [ + "command" + ] + }, + { + "name": "str_replace_editor", + "required": [ + "command", + "path" + ] + } + ], + "calls": [ + { + "name": "bash", + "arguments": "{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" + }, + { + "name": "bash", + "arguments": "{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" + }, + { + "name": "str_replace_editor", + "arguments": "{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}" + }, + { + "name": "str_replace_editor", + "arguments": "{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}" + } + ], + "results": [ + { + "text": "COUNT=1 CWD=/tmp" + }, + { + "text": "COUNT=2 CWD=/tmp" + }, + { + "text": "New file created successfully at: {{cwd}}/note.txt" + }, + { + "text": "The file {{cwd}}/note.txt has been edited successfully." + } + ], + "final": { + "status": "ok", + "reason": { + "kind": "completed" + }, + "response": "PERSISTENT_TOOLS_OK", + "file": "beta\n" + } +} diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..d98371a5ef 100644 --- a/examples/package.json +++ b/examples/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-timeout-policy": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "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:*", @@ -64,6 +65,7 @@ "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index cfa5ac3ccd..32e348bf75 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -105,7 +105,11 @@ export interface Config { sessionTitle?: SessionTitleConfig /** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */ workspaceContext: workspaceContext.Config | false - /** Skill registry, local provider, and model-facing consumer config. */ + /** + * Skill registry, local provider, and model-facing consumer config. + * Skills use `enabled` because one nested config controls a provider stack; + * single model-tool plugins use `Config | false` to disable that one consumer. + */ skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index 1f72b1a211..10b1f19182 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -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 packages/fs/tool-str-replace-editor/README.md -README.md: 2d98b51d5651cbc72ab8b2055d8e70a43d98157b -README.zh.md: a2ee8f1e3661044c0869ae91af6ceedb2dd8da1d +README.md: 8ac6a22f24ddcdd3818b346e3426e58e718027e2 +README.zh.md: cf82b132b63730af209c159066a70f6a18b77f39 diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index 2d98b51d56..8ac6a22f24 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -11,6 +11,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w | `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. | | `description` | Editor command guide | Model-facing tool description. | | `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. | +| `expandTabsOnMutation` | `true` | Preserve the canonical Claude SWE behavior that expands tabs across the whole file before replace/insert. Set `false` for atomic literal replacement that preserves unrelated tabs. | ## Tool @@ -36,7 +37,7 @@ Prefix-stable while the configured description and schema remain unchanged. #### What the model sees -Views return numbered text or a shallow directory listing. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice. +Views return numbered text or a shallow directory listing. Calls expose file locations, and create/replace calls expose diff cards to presentation surfaces. Mutations return concise confirmations. Long views keep their prefix and append a clipping notice. #### Token effect @@ -50,5 +51,5 @@ Append-only tool results follow the reusable request prefix. - Operations target UTF-8 text; binary files are unsupported. - `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument. -- Canonical mode expands tabs before replacement or insertion, matching the reference string-replacement editor. -- The package delegates security and read-before-edit policy to the mounted filesystem and policy plugins. +- Canonical mode (`expandTabsOnMutation: true`) expands tabs in the entire file before replacement or insertion, including lines outside the edited region. Set it to `false` for Makefiles and other tab-sensitive files. +- Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins. diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index a2ee8f1e36..cf82b132b6 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -11,6 +11,7 @@ | `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 | | `description` | 编辑器命令指南 | 面向模型的工具描述。 | | `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 | +| `expandTabsOnMutation` | `true` | 保留 Claude SWE 参考行为:替换/插入前展开整个文件的制表符。设为 `false` 时使用原子字面量替换,并保留未触及的制表符。 | ## 工具 @@ -36,7 +37,7 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使 #### 模型所见 -查看操作返回带行号文本或浅层目录列表。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。 +查看操作返回带行号文本或浅层目录列表。调用会向展示层提供文件位置,创建/替换还会提供 diff 卡片。修改操作返回简洁确认。长查看结果保留前缀并追加截断提示。 #### Token 影响 @@ -50,5 +51,5 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使 - 操作面向 UTF-8 文本,不支持二进制文件。 - `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。 -- 规范模式会在替换或插入前展开制表符,与参考字符串替换编辑器保持一致。 -- 安全与先读后改策略委托给挂载的文件系统和策略插件。 +- 规范模式(`expandTabsOnMutation: true`)会在替换或插入前展开整个文件中的制表符,包括未编辑区域。Makefile 等依赖制表符的文件应设为 `false`。 +- 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。 diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 88d3af8d53..0fc6e54ec6 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -28,6 +28,8 @@ "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -38,8 +40,12 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index c4a16e5437..5d9855c1da 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -7,9 +7,12 @@ import { isAbsolute } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs' +import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' +import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' +import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type { ToolCallView, ToolRunContext } from '@deepseek-ai/dsh-tools' const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' @@ -49,17 +52,68 @@ function expandTabs(content: string, tabSize = 8): string { return result } +function codepointCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +function matchOffsets(content: string, search: string): number[] { + const offsets: number[] = [] + let offset = 0 + while (true) { + const match = content.indexOf(search, offset) + if (match < 0) return offsets + offsets.push(match) + offset = match + search.length + } +} + +function lineNumbersAt(content: string, offsets: readonly number[]): number[] { + let line = 1 + let cursor = 0 + return offsets.map((offset) => { + while (cursor < offset) { + if (content[cursor] === '\n') line += 1 + cursor += 1 + } + return line + }) +} + +class MutationPolicy { + private readonly policy: SandboxPolicyService | undefined + + constructor(ctx: Context) { + this.policy = ctx.fs.sandboxMode === undefined ? undefined : ctx.get('sandboxPolicy') + if (ctx.fs.sandboxMode !== undefined && this.policy === undefined) { + throw new Error('tool-str-replace-editor: the mounted filesystem confines but ctx.sandboxPolicy is missing') + } + } + + resolve(exec: ToolRunContext): SandboxExecutionPolicy | undefined { + return this.policy?.resolve({ + ...exec.agent === undefined ? {} : { session: exec.agent.session }, + }) + } + + mapError(error: unknown, policy: SandboxExecutionPolicy | undefined): unknown { + if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error + const mode = (policy as SandboxExecutionPolicy).mode + return new FsError(sandboxDenialMarker(mode), 'FS_SANDBOX_DENIED', { cause: error }) + } +} + async function resolveTarget( ctx: Context, path: string, requireAbsolutePath: boolean, exec: ToolRunContext, + workspaceRoot?: string, ): Promise { if (path.trim().length === 0) throw new Error('path must be a non-empty string') if (requireAbsolutePath && !isAbsolute(path)) { throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) } - const cwd = exec.agent?.session.header.cwd + const cwd = exec.agent?.session.header.cwd ?? workspaceRoot return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal }) } @@ -158,8 +212,8 @@ async function listDirectory( const rows: string[] = [] for (const entry of entries.filter(candidate => !candidate.name.startsWith('.') - && !candidate.name.startsWith('node_modules') - && !candidate.name.startsWith('__pycache__'))) { + && candidate.name !== 'node_modules' + && candidate.name !== '__pycache__')) { const type = entry.type === 'directory' ? 'd' : entry.type === 'file' ? 'f' : '?' rows.push(`${type}\t${entry.target.displayPath}`) if (entry.type === 'directory' && depth < 2) { @@ -172,7 +226,7 @@ async function listDirectory( rows.sort((left, right) => { const leftPath = left.slice(left.indexOf('\t') + 1) const rightPath = right.slice(right.indexOf('\t') + 1) - return leftPath.localeCompare(rightPath) + return codepointCompare(leftPath, rightPath) }) const listing = maybeTruncate(rows.join('\n') + '\n', maxOutputChars) return `Here're the files and directories up to 2 levels deep in ${target.displayPath}, excluding hidden items, node_modules, and Python cache directories:\n${listing}\n` @@ -204,78 +258,124 @@ async function viewPath( async function createFile( ctx: Context, + policy: MutationPolicy, path: string, fileText: string | undefined, requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { const content = requiredForCommand(fileText, 'file_text', 'create') - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const sandboxPolicy = policy.resolve(exec) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) if (await ctx.fs.stat(target, exec.signal) !== undefined) { throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) } - const outcome = await ctx.fs.writeText(target, content, { kind: 'createIfAbsent' }, exec.signal) + const intent = await ctx.waterfall( + 'fs/write-intent', + target, + exec, + () => ({ kind: 'createIfAbsent' } as const), + ) + let outcome + try { + outcome = await ctx.fs.writeText( + target, + content, + intent ?? { kind: 'createIfAbsent' }, + exec.signal, + sandboxPolicy, + ) + } catch (error: unknown) { + throw policy.mapError(error, sandboxPolicy) + } ctx.emit('fs/observed', target, outcome.version, exec) return `New file created successfully at: ${target.displayPath}` } async function replaceInFile( ctx: Context, + policy: MutationPolicy, path: string, oldStr: string | undefined, newStr: string | undefined, requireAbsolutePath: boolean, + expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) - const oldValue = expandTabs(requiredForCommand(oldStr, 'old_str', 'str_replace', false)) - const newValue = expandTabs(newStr ?? '') + const sandboxPolicy = policy.resolve(exec) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) + const rawOldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) + const oldValue = expandTabsOnMutation ? expandTabs(rawOldValue) : rawOldValue + const newValue = expandTabsOnMutation ? expandTabs(newStr ?? '') : newStr ?? '' const info = await statExisting(ctx, target, 'str_replace', exec) if (info.type !== 'file') { throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const before = expandTabs(await ctx.fs.readText(target, exec.signal)) - const occurrences = before.split(oldValue).length - 1 - if (occurrences === 0) { + const rawBefore = await ctx.fs.readText(target, exec.signal) + const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore + const offsets = matchOffsets(before, oldValue) + if (offsets.length === 0) { throw new FsError( `No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`, 'FS_EDIT_NOT_FOUND', ) } - if (occurrences > 1) { - const lines = before.split('\n') - .flatMap((line, index) => line.includes(oldValue) ? [index + 1] : []) + if (offsets.length > 1) { + const lines = lineNumbersAt(before, offsets) throw new FsError( `No replacement was performed. Multiple occurrences of old_str \`${oldValue}\` in lines [${lines.join(', ')}]. Please ensure it is unique`, 'FS_AMBIGUOUS_EDIT', ) } - const outcome = await ctx.fs.writeText( - target, - before.replace(oldValue, newValue), - { kind: 'replaceIfVersion', version: info.version }, - exec.signal, - ) + let outcome + try { + outcome = expandTabsOnMutation + ? await ctx.fs.writeText( + target, + before.replace(oldValue, newValue), + intent === undefined + ? { kind: 'replaceIfVersion', version: info.version } + : { kind: 'replaceIfVersion', version: intent.version }, + exec.signal, + sandboxPolicy, + ) + : await ctx.fs.editText( + target, + { oldString: oldValue, newString: newValue, replaceAll: false }, + intent ?? { version: info.version }, + exec.signal, + sandboxPolicy, + ) + } catch (error: unknown) { + throw policy.mapError(error, sandboxPolicy) + } ctx.emit('fs/observed', target, outcome.version, exec) return `The file ${target.displayPath} has been edited successfully.` } async function insertInFile( ctx: Context, + policy: MutationPolicy, path: string, insertLine: number | undefined, newStr: string | undefined, requireAbsolutePath: boolean, + expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') - const value = expandTabs(requiredForCommand(newStr, 'new_str', 'insert')) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const rawValue = requiredForCommand(newStr, 'new_str', 'insert') + const value = expandTabsOnMutation ? expandTabs(rawValue) : rawValue + const sandboxPolicy = policy.resolve(exec) + const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const info = await statExisting(ctx, target, 'insert', exec) if (info.type !== 'file') { throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const before = expandTabs(await ctx.fs.readText(target, exec.signal)) + const rawBefore = await ctx.fs.readText(target, exec.signal) + const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore const lines = before.split('\n') if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { throw new Error( @@ -287,12 +387,15 @@ async function insertInFile( ...value.split('\n'), ...lines.slice(insertLine), ].join('\n') - const outcome = await ctx.fs.writeText( - target, - after, - { kind: 'replaceIfVersion', version: info.version }, - exec.signal, - ) + const expected: FsWriteIntent = intent === undefined + ? { kind: 'replaceIfVersion', version: info.version } + : { kind: 'replaceIfVersion', version: intent.version } + let outcome + try { + outcome = await ctx.fs.writeText(target, after, expected, exec.signal, sandboxPolicy) + } catch (error: unknown) { + throw policy.mapError(error, sandboxPolicy) + } ctx.emit('fs/observed', target, outcome.version, exec) return `The file ${target.displayPath} has been edited successfully.` } @@ -301,10 +404,59 @@ interface ResolvedConfig { maxOutputChars: number description: string requireAbsolutePath: boolean + expandTabsOnMutation: boolean +} + +function presentEditorCall(args: { + command: 'view' | 'create' | 'str_replace' | 'insert' + path: string + file_text?: string + insert_line?: number + new_str?: string + old_str?: string +}): ToolCallView { + switch (args.command) { + case 'view': + return { + card: 'generic', + title: `view ${args.path}`, + kind: 'read', + locations: [{ path: args.path }], + } + case 'create': + return { + card: 'diff', + title: `create ${args.path}`, + diffs: [{ path: args.path, oldText: null, newText: args.file_text ?? '' }], + locations: [{ path: args.path }], + } + case 'str_replace': + return { + card: 'diff', + title: `str_replace ${args.path}`, + diffs: [{ + path: args.path, + oldText: args.old_str ?? null, + newText: args.new_str ?? '', + }], + locations: [{ path: args.path }], + } + case 'insert': + return { + card: 'generic', + title: `insert ${args.path}`, + kind: 'edit', + locations: [{ + path: args.path, + ...args.insert_line === undefined ? {} : { line: Math.max(1, args.insert_line + 1) }, + }], + } + } } /** Register the model-facing `str_replace_editor` tool. */ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { + const policy = new MutationPolicy(ctx) ctx.tools.register(defineTool({ name: 'str_replace_editor', description: config.description, @@ -351,18 +503,32 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { case 'view': return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec) case 'create': - return createFile(ctx, args.path, args.file_text, config.requireAbsolutePath, exec) + return createFile(ctx, policy, args.path, args.file_text, config.requireAbsolutePath, exec) case 'str_replace': - return replaceInFile(ctx, args.path, args.old_str, args.new_str, config.requireAbsolutePath, exec) + return replaceInFile( + ctx, + policy, + args.path, + args.old_str, + args.new_str, + config.requireAbsolutePath, + config.expandTabsOnMutation, + exec, + ) case 'insert': - return insertInFile(ctx, args.path, args.insert_line, args.new_str, config.requireAbsolutePath, exec) + return insertInFile( + ctx, + policy, + args.path, + args.insert_line, + args.new_str, + config.requireAbsolutePath, + config.expandTabsOnMutation, + exec, + ) } }, - presentCall: args => ({ - card: 'generic', - title: `${args.command} ${args.path}`, - kind: args.command === 'view' ? 'read' : 'edit', - }), + presentCall: presentEditorCall, })) } @@ -377,6 +543,8 @@ export interface Config { description?: string /** Require local absolute paths like the canonical editor contract (default true). */ requireAbsolutePath?: boolean + /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ + expandTabsOnMutation?: boolean } /** Runtime configuration schema for the string-replacement editor tool. */ @@ -384,6 +552,7 @@ export const Config: z = z.object({ maxOutputChars: z.number().default(16_000), description: z.string().default(DEFAULT_DESCRIPTION), requireAbsolutePath: z.boolean().default(true), + expandTabsOnMutation: z.boolean().default(true), }) /** Register one `str_replace_editor` tool over `ctx.fs`. */ @@ -392,6 +561,7 @@ export function apply(ctx: Context, config: Config): void { maxOutputChars: config.maxOutputChars ?? 16_000, description: config.description ?? DEFAULT_DESCRIPTION, requireAbsolutePath: config.requireAbsolutePath ?? true, + expandTabsOnMutation: config.expandTabsOnMutation ?? true, } if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 7cf9ba5212..213161e147 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -9,6 +9,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' +import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' @@ -57,7 +60,10 @@ function call(ctx: Context, owner: Agent | undefined, args: unknown) { }) } -async function setup(config: ToolStrReplaceEditor.Config = {}) { +async function setup( + config: ToolStrReplaceEditor.Config = {}, + options: { fsPolicy?: boolean; sandboxMode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {}, +) { const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-')) roots.push(root) const ctx = new Context() @@ -65,7 +71,13 @@ async function setup(config: ToolStrReplaceEditor.Config = {}) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalFileSystem, { cwd: root }) + if (options.sandboxMode === undefined) { + await ctx.plugin(LocalFileSystem, { cwd: root }) + } else { + await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root }) + await ctx.plugin(SandboxedFileSystem, { cwd: root }) + } + if (options.fsPolicy === true) await ctx.plugin(FsPolicy) await ctx.plugin(ToolStrReplaceEditor, config) return { ctx, root, owner: agent(ctx, root) } } @@ -85,13 +97,38 @@ describe('tool-str-replace-editor', () => { expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'view', path: '/workspace/a.txt', - })).toMatchObject({ card: 'generic', kind: 'read' }) + })).toMatchObject({ + card: 'generic', + kind: 'read', + locations: [{ path: '/workspace/a.txt' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'create', + path: '/workspace/a.txt', + file_text: 'hello', + })).toMatchObject({ + card: 'diff', + diffs: [{ path: '/workspace/a.txt', oldText: null, newText: 'hello' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'str_replace', + path: '/workspace/a.txt', + old_str: 'old', + new_str: 'new', + })).toMatchObject({ + card: 'diff', + diffs: [{ path: '/workspace/a.txt', oldText: 'old', newText: 'new' }], + }) expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ command: 'insert', path: '/workspace/a.txt', insert_line: 0, new_str: 'x', - })).toMatchObject({ card: 'generic', kind: 'edit' }) + })).toMatchObject({ + card: 'generic', + kind: 'edit', + locations: [{ path: '/workspace/a.txt', line: 1 }], + }) }) it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { @@ -136,16 +173,20 @@ describe('tool-str-replace-editor', () => { }) it('lists visible entries to depth two and clips at the configured view limit', async () => { - const { ctx, root, owner } = await setup({ maxOutputChars: 10 }) + const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 }) await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true }) await mkdir(join(root, 'dir', 'node_modules', 'pkg'), { recursive: true }) + await mkdir(join(root, 'dir', 'node_modules_old'), { recursive: true }) await mkdir(join(root, 'dir', '__pycache__'), { recursive: true }) + await mkdir(join(root, 'dir', '__pycache__backup'), { recursive: true }) await writeFile(join(root, 'dir', 'visible.txt'), 'ok') await writeFile(join(root, 'dir', '.hidden'), 'hidden') await writeFile(join(root, 'dir', 'nested', 'child.txt'), 'child') await writeFile(join(root, 'dir', 'nested', 'third', 'too-deep.txt'), 'deep') await writeFile(join(root, 'dir', 'node_modules', 'pkg', 'index.js'), 'hidden dependency') + await writeFile(join(root, 'dir', 'node_modules_old', 'kept.js'), 'visible source') await writeFile(join(root, 'dir', '__pycache__', 'module.pyc'), 'cache') + await writeFile(join(root, 'dir', '__pycache__backup', 'kept.py'), 'visible source') const listDir = ctx.fs.listDir.bind(ctx.fs) const otherTarget = await ctx.fs.resolve(join(root, 'dir', 'other')) ctx.fs.listDir = async (target, signal) => { @@ -156,14 +197,19 @@ describe('tool-str-replace-editor', () => { } const listing = text(await call(ctx, owner, { command: 'view', path: join(root, 'dir') })) - expect(listing).toContain('') expect(listing).not.toContain('.hidden') expect(listing).not.toContain('too-deep.txt') expect(listing).not.toContain('index.js') expect(listing).not.toContain('module.pyc') + expect(listing).toContain('node_modules_old/kept.js') + expect(listing).toContain('__pycache__backup/kept.py') - await writeFile(join(root, 'large.txt'), 'x'.repeat(100)) - expect(text(await call(ctx, owner, { command: 'view', path: join(root, 'large.txt') }))) + const clipped = await setup({ maxOutputChars: 10 }) + await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100)) + expect(text(await call(clipped.ctx, clipped.owner, { + command: 'view', + path: join(clipped.root, 'large.txt'), + }))) .toContain('') }) @@ -233,10 +279,20 @@ describe('tool-str-replace-editor', () => { expect(text(repeated)).toContain('Multiple occurrences of old_str `same` in lines [1, 3]') expect(text(repeated)).not.toContain('replace_all') + await writeFile(ambiguous, 'alpha\nbeta\nmiddle\nalpha\nbeta') + const repeatedMultiline = await call(ctx, owner, { + command: 'str_replace', + path: ambiguous, + old_str: 'alpha\nbeta', + new_str: 'x', + }) + expect(text(repeatedMultiline)) + .toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]') + const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) expect(relative.isError).toBe(true) expect(text(relative)).toContain('is not an absolute path') - expect(await readFile(ambiguous, 'utf8')).toBe('same\nother\nsame') + expect(await readFile(ambiguous, 'utf8')).toBe('alpha\nbeta\nmiddle\nalpha\nbeta') }) it('reports invalid commands or arguments without mutating files', async () => { @@ -302,6 +358,63 @@ describe('tool-str-replace-editor', () => { .toContain("Here's the content of") }) + it('delegates read-before-edit decisions to fs-policy', async () => { + const { ctx, root, owner } = await setup({}, { fsPolicy: true }) + const existing = join(root, 'existing.txt') + const created = join(root, 'created.txt') + await writeFile(existing, 'before') + + const blindEdit = await call(ctx, owner, { + command: 'str_replace', + path: existing, + old_str: 'before', + new_str: 'after', + }) + expect(blindEdit.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) + expect(await readFile(existing, 'utf8')).toBe('before') + + await call(ctx, owner, { command: 'view', path: existing }) + expect((await call(ctx, owner, { + command: 'str_replace', + path: existing, + old_str: 'before', + new_str: 'after', + })).isError).toBe(false) + expect(await readFile(existing, 'utf8')).toBe('after') + + expect((await call(ctx, owner, { + command: 'create', + path: created, + file_text: 'new', + })).isError).toBe(false) + expect(await readFile(created, 'utf8')).toBe('new') + }) + + it('passes the session sandbox policy to every mutation', async () => { + const { ctx, root, owner } = await setup({}, { sandboxMode: 'read-only' }) + const path = join(root, 'blocked.txt') + const result = await call(ctx, owner, { + command: 'create', + path, + file_text: 'blocked', + }) + expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) + expect(text(result)).toContain('[sandbox: file access denied under read-only mode]') + }) + + it('can preserve tabs outside the edited region', async () => { + const { ctx, root, owner } = await setup({ expandTabsOnMutation: false }) + const path = join(root, 'Makefile') + await writeFile(path, 'target:\n\told\n') + await call(ctx, owner, { + command: 'str_replace', + path, + old_str: 'old', + new_str: 'new', + }) + expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n') + }) + it('rejects invalid plugin config', () => { expect(() => { ToolStrReplaceEditor.apply(new Context(), { maxOutputChars: 0 }) diff --git a/packages/fs/tool-str-replace-editor/tsconfig.json b/packages/fs/tool-str-replace-editor/tsconfig.json index 2c6eb3688c..6ee0dc19eb 100644 --- a/packages/fs/tool-str-replace-editor/tsconfig.json +++ b/packages/fs/tool-str-replace-editor/tsconfig.json @@ -9,6 +9,8 @@ { "path": "../../../vendor/cordis" }, { "path": "../../core/tools" }, { "path": "../fs" }, + { "path": "../../sandbox/sandbox" }, + { "path": "../../sandbox/sandbox-policy" }, { "path": "../../support/invariants" } ] } diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index b812fe8992..8ba51388cf 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -16,7 +16,10 @@ const LOST_PREFIX_MESSAGE = 'The beginning of this comma const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ ' const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' +// One page is enough to find a just-emitted completion marker; the full +// scrollback is assembled only when a command settles or needs partial output. const SCROLLBACK_PAGE_LINES = 1_000 +const POLL_INTERVAL_MS = 25 const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.' @@ -101,7 +104,7 @@ function commandOutput( const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), - incomplete: startMarker < 0 || snapshot.truncated, + incomplete: startMarker < 0, } } @@ -115,22 +118,29 @@ function partialOutput( snapshot: RetainedOutput, marker: CommandMarkers, fallback: string, + fallbackTruncated = false, ): CapturedOutput { const startMarker = snapshot.text.lastIndexOf(marker.start) if (startMarker >= 0) { return { text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), - incomplete: snapshot.truncated, + incomplete: false, } } + const fallbackStart = fallback.lastIndexOf(marker.start) + const afterStart = fallbackStart < 0 + ? fallback + : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '') + const fallbackEnd = afterStart.lastIndexOf(marker.end) + const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd) return { - text: stripPrompt(fallback), - incomplete: snapshot.truncated, + text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')), + incomplete: fallbackTruncated || fallbackStart < 0, } } async function pause(): Promise { - await new Promise(resolve => setTimeout(resolve, 25)) + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) } function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { @@ -142,11 +152,13 @@ function retainedScrollback( ctx: Context, owner: Agent, id: PtySessionId, + latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), ): RetainedOutput { - const pages: string[] = [] - let offset = 0 - let truncated = false + const pages: string[] = latest.text.length === 0 ? [] : [latest.text] + let offset = latest.lineEnd + let truncated = latest.truncated while (true) { + if (offset >= latest.totalLines) break const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) truncated ||= page.truncated if (page.text.length > 0) pages.unshift(page.text) @@ -167,7 +179,10 @@ function renderCaptured(output: CapturedOutput, maxOutputChars: number): string function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { const pending = new WeakMap>() const live = new Map() + const creating = new Set>() const ownerCleanupInstalled = new WeakSet() + const lifecycle = new AbortController() + let disposed = false const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return @@ -175,6 +190,9 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } ctx.effect(() => async () => { + disposed = true + lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation')) + await Promise.allSettled([...creating]) const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') }) await Promise.all(closing) live.clear() @@ -188,15 +206,17 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } const get = (owner: Agent, signal: AbortSignal): Promise => { + if (disposed) return Promise.reject(new Error('tool-bash-persistent is disposed')) const existing = pending.get(owner) if (existing !== undefined) return existing - const creating = (async () => { + const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) + const creation = (async () => { try { const cwd = owner.session.header.cwd const spawned = await ctx.pty.spawn(owner, { type: config.backendType, ...cwd === undefined ? {} : { cwd }, - }, signal) + }, combinedSignal) live.set(owner, spawned.sessionId) if (!ownerCleanupInstalled.has(owner)) { ownerCleanupInstalled.add(owner) @@ -208,7 +228,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell const setup = ctx.pty.startSend(owner, spawned.sessionId, { text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`, submit: true, - signal, + signal: combinedSignal, }) const result = await setup.done if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { @@ -220,8 +240,12 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell throw error } })() - pending.set(owner, creating) - return creating + const tracked = creation.finally(() => { + creating.delete(tracked) + }) + creating.add(tracked) + pending.set(owner, tracked) + return tracked } return { get, reset } @@ -241,21 +265,32 @@ async function executeCommand( const wrapped = wrapCommand(command, marker) let first = true let fallback = '' + let fallbackTruncated = false while (true) { - const operation = ctx.pty.startSend(owner, id, { - text: first ? wrapped : '', - submit: first, - signal: commandDeadline.signal, - }) - first = false - const result = await operation.done - fallback += result.viewport - const snapshot = retainedScrollback(ctx, owner, id) + let operation + let result + try { + operation = ctx.pty.startSend(owner, id, { + text: first ? wrapped : '', + submit: first, + signal: commandDeadline.signal, + }) + first = false + result = await operation.done + } catch (error: unknown) { + await shells.reset(owner, 'persistent bash send failed') + throw error + } + const incremental = operation.readOutput() + fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport + fallbackTruncated ||= incremental.truncated || result.truncated + const latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) if (timedOut !== undefined) { + const snapshot = retainedScrollback(ctx, owner, id, latest) const partial = renderCaptured( - partialOutput(snapshot, marker, fallback), + partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars, ) await shells.reset(owner, 'persistent bash command timed out') @@ -265,12 +300,15 @@ async function executeCommand( SHELL_RESET_MESSAGE, ].join('\n') } - const complete = commandOutput(snapshot, marker) - if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + if (latest.text.includes(marker.end)) { + const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + } if (result.sessionStatus.kind === 'exited') { + const snapshot = retainedScrollback(ctx, owner, id, latest) await shells.reset(owner, 'persistent bash shell exited') return [ - renderCaptured(partialOutput(snapshot, marker, fallback), config.maxOutputChars), + renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), SHELL_RESET_MESSAGE, ].filter(part => part.length > 0).join('\n') } @@ -279,7 +317,11 @@ async function executeCommand( commandDeadline.signal.throwIfAborted() } if (promptCompleted(result)) { - return maybeTruncate(stripPrompt(fallback), config.maxOutputChars, result.truncated) + const snapshot = retainedScrollback(ctx, owner, id, latest) + return renderCaptured( + partialOutput(snapshot, marker, fallback, fallbackTruncated), + config.maxOutputChars, + ) } await pause() } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index f757d6d1f8..3863ec29ce 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -85,6 +85,8 @@ type StubMode = | 'init-exit' | 'init-timeout' | 'spawn-error' + | 'send-error' + | 'prompt-after-idle' class StubPtySession implements PtyBackendSession { readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' @@ -95,6 +97,7 @@ class StubPtySession implements PtyBackendSession { mode: StubMode sends = 0 pendingText = '' + historyTruncated = false constructor(mode: StubMode) { this.mode = mode @@ -112,6 +115,7 @@ class StubPtySession implements PtyBackendSession { } return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } + if (this.mode === 'send-error') throw new Error('stub send failed') if (this.mode === 'wait-for-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { @@ -126,6 +130,17 @@ class StubPtySession implements PtyBackendSession { this.pendingText = request.text return this.operation(Promise.resolve(this.result('', 'inferred_idle'))) } + if (this.mode === 'prompt-after-idle') { + if (request.text.length > 0) { + const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0] + const output = `${start ?? ''}\npartial syntax output\n` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + const output = `bash: syntax error\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') { const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n' const output = `bash: syntax error${newline}${this.motd}${newline}` @@ -166,7 +181,7 @@ class StubPtySession implements PtyBackendSession { totalLines: lines.length, lineBegin: 0, lineEnd: lines.length, - truncated: false, + truncated: this.historyTruncated, } } @@ -316,6 +331,29 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') }) + it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'prompt-after-idle' + session.scrollback = '' + const result = text(await call(ctx, owner, 'bad {')) + expect(result).toContain('partial syntax output') + expect(result).toContain('bash: syntax error') + expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT') + expect(result).not.toContain('DSH_PERSISTENT_BASH_START') + }) + + it('does not attribute old scrollback truncation to a complete current command', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.historyTruncated = true + const result = text(await call(ctx, owner, 'short command')) + expect(result).toBe('hello from stub') + expect(result).not.toContain('') + expect(result).not.toContain('beginning of this command output was dropped') + }) + it('closes a timed-out shell and reports bounded partial output', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 }) await call(ctx, owner, 'warm up') @@ -359,6 +397,48 @@ describe('tool-bash-persistent', () => { expect(stub.sessions).toHaveLength(0) }) + it('resets a cached shell after startSend fails', async () => { + const { ctx, owner, stub } = await setup() + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'send-error' + expect((await call(ctx, owner, 'fails')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent bash send failed') + expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + + it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const spawnStarted = Promise.withResolvers() + const spawnAborted = Promise.withResolvers() + ctx.pty.registerBackend({ + type: 'slow', + spawn: spec => new Promise((_resolve, reject) => { + spawnStarted.resolve(undefined) + spec.signal?.addEventListener('abort', () => { + spawnAborted.resolve(undefined) + const reason: unknown = spec.signal?.reason + reject(reason instanceof Error + ? reason + : new Error('slow PTY spawn aborted', { cause: reason })) + }, { once: true }) + }), + }) + const fiber = await ctx.plugin(ToolBashPersistent, { backendType: 'slow' }) + const owner = agent(ctx, '/workspace') + const running = call(ctx, owner, 'pwd') + await spawnStarted.promise + await fiber.dispose() + await spawnAborted.promise + expect((await running).isError).toBe(true) + expect(ctx.pty.list(owner)).toEqual([]) + }) + it('rejects invalid config and invalid calls', async () => { const { ctx, owner, stub } = await setup() expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c57d106a6..73092a292c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -593,6 +593,9 @@ importers: '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:* version: link:../packages/ui/tool-ask-user + '@deepseek-ai/dsh-tool-bash-persistent': + specifier: workspace:* + version: link:../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../packages/cordis/tool-cordis @@ -617,6 +620,9 @@ importers: '@deepseek-ai/dsh-tool-session-query': specifier: workspace:* version: link:../packages/session-query/tool-session-query + '@deepseek-ai/dsh-tool-str-replace-editor': + specifier: workspace:* + version: link:../packages/fs/tool-str-replace-editor '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent @@ -2593,12 +2599,24 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../fs-sandbox '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 44fcf94b60..4b104ec211 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -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 python/sdk-runtime/README.md -README.md: 977bce41191d6c7716548dafde81d2c7ec14dec7 -README.zh.md: ade8455c56c27fcbe3e43a68abeaad98421cf720 +README.md: ee3791eddf26b526316d4f3952793a03cc48841e +README.zh.md: 59d40ee56688cb377902ff126b7fa77606c7ad8b diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 977bce4119..ee3791eddf 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -15,7 +15,7 @@ Both carriers hold the same content, defined once: the [package.json](package.js A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. -Each wheel contains exactly one runtime executable and its matching native spawn helper. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. +Each wheel contains exactly one runtime executable and its matching native spawn helper. A missing sidecar makes the runtime installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools; old exe-only wheels are intentionally unsupported. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. ## Resolution API diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index ade8455c56..59d40ee566 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -15,7 +15,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。缺少伴随文件意味着运行时安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此;旧的仅 exe wheel 有意不再兼容。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 ## 解析 API diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 03c82b25b0..38400009ac 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -260,7 +260,9 @@ class SingleExeBuild { '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', - // The production closure intentionally omits patched dev-only packages. + // The production closure intentionally omits the patched dev-only + // @earendil-works/pi-tui package. The root frozen install still validates + // every patch; this exception is scoped only to the production deploy. '--config.allow-unused-patches=true', this.staging, ]) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 11500b5ec7..346818e5fa 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -27,7 +27,7 @@ WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value witho WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor." PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok" -PERSISTENT_EDITOR_PATH: str | None = None +PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: " PERSISTENT_BASH_COMMAND = ( "counter=$(( ${counter:-0} + 1 )); export counter; " "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " @@ -198,7 +198,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: raise AssertionError(f"unexpected tool follow-up: {tool_name}") prompt = message_text(latest.get("content")) - if prompt == PERSISTENT_TOOLS_PROMPT: + if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"): names = advertised_tool_names(body) if names != {"bash", "str_replace_editor"}: raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}") @@ -261,14 +261,27 @@ def persistent_tool_followup( if call_id == "persistent-bash-2" and tool_name == "bash": if "COUNT=2 CWD=/tmp" not in tool_text: raise AssertionError(f"persistent bash did not retain state: {tool_text}") - if PERSISTENT_EDITOR_PATH is None: - raise AssertionError("persistent editor smoke path was not initialized") + messages = body.get("messages") + if not isinstance(messages, list): + raise AssertionError("persistent editor smoke request has no messages") + editor_path = next( + ( + text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip() + for message in messages + if isinstance(message, dict) and message.get("role") == "user" + for text in [message_text(message.get("content"))] + if PERSISTENT_EDITOR_PATH_PREFIX in text + ), + None, + ) + if editor_path is None: + raise AssertionError("persistent editor smoke prompt has no editor path") return tool_call_chunks( "persistent-editor", "str_replace_editor", { "command": "create", - "path": PERSISTENT_EDITOR_PATH, + "path": editor_path, "file_text": "created by packaged editor\n", }, ) @@ -545,12 +558,12 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: """Exercise native PTY state and the editor through the packaged executable.""" - global PERSISTENT_EDITOR_PATH from deepseek_harness import DeepSeekHarness with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary: root = Path(temporary).resolve() - PERSISTENT_EDITOR_PATH = str(root / "created.txt") + editor_path = root / "created.txt" + prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}" sessions = root / "sessions" cordis = root / "cordis.yml" cordis.write_text(PERSISTENT_TOOLS_CORDIS) @@ -565,17 +578,15 @@ def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: base_url=base_url, request_timeout_seconds=60, ) as harness: - result = harness.run(PERSISTENT_TOOLS_PROMPT, session_id="persistent-tools-smoke") + result = harness.run(prompt, session_id="persistent-tools-smoke") assert result.status == "ok", result event_text = json.dumps(result.events) if PERSISTENT_TOOLS_TEXT not in event_text: raise AssertionError(f"packaged tools run emitted no final response: {result.events}") - created = root / "created.txt" - if created.read_text() != "created by packaged editor\n": - raise AssertionError(f"packaged editor wrote unexpected content: {created.read_text()!r}") + if editor_path.read_text() != "created by packaged editor\n": + raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}") assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") - PERSISTENT_EDITOR_PATH = None def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: From df58af92cda9b7d7a61e648ec212c07e31707c1d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 15:33:46 +0800 Subject: [PATCH 05/46] test(tools): close persistent tool coverage gaps --- docs/config-catalog.md | 2 +- .../fs/tool-str-replace-editor/src/index.ts | 2 +- .../tests/tools.spec.ts | 101 +++++++++++++++++- .../pty/tool-bash-persistent/src/index.ts | 15 +-- .../tool-bash-persistent/tests/tools.spec.ts | 13 ++- 5 files changed, 113 insertions(+), 20 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0e92e4e06e..2e6ffaf3d8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1593,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:382`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:373`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 5d9855c1da..7ff0773107 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -281,7 +281,7 @@ async function createFile( outcome = await ctx.fs.writeText( target, content, - intent ?? { kind: 'createIfAbsent' }, + intent, exec.signal, sandboxPolicy, ) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 213161e147..fb1e0d4b58 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -129,6 +129,24 @@ describe('tool-str-replace-editor', () => { kind: 'edit', locations: [{ path: '/workspace/a.txt', line: 1 }], }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'create', + path: '/workspace/empty.txt', + })).toMatchObject({ + diffs: [{ path: '/workspace/empty.txt', oldText: null, newText: '' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'str_replace', + path: '/workspace/a.txt', + })).toMatchObject({ + diffs: [{ path: '/workspace/a.txt', oldText: null, newText: '' }], + }) + expect(ctx.tools.get('str_replace_editor')?.presentCall?.({ + command: 'insert', + path: '/workspace/a.txt', + })).toMatchObject({ + locations: [{ path: '/workspace/a.txt' }], + }) }) it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { @@ -192,7 +210,11 @@ describe('tool-str-replace-editor', () => { ctx.fs.listDir = async (target, signal) => { const entries = await listDir(target, signal) return target.displayPath === join(root, 'dir') - ? [...entries, { name: 'other', type: 'other', target: otherTarget }] + ? [ + { name: 'same-target', type: 'other', target: otherTarget }, + { name: 'other', type: 'other', target: otherTarget }, + ...entries.toReversed(), + ] : entries } @@ -235,6 +257,11 @@ describe('tool-str-replace-editor', () => { command: 'view', path: plain, }))).toContain(' 1 one') + expect((await call(ctx, undefined, { + command: 'create', + path: join(root, 'ownerless.txt'), + file_text: 'ownerless', + })).isError).toBe(false) await call(ctx, owner, { command: 'insert', @@ -382,6 +409,14 @@ describe('tool-str-replace-editor', () => { })).isError).toBe(false) expect(await readFile(existing, 'utf8')).toBe('after') + expect((await call(ctx, owner, { + command: 'insert', + path: existing, + insert_line: 1, + new_str: 'tail', + })).isError).toBe(false) + expect(await readFile(existing, 'utf8')).toBe('after\ntail') + expect((await call(ctx, owner, { command: 'create', path: created, @@ -400,19 +435,79 @@ describe('tool-str-replace-editor', () => { }) expect(result.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) expect(text(result)).toContain('[sandbox: file access denied under read-only mode]') + + const ownerless = await call(ctx, undefined, { + command: 'create', + path: join(root, 'ownerless-blocked.txt'), + file_text: 'blocked', + }) + expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) }) it('can preserve tabs outside the edited region', async () => { const { ctx, root, owner } = await setup({ expandTabsOnMutation: false }) const path = join(root, 'Makefile') - await writeFile(path, 'target:\n\told\n') + await writeFile(path, 'target:\n\told\nremove\n') await call(ctx, owner, { command: 'str_replace', path, old_str: 'old', new_str: 'new', }) - expect(await readFile(path, 'utf8')).toBe('target:\n\tnew\n') + await call(ctx, owner, { + command: 'str_replace', + path, + old_str: 'remove\n', + }) + await call(ctx, owner, { + command: 'insert', + path, + insert_line: 1, + new_str: '\tkept', + }) + expect(await readFile(path, 'utf8')).toBe('target:\n\tkept\n\tnew\n') + }) + + it('reports missing sandbox-policy composition during plugin startup', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-tool-str-replace-editor-missing-policy-')) + roots.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalFileSystem, { cwd: root }) + Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' }) + + await expect(ctx.plugin(ToolStrReplaceEditor)) + .rejects.toThrow('the mounted filesystem confines but ctx.sandboxPolicy is missing') + }) + + it('maps unexpected backend write failures for replace and insert', async () => { + const { ctx, root, owner } = await setup() + const path = join(root, 'backend-error.txt') + await writeFile(path, 'old\n') + ctx.fs.writeText = async () => { + throw new Error('backend write failed') + } + + const replace = await call(ctx, owner, { + command: 'str_replace', + path, + old_str: 'old', + new_str: 'new', + }) + expect(replace.isError).toBe(true) + expect(text(replace)).toContain('backend write failed') + + const insert = await call(ctx, owner, { + command: 'insert', + path, + insert_line: 1, + new_str: 'new', + }) + expect(insert.isError).toBe(true) + expect(text(insert)).toContain('backend write failed') }) it('rejects invalid plugin config', () => { diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 8ba51388cf..136dcbaabd 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -81,12 +81,7 @@ function wrapCommand(command: string, marker: CommandMarkers): string { } function stripPrompt(text: string): string { - let result = text - while (result.endsWith(`${SHELL_PROMPT}\r\n`) || result.endsWith(`${SHELL_PROMPT}\n`)) { - result = result.slice(0, result.endsWith('\r\n') - ? -SHELL_PROMPT.length - 2 - : -SHELL_PROMPT.length - 1) - } + let result = text.replace(/\r?\n$/, '') while (result.endsWith(SHELL_PROMPT)) { result = result.slice(0, -SHELL_PROMPT.length) } @@ -96,10 +91,9 @@ function stripPrompt(text: string): string { function commandOutput( snapshot: RetainedOutput, marker: CommandMarkers, -): CapturedOutput | undefined { +): CapturedOutput { const text = snapshot.text const end = text.lastIndexOf(marker.end) - if (end < 0) return undefined const startMarker = text.lastIndexOf(marker.start, end) const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { @@ -182,7 +176,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell const creating = new Set>() const ownerCleanupInstalled = new WeakSet() const lifecycle = new AbortController() - let disposed = false const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return @@ -190,7 +183,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } ctx.effect(() => async () => { - disposed = true lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation')) await Promise.allSettled([...creating]) const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') }) @@ -206,7 +198,6 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell } const get = (owner: Agent, signal: AbortSignal): Promise => { - if (disposed) return Promise.reject(new Error('tool-bash-persistent is disposed')) const existing = pending.get(owner) if (existing !== undefined) return existing const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) @@ -302,7 +293,7 @@ async function executeCommand( } if (latest.text.includes(marker.end)) { const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) - if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { const snapshot = retainedScrollback(ctx, owner, id, latest) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 3863ec29ce..811d733f2f 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -87,6 +87,7 @@ type StubMode = | 'spawn-error' | 'send-error' | 'prompt-after-idle' + | 'empty-page-after-latest' class StubPtySession implements PtyBackendSession { readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' @@ -168,19 +169,22 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } - read(_request: PtyReadRequest) { + read(request: PtyReadRequest) { if (this.mode === 'empty-read') { return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } } if (this.mode === 'stalled-read') { return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false } } + if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) { + return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false } + } const lines = this.scrollback.split('\n') return { text: this.scrollback, - totalLines: lines.length, + totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length, lineBegin: 0, - lineEnd: lines.length, + lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length, truncated: this.historyTruncated, } } @@ -329,6 +333,9 @@ describe('tool-bash-persistent', () => { session.mode = 'stalled-read' expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') + + session.mode = 'empty-page-after-latest' + expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub') }) it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { From b9bc1c8a612f4235cabcd7c1259cd1ecf24bfe0b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 16:11:30 +0800 Subject: [PATCH 06/46] fix(python-sdk): read nested assistant messages --- python/sdk/src/deepseek_harness/api.py | 4 +++- python/sdk/tests/test_client.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 5986dc2cdc..29bb4223ac 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -191,7 +191,9 @@ def final_response(events: list[JsonObject]) -> str: data = event.get("data") if not isinstance(data, dict): continue - content = data.get("content") + message = data.get("message") + content_owner = message if isinstance(message, dict) else data + content = content_owner.get("content") if not isinstance(content, list): continue parts: list[str] = [] diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index de2927c598..52ceac9f4d 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -46,7 +46,12 @@ for line in sys.stdin: "sessionId": params["sessionId"], "event": { "type": "assistant/message", - "data": {"content": [{"type": "text", "text": "hello from runtime"}]}, + "data": { + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "hello from runtime"}], + }, + }, }, }, }), flush=True) From f8df313f218734a105fa547ef8df367047e299ca Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:24:33 +0800 Subject: [PATCH 07/46] fix(pty): preserve persistent bash exit status --- .../pty/tool-bash-persistent/src/index.ts | 26 ++++++++++++++++-- .../tool-bash-persistent/tests/tools.spec.ts | 27 +++++++++++++++---- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 136dcbaabd..6cadf75708 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -43,6 +43,7 @@ interface RetainedOutput { interface CapturedOutput { text: string incomplete: boolean + exitCode?: number } interface PersistentShells { @@ -94,11 +95,13 @@ function commandOutput( ): CapturedOutput { const text = snapshot.text const end = text.lastIndexOf(marker.end) + const exitCode = Number.parseInt(text.slice(end + marker.end.length), 10) const startMarker = text.lastIndexOf(marker.start, end) const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), incomplete: startMarker < 0, + exitCode, } } @@ -165,9 +168,24 @@ function retainedScrollback( function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) - return output.incomplete && output.text.length > 0 + const withPrefix = output.incomplete && output.text.length > 0 ? LOST_PREFIX_MESSAGE + rendered : rendered + return renderExitStatus(withPrefix, output.exitCode ?? 0, null) +} + +function renderExitStatus( + content: string, + exitCode: number | null, + signal: NodeJS.Signals | null, +): string { + const marker = signal !== null + ? `[killed by signal: ${signal}]` + : exitCode !== null && exitCode !== 0 + ? `[exit code: ${exitCode}]` + : undefined + if (marker === undefined) return content + return content.length === 0 ? marker : `${content}\n${marker}` } function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { @@ -299,7 +317,11 @@ async function executeCommand( const snapshot = retainedScrollback(ctx, owner, id, latest) await shells.reset(owner, 'persistent bash shell exited') return [ - renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + renderExitStatus( + renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + result.sessionStatus.exitCode, + result.sessionStatus.signal, + ), SHELL_RESET_MESSAGE, ].filter(part => part.length > 0).join('\n') } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 811d733f2f..990004c1c9 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -78,9 +78,11 @@ type StubMode = | 'empty-read' | 'stalled-read' | 'exit' + | 'signal-exit' | 'wait-for-abort' | 'idle-then-normal' | 'large' + | 'nonzero' | 'end-only' | 'init-exit' | 'init-timeout' @@ -157,13 +159,18 @@ class StubPtySession implements PtyBackendSession { this.scrollback += output return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } - const commandOutput = this.mode === 'large' ? 'x'.repeat(100) : 'hello from stub' - const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}0\n${this.motd}` + const commandOutput = this.mode === 'large' + ? 'x'.repeat(100) + : this.mode === 'nonzero' ? '' : 'hello from stub' + const exitCode = this.mode === 'nonzero' ? 7 : 0 + const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` this.scrollback += output - if (this.mode === 'exit') { + if (this.mode === 'exit' || this.mode === 'signal-exit') { const exitedOutput = `${start ?? ''}\nhello from stub\n` this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput - this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + this.statusValue = this.mode === 'signal-exit' + ? { kind: 'exited', exitCode: null, signal: 'SIGTERM' } + : { kind: 'exited', exitCode: 9, signal: null } return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) } return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) @@ -303,19 +310,29 @@ describe('tool-bash-persistent', () => { session.mode = 'large' expect(text(await call(ctx, owner, 'large'))).toContain('') + session.mode = 'nonzero' + expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]') + session.mode = 'exit' const exited = text(await call(ctx, owner, 'exit')) expect(exited).toContain('hello from') + expect(exited).toContain('[exit code: 9]') expect(exited).toContain('next bash call starts from the workspace') expect(session.closed).toContain('persistent bash shell exited') await call(ctx, owner, 'new shell') expect(stub.sessions).toHaveLength(2) + const replacement = stub.sessions[1]! + replacement.mode = 'signal-exit' + expect(text(await call(ctx, owner, 'kill shell'))).toContain('[killed by signal: SIGTERM]') + + await call(ctx, owner, 'another shell') + expect(stub.sessions).toHaveLength(3) const externallyClosed = ctx.pty.list(owner)[0]?.sessionId expect(externallyClosed).toBeDefined() await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') await fiber.dispose() - expect(stub.sessions[1]?.closed).toEqual(['external cleanup']) + expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { From 6b26126b3a743e976d3326300021dc6c205fdfdf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:25:56 +0800 Subject: [PATCH 08/46] fix(fs): preserve tabs during editor mutations --- .../fs/tool-str-replace-editor/src/index.ts | 47 +++++-------------- .../tests/tools.spec.ts | 8 ++-- 2 files changed, 17 insertions(+), 38 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 7ff0773107..98bfd6528d 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -299,21 +299,18 @@ async function replaceInFile( oldStr: string | undefined, newStr: string | undefined, requireAbsolutePath: boolean, - expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) - const rawOldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) - const oldValue = expandTabsOnMutation ? expandTabs(rawOldValue) : rawOldValue - const newValue = expandTabsOnMutation ? expandTabs(newStr ?? '') : newStr ?? '' + const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) + const newValue = newStr ?? '' const info = await statExisting(ctx, target, 'str_replace', exec) if (info.type !== 'file') { throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const rawBefore = await ctx.fs.readText(target, exec.signal) - const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore + const before = await ctx.fs.readText(target, exec.signal) const offsets = matchOffsets(before, oldValue) if (offsets.length === 0) { throw new FsError( @@ -330,23 +327,13 @@ async function replaceInFile( } let outcome try { - outcome = expandTabsOnMutation - ? await ctx.fs.writeText( - target, - before.replace(oldValue, newValue), - intent === undefined - ? { kind: 'replaceIfVersion', version: info.version } - : { kind: 'replaceIfVersion', version: intent.version }, - exec.signal, - sandboxPolicy, - ) - : await ctx.fs.editText( - target, - { oldString: oldValue, newString: newValue, replaceAll: false }, - intent ?? { version: info.version }, - exec.signal, - sandboxPolicy, - ) + outcome = await ctx.fs.editText( + target, + { oldString: oldValue, newString: newValue, replaceAll: false }, + intent ?? { version: info.version }, + exec.signal, + sandboxPolicy, + ) } catch (error: unknown) { throw policy.mapError(error, sandboxPolicy) } @@ -361,12 +348,10 @@ async function insertInFile( insertLine: number | undefined, newStr: string | undefined, requireAbsolutePath: boolean, - expandTabsOnMutation: boolean, exec: ToolRunContext, ): Promise { if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') - const rawValue = requiredForCommand(newStr, 'new_str', 'insert') - const value = expandTabsOnMutation ? expandTabs(rawValue) : rawValue + const value = requiredForCommand(newStr, 'new_str', 'insert') const sandboxPolicy = policy.resolve(exec) const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) @@ -374,8 +359,7 @@ async function insertInFile( if (info.type !== 'file') { throw new FsError(`cannot insert into "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - const rawBefore = await ctx.fs.readText(target, exec.signal) - const before = expandTabsOnMutation ? expandTabs(rawBefore) : rawBefore + const before = await ctx.fs.readText(target, exec.signal) const lines = before.split('\n') if (!Number.isInteger(insertLine) || insertLine < 0 || insertLine > lines.length) { throw new Error( @@ -404,7 +388,6 @@ interface ResolvedConfig { maxOutputChars: number description: string requireAbsolutePath: boolean - expandTabsOnMutation: boolean } function presentEditorCall(args: { @@ -512,7 +495,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.old_str, args.new_str, config.requireAbsolutePath, - config.expandTabsOnMutation, exec, ) case 'insert': @@ -523,7 +505,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.insert_line, args.new_str, config.requireAbsolutePath, - config.expandTabsOnMutation, exec, ) } @@ -543,8 +524,6 @@ export interface Config { description?: string /** Require local absolute paths like the canonical editor contract (default true). */ requireAbsolutePath?: boolean - /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ - expandTabsOnMutation?: boolean } /** Runtime configuration schema for the string-replacement editor tool. */ @@ -552,7 +531,6 @@ export const Config: z = z.object({ maxOutputChars: z.number().default(16_000), description: z.string().default(DEFAULT_DESCRIPTION), requireAbsolutePath: z.boolean().default(true), - expandTabsOnMutation: z.boolean().default(true), }) /** Register one `str_replace_editor` tool over `ctx.fs`. */ @@ -561,7 +539,6 @@ export function apply(ctx: Context, config: Config): void { maxOutputChars: config.maxOutputChars ?? 16_000, description: config.description ?? DEFAULT_DESCRIPTION, requireAbsolutePath: config.requireAbsolutePath ?? true, - expandTabsOnMutation: config.expandTabsOnMutation ?? true, } if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index fb1e0d4b58..9518057634 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -444,8 +444,8 @@ describe('tool-str-replace-editor', () => { expect(ownerless.error).toMatchObject({ info: { code: 'FS_SANDBOX_DENIED' } }) }) - it('can preserve tabs outside the edited region', async () => { - const { ctx, root, owner } = await setup({ expandTabsOnMutation: false }) + it('preserves tabs outside the edited region', async () => { + const { ctx, root, owner } = await setup() const path = join(root, 'Makefile') await writeFile(path, 'target:\n\told\nremove\n') await call(ctx, owner, { @@ -487,9 +487,10 @@ describe('tool-str-replace-editor', () => { const { ctx, root, owner } = await setup() const path = join(root, 'backend-error.txt') await writeFile(path, 'old\n') - ctx.fs.writeText = async () => { + const failWrite = async (): Promise => { throw new Error('backend write failed') } + ctx.fs.editText = failWrite const replace = await call(ctx, owner, { command: 'str_replace', @@ -500,6 +501,7 @@ describe('tool-str-replace-editor', () => { expect(replace.isError).toBe(true) expect(text(replace)).toContain('backend write failed') + ctx.fs.writeText = failWrite const insert = await call(ctx, owner, { command: 'insert', path, From fbad903dd012c6e3dc5baf8d9cbf564aa1eb41a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:32:19 +0800 Subject: [PATCH 09/46] test(examples): move persistent tools into snapshot lane --- .../persistent-tools.snapshot.cordis.yml | 19 ++ .../tests/persistent-tools.snapshot.spec.ts | 214 ------------------ examples/jsonrpc-agent/tests/sdk.snapshot.ts | 38 +++- .../persistent-tools/behavior.expected.json | 57 ----- .../notifications.expected.jsonl | 54 +++++ .../persistent-tools/result.expected.json | 1 + .../snapshots/persistent-tools/session.jsonl | 54 +++++ 7 files changed, 162 insertions(+), 275 deletions(-) create mode 100644 examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml delete mode 100644 examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts delete mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json create mode 100644 examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl diff --git a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml b/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml new file mode 100644 index 0000000000..5bda5ac6a5 --- /dev/null +++ b/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml @@ -0,0 +1,19 @@ +# Keyless replay keeps the persistent-tool composition intact and replaces +# only its live DeepSeek adapter with the fixture-backed provider. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./persistent-tools.cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash diff --git a/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts b/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts deleted file mode 100644 index 18d1454837..0000000000 --- a/examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { createServer } from 'node:http' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' -import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' - -const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) -const configPath = fileURLToPath(new URL('../persistent-tools.cordis.yml', import.meta.url)) -const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const expectedPath = fileURLToPath(new URL('./snapshots/persistent-tools/behavior.expected.json', import.meta.url)) - -interface ModelRequest { - messages?: Array> - tools?: Array<{ function?: { name?: string; parameters?: { required?: string[] } } }> -} - -function sseToolCall(id: string, name: string, args: Record): string[] { - return [ - 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', - `data: ${JSON.stringify({ - choices: [{ - delta: { - tool_calls: [{ - index: 0, - id, - type: 'function', - function: { name, arguments: JSON.stringify(args) }, - }], - }, - }], - })}\n\n`, - 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', - 'data: [DONE]\n\n', - ] -} - -function sseText(text: string): string[] { - return [ - 'data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n', - `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`, - 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":3}}\n\n', - 'data: [DONE]\n\n', - ] -} - -function messageText(content: unknown): string { - if (typeof content === 'string') return content - if (!Array.isArray(content)) return '' - return content.flatMap((block) => { - if (typeof block !== 'object' || block === null) return [] - const text = (block as { text?: unknown }).text - return typeof text === 'string' ? [text] : [] - }).join('') -} - -function latestToolCall(messages: Array>): { id: string; name: string } { - for (const message of messages.toReversed()) { - const calls = message.tool_calls - if (!Array.isArray(calls)) continue - const call = (calls as unknown[]).at(-1) - if (typeof call !== 'object' || call === null) continue - const id = (call as { id?: unknown }).id - const fn = (call as { function?: { name?: unknown } }).function - if (typeof id === 'string' && typeof fn?.name === 'string') return { id, name: fn.name } - } - throw new Error('model request has no preceding tool call') -} - -function normalize(value: string, cwd: string): string { - return value.replaceAll(cwd, '{{cwd}}') -} - -describe('jsonrpc persistent tools snapshot', () => { - it('runs persistent shell state and editor mutations keylessly', async () => { - const cwd = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-persistent-tools-')) - const sessionRoot = join(cwd, '.sessions') - const target = join(cwd, 'note.txt') - const requests: ModelRequest[] = [] - const modelServer = createServer((request, response) => { - let body = '' - request.setEncoding('utf8') - request.on('data', (chunk: string) => { body += chunk }) - request.on('end', () => { - const parsed = JSON.parse(body) as ModelRequest - requests.push(parsed) - const messages = parsed.messages ?? [] - const latest = messages.at(-1) - if (latest === undefined) throw new Error('model request has no messages') - let chunks: string[] - if (latest.role !== 'tool') { - chunks = sseToolCall('bash-1', 'bash', { - command: 'cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', - }) - } else { - const call = latestToolCall(messages) - const toolText = messageText(latest.content) - if (call.id === 'bash-1') { - expect(toolText).toContain('COUNT=1 CWD=/tmp') - chunks = sseToolCall('bash-2', 'bash', { - command: 'DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf "COUNT=%s CWD=%s\\n" "$DSH_EXAMPLE_COUNT" "$PWD"', - }) - } else if (call.id === 'bash-2') { - expect(toolText).toContain('COUNT=2 CWD=/tmp') - chunks = sseToolCall('editor-create', 'str_replace_editor', { - command: 'create', - path: target, - file_text: 'alpha\n', - }) - } else if (call.id === 'editor-create') { - expect(toolText).toContain('New file created successfully') - chunks = sseToolCall('editor-replace', 'str_replace_editor', { - command: 'str_replace', - path: target, - old_str: 'alpha', - new_str: 'beta', - }) - } else if (call.id === 'editor-replace') { - expect(toolText).toContain('has been edited successfully') - chunks = sseText('PERSISTENT_TOOLS_OK') - } else { - throw new Error(`unexpected tool call ${call.id}`) - } - } - response.writeHead(200, { 'content-type': 'text/event-stream' }) - for (const chunk of chunks) response.write(chunk) - response.end() - }) - }) - await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) - const address = modelServer.address() - if (address === null || typeof address === 'string') throw new Error('model server did not bind') - const launch = resolveExampleLaunch({ - srcBin: runtimeBin, - configArgs: [], - tsconfigPath: repoTsconfig, - }) - const harness = new DeepSeekHarness({ - launch: { - command: launch.command, - args: launch.args, - cwd: repoRoot, - env: { - ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, - ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, - DSH_CORDIS_CONFIG: configPath, - DSH_CWD: cwd, - DSH_SESSION_ROOT: sessionRoot, - DEEPSEEK_API_KEY: 'keyless-local-mock', - DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, - NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - }, - requestTimeoutMs: 60_000, - }, - cwd, - provider: 'deepseek', - model: 'deepseek-v4-flash', - }) - - try { - const result = await harness.run( - 'Prove that bash state persists, then create and edit note.txt.', - { sessionId: 'persistent-tools-snapshot' }, - ) - const calls = result.events.flatMap((event) => { - if (event.type !== 'tool/call') return [] - return [{ - name: event.data.name, - arguments: normalize(event.data.arguments, cwd), - }] - }) - const results = result.events.flatMap((event) => { - if (event.type !== 'tool/result') return [] - return event.data.message.content.flatMap((block) => { - if (block.type !== 'tool-result') return [] - return block.content.flatMap(content => - content.type === 'text' - ? [{ text: normalize(content.text, cwd) }] - : []) - }) - }) - const tools = (requests[0]?.tools ?? []).map(tool => ({ - name: tool.function?.name, - required: tool.function?.parameters?.required ?? [], - })).sort((left, right) => { - const leftName = String(left.name) - const rightName = String(right.name) - return leftName < rightName ? -1 : leftName > rightName ? 1 : 0 - }) - const behavior = { - tools, - calls, - results, - final: { - status: result.status, - reason: result.reason, - response: result.finalResponse, - file: await readFile(target, 'utf8'), - }, - } - if (process.env.DSH_SNAPSHOT === 'refresh') { - await writeFile(expectedPath, `${JSON.stringify(behavior, null, 2)}\n`) - } - expect(behavior).toEqual(JSON.parse(await readFile(expectedPath, 'utf8'))) - } finally { - await harness.close() - await new Promise(resolve => modelServer.close(() => { resolve() })) - await rm(cwd, { recursive: true, force: true }) - } - }, 75_000) -}) diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index c54812e3e5..26a6461586 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -11,7 +11,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' +import { delimiter, isAbsolute, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { @@ -31,6 +31,8 @@ const testsDir = dirOf(import.meta.url) const snapshotsDir = join(testsDir, 'snapshots') const liveConfig = join(testsDir, '..', 'cordis.yml') const replayConfig = join(testsDir, '..', 'cordis.snapshot.yml') +const persistentToolsLiveConfig = join(testsDir, '..', 'persistent-tools.cordis.yml') +const persistentToolsReplayConfig = join(testsDir, '..', 'persistent-tools.snapshot.cordis.yml') const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) @@ -51,6 +53,10 @@ interface SdkScenario { sessionId: string /** How many child sessions the turn persists (subagent scenarios). */ children: number + /** Optional scenario-specific live and replay compositions. */ + configs?: { live: string; replay: string } + /** Files whose final contents are part of the scenario contract. */ + expectedFiles?: Readonly> } const SCENARIOS: SdkScenario[] = [ @@ -72,6 +78,16 @@ const SCENARIOS: SdkScenario[] = [ sessionId: 'sdk-snapshot-subagent', children: 1, }, + { + name: 'persistent-tools', + prompt: 'Prove that bash state persists, then create and edit note.txt.', + sessionId: 'persistent-tools-snapshot', + children: 0, + configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, + // Replay returns recorded tool arguments verbatim, so this cross-platform + // POSIX fixture uses one stable absolute path and cleans it around the run. + expectedFiles: { '/tmp/dsh-persistent-tools-snapshot-note.txt': 'beta\n' }, + }, ] interface PersistedLog { @@ -147,11 +163,15 @@ async function runScenario(scenario: SdkScenario): Promise<{ result: TurnResult notifications: HarnessNotification[] logs: PersistedLog[] + observedFiles: Record cwd: string }> { const cwd = await mkdtemp(join(tmpdir(), `sdk-snapshot-${scenario.name}-`)) const sessionsRoot = join(cwd, '.sessions') const scenarioDir = join(snapshotsDir, scenario.name) + const expectedFilePaths = Object.keys(scenario.expectedFiles ?? {}).map(path => + isAbsolute(path) ? path : join(cwd, path)) + await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true }))) const launch = resolveExampleLaunch({ srcBin: runtimeBin, configArgs: [], @@ -164,7 +184,9 @@ async function runScenario(scenario: SdkScenario): Promise<{ const env: Record = { ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, - DSH_CORDIS_CONFIG: recording ? liveConfig : replayConfig, + DSH_CORDIS_CONFIG: recording + ? scenario.configs?.live ?? liveConfig + : scenario.configs?.replay ?? replayConfig, DSH_SESSION_ROOT: sessionsRoot, DSH_CWD: cwd, DSH_SNAPSHOT: mode, @@ -195,9 +217,16 @@ async function runScenario(scenario: SdkScenario): Promise<{ }) await harness.close() const logs = await persistedLogs(sessionsRoot) - return { result, notifications, logs, cwd } + const observedFiles = Object.fromEntries(await Promise.all( + Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string]> => [ + path, + await readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8'), + ]), + )) + return { result, notifications, logs, observedFiles, cwd } } finally { await harness.close() + await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true }))) await rm(cwd, { recursive: true, force: true }) } } @@ -227,7 +256,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const notificationsExpectedPath = join(scenarioDir, 'notifications.expected.jsonl') const resultExpectedPath = join(scenarioDir, 'result.expected.json') - const { result, notifications, logs, cwd } = await runScenario(scenario) + const { result, notifications, logs, observedFiles, cwd } = await runScenario(scenario) const ordered = orderLogs(logs, scenario) const actualContext = contextOf(ordered, cwd) @@ -293,6 +322,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { // Wire-shape invariants that must hold in every mode. expect(result.status).toBe('ok') expect(notifications.at(-1)?.method).toBe('session.finished') + expect(observedFiles).toEqual(scenario.expectedFiles ?? {}) if (scenario.children > 0) { expect(notifications.some(n => n.method === 'subagent.started')).toBe(true) expect(notifications.some(n => n.method === 'subagent.finished')).toBe(true) diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json deleted file mode 100644 index 18b54e9312..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/behavior.expected.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "tools": [ - { - "name": "bash", - "required": [ - "command" - ] - }, - { - "name": "str_replace_editor", - "required": [ - "command", - "path" - ] - } - ], - "calls": [ - { - "name": "bash", - "arguments": "{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" - }, - { - "name": "bash", - "arguments": "{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}" - }, - { - "name": "str_replace_editor", - "arguments": "{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}" - }, - { - "name": "str_replace_editor", - "arguments": "{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}" - } - ], - "results": [ - { - "text": "COUNT=1 CWD=/tmp" - }, - { - "text": "COUNT=2 CWD=/tmp" - }, - { - "text": "New file created successfully at: {{cwd}}/note.txt" - }, - { - "text": "The file {{cwd}}/note.txt has been edited successfully." - } - ], - "final": { - "status": "ok", - "reason": { - "kind": "completed" - }, - "response": "PERSISTENT_TOOLS_OK", - "file": "beta\n" - } -} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl new file mode 100644 index 0000000000..481e0a3d08 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl @@ -0,0 +1,54 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit note.txt."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-1","name":"bash","argumentsDelta":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"bash-1"},"content":[{"type":"tool-result","toolCallId":"bash-1","content":[{"type":"text","text":"COUNT=1 CWD=/tmp"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-2","name":"bash","argumentsDelta":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bash-2"},"content":[{"type":"tool-result","toolCallId":"bash-2","content":[{"type":"text","text":"COUNT=2 CWD=/tmp"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: /tmp/dsh-{{sessionId}}-note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[31],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file /tmp/dsh-{{sessionId}}-note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":51,"time":0,"data":{"turn":1,"step":5}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":52,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json new file mode 100644 index 0000000000..989372e15b --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/result.expected.json @@ -0,0 +1 @@ +{"status":"ok","reason":{"kind":"completed"},"finalResponse":"PERSISTENT_TOOLS_OK"} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl new file mode 100644 index 0000000000..d4c36a93ac --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl @@ -0,0 +1,54 @@ +{"type":"session","version":0,"id":"persistent-tools-snapshot","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785331618311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit note.txt."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785331618312,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785331618313,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785331618325,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":1785331618325,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-1","name":"bash","argumentsDelta":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}} +{"type":"assistant/chunk","seq":7,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"type":"assistant/chunk","seq":8,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":9,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":1785331618327,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"68f0912b-5e3a-417e-a324-00871206cdf7"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":1785331618327,"data":{"turn":1,"step":1,"callId":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}} +{"type":"tool/result","seq":12,"time":1785331618649,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"bash-1"},"content":[{"type":"tool-result","toolCallId":"bash-1","content":[{"type":"text","text":"COUNT=1 CWD=/tmp"}],"isError":false}],"role":"user","id":"a83a469c-0321-4f8b-a40e-913c1b433b9d"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":1785331618649,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":1785331618649,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":16,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-2","name":"bash","argumentsDelta":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}} +{"type":"assistant/chunk","seq":17,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} +{"type":"assistant/chunk","seq":18,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":19,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":20,"time":1785331618652,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"425c837c-b7e5-48ef-bc97-282bf5a10221"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":1785331618652,"data":{"turn":1,"step":2,"callId":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}} +{"type":"tool/result","seq":22,"time":1785331618759,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bash-2"},"content":[{"type":"tool-result","toolCallId":"bash-2","content":[{"type":"text","text":"COUNT=2 CWD=/tmp"}],"isError":false}],"role":"user","id":"1d3fcea8-51d9-47a1-8e8e-283c7b9cf53a"}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1785331618759,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":1785331618759,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"type":"assistant/chunk","seq":28,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":29,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6407aec3-f75c-427a-8783-a61bd99327bb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}} +{"type":"tool/result","seq":32,"time":1785331618782,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: /tmp/dsh-persistent-tools-snapshot-note.txt"}],"isError":false}],"role":"user","id":"121833da-381d-492e-9d6c-82eaa9694ef1"}},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1785331618782,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":1785331618782,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}} +{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"type":"assistant/chunk","seq":38,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":39,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1cf1d34c-faee-464d-bdd7-413ba7233e23"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}} +{"type":"tool/result","seq":42,"time":1785331618799,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file /tmp/dsh-persistent-tools-snapshot-note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"c88746c2-208d-46aa-8c3d-79ccc88c7f6d"}},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":1785331618799,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":1785331618799,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":46,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} +{"type":"assistant/chunk","seq":47,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":48,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":49,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":50,"time":1785331618802,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b8832049-1795-4127-b0e0-e31528da0e99"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"step/end","seq":51,"time":1785331618802,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":52,"time":1785331618802,"data":{"turn":1,"reason":{"kind":"completed"}}} From 205702adaf7b100a7e841f203471bfdaa5b1af2f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:33:39 +0800 Subject: [PATCH 10/46] fix(pty): reset persistent shell on cancellation --- .../pty/tool-bash-persistent/src/index.ts | 8 ++-- .../tool-bash-persistent/tests/tools.spec.ts | 45 +++++++++++-------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 6cadf75708..9c6f755434 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -309,6 +309,10 @@ async function executeCommand( SHELL_RESET_MESSAGE, ].join('\n') } + if (commandDeadline.signal.aborted) { + await shells.reset(owner, 'persistent bash command aborted') + commandDeadline.signal.throwIfAborted() + } if (latest.text.includes(marker.end)) { const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) return renderCaptured(complete, config.maxOutputChars) @@ -325,10 +329,6 @@ async function executeCommand( SHELL_RESET_MESSAGE, ].filter(part => part.length > 0).join('\n') } - if (commandDeadline.signal.aborted) { - await shells.reset(owner, 'persistent bash command aborted') - commandDeadline.signal.throwIfAborted() - } if (promptCompleted(result)) { const snapshot = retainedScrollback(ctx, owner, id, latest) return renderCaptured( diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 990004c1c9..81a4f28b87 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -80,6 +80,7 @@ type StubMode = | 'exit' | 'signal-exit' | 'wait-for-abort' + | 'end-on-abort' | 'idle-then-normal' | 'large' | 'nonzero' @@ -119,11 +120,16 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } if (this.mode === 'send-error') throw new Error('stub send failed') - if (this.mode === 'wait-for-abort') { + if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { - this.scrollback += 'partial output' - resolve(this.result('partial output', 'stdin_read')) + const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0] + const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(request.text)?.[0] + const output = this.mode === 'end-on-abort' + ? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}` + : 'partial output' + this.scrollback += output + resolve(this.result(output, 'stdin_read')) }, { once: true }) }) return this.operation(done) @@ -389,22 +395,25 @@ describe('tool-bash-persistent', () => { expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out') }) - it('cancels in-flight work, resets the shell, and releases a queued call', async () => { - const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) - await call(ctx, owner, 'warm up') - stub.sessions[0]!.mode = 'wait-for-abort' - const controller = new AbortController() - const cancelled = call(ctx, owner, 'hang', controller.signal) - const queued = call(ctx, owner, 'after cancellation') - setTimeout(() => { - controller.abort(new Error('caller stopped')) - }, 5) + it.each(['wait-for-abort', 'end-on-abort'] as const)( + 'cancels %s work, resets the shell, and releases a queued call', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = mode + const controller = new AbortController() + const cancelled = call(ctx, owner, 'hang', controller.signal) + const queued = call(ctx, owner, 'after cancellation') + setTimeout(() => { + controller.abort(new Error('caller stopped')) + }, 5) - expect((await cancelled).isError).toBe(true) - expect(text(await queued)).toBe('hello from stub') - expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted') - expect(stub.sessions).toHaveLength(2) - }) + expect((await cancelled).isError).toBe(true) + expect(text(await queued)).toBe('hello from stub') + expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted') + expect(stub.sessions).toHaveLength(2) + }, + ) it.each(['init-exit', 'init-timeout'] as const)( 'fails initialization and closes the unusable shell for %s', From 98e6a0573f09a328420194e8195f948339351527 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:37:12 +0800 Subject: [PATCH 11/46] fix(fs): keep one editor path contract --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +-- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- docs/config-catalog.md | 8 ++--- .../tool-str-replace-editor/README.i18n.yaml | 4 +-- packages/fs/tool-str-replace-editor/README.md | 5 +-- .../fs/tool-str-replace-editor/README.zh.md | 5 +-- .../fs/tool-str-replace-editor/src/index.ts | 34 +++++++------------ .../tests/tools.spec.ts | 20 ++++++----- 9 files changed, 33 insertions(+), 51 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index e3b6121e19..df7df7c84c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 286a53c1c686cc515b65119ed4b1a01a57b0614b -2026-07-29-persistent-bash-str-replace-editor.zh.md: d2417708c8a1334e9f8930481f4218cbefc5a87b +2026-07-29-persistent-bash-str-replace-editor.md: 26949e7435bcbf05132662c308f33f322920c7eb +2026-07-29-persistent-bash-str-replace-editor.zh.md: 6d5eadbfa68a59157e8f4bc148d03144bc665cb2 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 286a53c1c6..26949e7435 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -12,7 +12,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. -`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. The public schema and failures use only `old_str`; canonical mode requires absolute paths and expands tabs before mutations. Deployments with an intentional session-cwd contract can disable the absolute-path requirement. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. +`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute, mutations preserve tabs outside the requested edit, and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index d2417708c8..6d5eadbfa6 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -12,7 +12,7 @@ `@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 -`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。公开 schema 与错误只使用 `old_str`;规范模式要求绝对路径,并在变更前展开制表符。有明确 session-cwd 契约的部署可以关闭绝对路径要求。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 +`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径,变更会保留请求编辑范围之外的制表符,且公开 schema 与错误只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2e6ffaf3d8..dc2a740349 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1593,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:373`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:395`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1764,14 +1764,10 @@ export interface Config { maxOutputChars?: number /** Model-facing tool description. */ description?: string - /** Require local absolute paths like the canonical editor contract (default true). */ - requireAbsolutePath?: boolean - /** Expand tabs across the full file before each mutation, matching the canonical editor (default true). */ - expandTabsOnMutation?: boolean } ``` -Source: [`packages/fs/tool-str-replace-editor/src/index.ts:539`](../packages/fs/tool-str-replace-editor/src/index.ts) +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:514`](../packages/fs/tool-str-replace-editor/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index 10b1f19182..9c7a2190c4 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -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 packages/fs/tool-str-replace-editor/README.md -README.md: 8ac6a22f24ddcdd3818b346e3426e58e718027e2 -README.zh.md: cf82b132b63730af209c159066a70f6a18b77f39 +README.md: 12224537ab2ca2d2ba97e93fe8dc2192fa9ac1aa +README.zh.md: 5481723f8a3077ee329ec202b12a67b678abc691 diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index 8ac6a22f24..12224537ab 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -10,12 +10,10 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w |---|---:|---| | `maxOutputChars` | `16000` | Prefix characters retained for file and directory views. | | `description` | Editor command guide | Model-facing tool description. | -| `requireAbsolutePath` | `true` | Reject relative paths; disable only for deployments with a deliberate session-cwd contract. | -| `expandTabsOnMutation` | `true` | Preserve the canonical Claude SWE behavior that expands tabs across the whole file before replace/insert. Set `false` for atomic literal replacement that preserves unrelated tabs. | ## Tool -The schema provides `view`, `create`, `str_replace`, and `insert`. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. +The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit. ## Model Experience @@ -51,5 +49,4 @@ Append-only tool results follow the reusable request prefix. - Operations target UTF-8 text; binary files are unsupported. - `str_replace` intentionally rejects zero or multiple matches and has no `replace_all` argument. -- Canonical mode (`expandTabsOnMutation: true`) expands tabs in the entire file before replacement or insertion, including lines outside the edited region. Set it to `false` for Makefiles and other tab-sensitive files. - Every mutation goes through `fs/write-intent` or `fs/edit-intent`, resolves the current session sandbox policy, and delegates enforcement to the mounted filesystem and policy plugins. diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index cf82b132b6..5481723f8a 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -10,12 +10,10 @@ |---|---:|---| | `maxOutputChars` | `16000` | 文件和目录查看结果保留的前缀字符数。 | | `description` | 编辑器命令指南 | 面向模型的工具描述。 | -| `requireAbsolutePath` | `true` | 拒绝相对路径;仅当部署明确约定 session cwd 时才应关闭。 | -| `expandTabsOnMutation` | `true` | 保留 Claude SWE 参考行为:替换/插入前展开整个文件的制表符。设为 `false` 时使用原子字面量替换,并保留未触及的制表符。 | ## 工具 -Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。 +Schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。 ## 模型体验 @@ -51,5 +49,4 @@ Schema 提供 `view`、`create`、`str_replace` 与 `insert`。文件查看使 - 操作面向 UTF-8 文本,不支持二进制文件。 - `str_replace` 刻意拒绝零匹配或多匹配,且没有 `replace_all` 参数。 -- 规范模式(`expandTabsOnMutation: true`)会在替换或插入前展开整个文件中的制表符,包括未编辑区域。Makefile 等依赖制表符的文件应设为 `false`。 - 每个修改操作都会经过 `fs/write-intent` 或 `fs/edit-intent`,解析当前 session 的沙箱策略,并交由挂载的文件系统与策略插件执行。 diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 98bfd6528d..801e9200b4 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -105,12 +105,11 @@ class MutationPolicy { async function resolveTarget( ctx: Context, path: string, - requireAbsolutePath: boolean, exec: ToolRunContext, workspaceRoot?: string, ): Promise { if (path.trim().length === 0) throw new Error('path must be a non-empty string') - if (requireAbsolutePath && !isAbsolute(path)) { + if (!isAbsolute(path)) { throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) } const cwd = exec.agent?.session.header.cwd ?? workspaceRoot @@ -237,10 +236,9 @@ async function viewPath( path: string, viewRange: number[] | undefined, maxOutputChars: number, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec) + const target = await resolveTarget(ctx, path, exec) const info = await statExisting(ctx, target, 'view', exec) if (info.type === 'directory') { if (viewRange !== undefined) { @@ -261,12 +259,11 @@ async function createFile( policy: MutationPolicy, path: string, fileText: string | undefined, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { const content = requiredForCommand(fileText, 'file_text', 'create') const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) if (await ctx.fs.stat(target, exec.signal) !== undefined) { throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) } @@ -298,11 +295,10 @@ async function replaceInFile( path: string, oldStr: string | undefined, newStr: string | undefined, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) const newValue = newStr ?? '' @@ -327,10 +323,12 @@ async function replaceInFile( } let outcome try { - outcome = await ctx.fs.editText( + outcome = await ctx.fs.writeText( target, - { oldString: oldValue, newString: newValue, replaceAll: false }, - intent ?? { version: info.version }, + before.replace(oldValue, newValue), + intent === undefined + ? { kind: 'replaceIfVersion', version: info.version } + : { kind: 'replaceIfVersion', version: intent.version }, exec.signal, sandboxPolicy, ) @@ -347,13 +345,12 @@ async function insertInFile( path: string, insertLine: number | undefined, newStr: string | undefined, - requireAbsolutePath: boolean, exec: ToolRunContext, ): Promise { if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') const value = requiredForCommand(newStr, 'new_str', 'insert') const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, requireAbsolutePath, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const info = await statExisting(ctx, target, 'insert', exec) if (info.type !== 'file') { @@ -387,7 +384,6 @@ async function insertInFile( interface ResolvedConfig { maxOutputChars: number description: string - requireAbsolutePath: boolean } function presentEditorCall(args: { @@ -484,9 +480,9 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { async execute(args, exec) { switch (args.command) { case 'view': - return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, config.requireAbsolutePath, exec) + return viewPath(ctx, args.path, args.view_range, config.maxOutputChars, exec) case 'create': - return createFile(ctx, policy, args.path, args.file_text, config.requireAbsolutePath, exec) + return createFile(ctx, policy, args.path, args.file_text, exec) case 'str_replace': return replaceInFile( ctx, @@ -494,7 +490,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.path, args.old_str, args.new_str, - config.requireAbsolutePath, exec, ) case 'insert': @@ -504,7 +499,6 @@ function registerStrReplaceEditor(ctx: Context, config: ResolvedConfig): void { args.path, args.insert_line, args.new_str, - config.requireAbsolutePath, exec, ) } @@ -522,15 +516,12 @@ export interface Config { maxOutputChars?: number /** Model-facing tool description. */ description?: string - /** Require local absolute paths like the canonical editor contract (default true). */ - requireAbsolutePath?: boolean } /** Runtime configuration schema for the string-replacement editor tool. */ export const Config: z = z.object({ maxOutputChars: z.number().default(16_000), description: z.string().default(DEFAULT_DESCRIPTION), - requireAbsolutePath: z.boolean().default(true), }) /** Register one `str_replace_editor` tool over `ctx.fs`. */ @@ -538,7 +529,6 @@ export function apply(ctx: Context, config: Config): void { const resolved: ResolvedConfig = { maxOutputChars: config.maxOutputChars ?? 16_000, description: config.description ?? DEFAULT_DESCRIPTION, - requireAbsolutePath: config.requireAbsolutePath ?? true, } if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { throw new Error('tool-str-replace-editor: maxOutputChars must be a positive safe integer') diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 9518057634..64bdd481ee 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -316,6 +316,16 @@ describe('tool-str-replace-editor', () => { expect(text(repeatedMultiline)) .toContain('Multiple occurrences of old_str `alpha\nbeta` in lines [1, 4]') + const mixedEol = join(root, 'mixed-eol.txt') + await writeFile(mixedEol, 'alpha\r\nbeta\nmiddle\nalpha\nbeta') + expect((await call(ctx, owner, { + command: 'str_replace', + path: mixedEol, + old_str: 'alpha\r\nbeta', + new_str: 'replaced', + })).isError).toBe(false) + expect(await readFile(mixedEol, 'utf8')).toBe('replaced\nmiddle\nalpha\nbeta') + const relative = await call(ctx, owner, { command: 'view', path: 'ambiguous.txt' }) expect(relative.isError).toBe(true) expect(text(relative)).toContain('is not an absolute path') @@ -378,13 +388,6 @@ describe('tool-str-replace-editor', () => { })).error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } }) }) - it('can opt into session-relative paths for non-canonical deployments', async () => { - const { ctx, root, owner } = await setup({ requireAbsolutePath: false }) - await writeFile(join(root, 'relative.txt'), 'relative') - expect(text(await call(ctx, owner, { command: 'view', path: 'relative.txt' }))) - .toContain("Here's the content of") - }) - it('delegates read-before-edit decisions to fs-policy', async () => { const { ctx, root, owner } = await setup({}, { fsPolicy: true }) const existing = join(root, 'existing.txt') @@ -490,7 +493,7 @@ describe('tool-str-replace-editor', () => { const failWrite = async (): Promise => { throw new Error('backend write failed') } - ctx.fs.editText = failWrite + ctx.fs.writeText = failWrite const replace = await call(ctx, owner, { command: 'str_replace', @@ -501,7 +504,6 @@ describe('tool-str-replace-editor', () => { expect(replace.isError).toBe(true) expect(text(replace)).toContain('backend write failed') - ctx.fs.writeText = failWrite const insert = await call(ctx, owner, { command: 'insert', path, From 7b1e8978a989cd4406e5d8fa123c82590c75de2e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:49:21 +0800 Subject: [PATCH 12/46] fix(build): validate packaged spawn helpers --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- patches/node-pty@1.1.0.patch | 6 +- pnpm-lock.yaml | 8 +-- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 33 ++++++++++- python/sdk/tests/test_release_version.py | 55 ++++++++++++++++++- scripts/build-exe-for-python-sdk.ts | 32 ++++++++++- scripts/build-python-release.py | 37 +++++++++++++ 12 files changed, 167 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index df7df7c84c..cb1e7631fd 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 26949e7435bcbf05132662c308f33f322920c7eb -2026-07-29-persistent-bash-str-replace-editor.zh.md: 6d5eadbfa68a59157e8f4bc148d03144bc665cb2 +2026-07-29-persistent-bash-str-replace-editor.md: b1be9cc40e11b07b722877e666de0e0328636a05 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 0bf100514e971dfa759f44dcca15a7ad6a2fdd8a diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 26949e7435..b1be9cc40e 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. -Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present (or when `DSH_NODE_PTY_SPAWN_HELPER` explicitly selects one), preserving upstream lookup in ordinary Node runs; the executable and runtime-wheel builders fail before publication when the helper is absent, mismatched, or not executable. +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The executable and runtime-wheel builders inspect ELF or thin Mach-O headers and fail before publication when the helper is absent, mismatched, or not executable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 6d5eadbfa6..0bf100514e 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -16,7 +16,7 @@ `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 -两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它(也可由 `DSH_NODE_PTY_SPAWN_HELPER` 显式指定),普通 Node 运行仍保留上游查找方式;若 helper 缺失、架构不匹配或不可执行,可执行文件与 runtime wheel 构建会在发布前失败。 +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。可执行文件与运行时 wheel 包的构建器会检查 ELF 或 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 ## 考虑过的替代方案 diff --git a/patches/node-pty@1.1.0.patch b/patches/node-pty@1.1.0.patch index f0de7b9054..56892a3d58 100644 --- a/patches/node-pty@1.1.0.patch +++ b/patches/node-pty@1.1.0.patch @@ -2,7 +2,7 @@ diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf988cf94c5 100644 --- a/lib/unixTerminal.js +++ b/lib/unixTerminal.js -@@ -26,10 +26,22 @@ var terminal_1 = require("./terminal"); +@@ -26,10 +26,23 @@ var terminal_1 = require("./terminal"); var utils_1 = require("./utils"); var native = utils_1.loadNativeModule('pty'); var pty = native.module; @@ -10,6 +10,7 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf9 -helperPath = path.resolve(__dirname, helperPath); -helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); -helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++// A current external embedded-runtime consumer supplies a non-sibling helper. +var helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; +if (helperPath) { + helperPath = path.resolve(helperPath); @@ -33,7 +34,7 @@ diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf9771220392d17 100644 --- a/src/unixTerminal.ts +++ b/src/unixTerminal.ts -@@ -14,10 +14,20 @@ import { assign, loadNativeModule } from './utils'; +@@ -14,10 +14,21 @@ import { assign, loadNativeModule } from './utils'; const native = loadNativeModule('pty'); const pty: IUnixNative = native.module; @@ -41,6 +42,7 @@ index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf97712 -helperPath = path.resolve(__dirname, helperPath); -helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); -helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++// A current external embedded-runtime consumer supplies a non-sibling helper. +let helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; +if (helperPath) { + helperPath = path.resolve(helperPath); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73092a292c..82af864f54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 - node-pty@1.1.0: 4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15 + node-pty@1.1.0: 7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6 importers: @@ -656,7 +656,7 @@ importers: devDependencies: node-pty: specifier: 1.1.0 - version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) + version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) packages/acp/acp: dependencies: @@ -3402,7 +3402,7 @@ importers: dependencies: node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) + version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -15287,7 +15287,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-pty@1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15): + node-pty@1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6): dependencies: node-addon-api: 7.1.1 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 4b104ec211..c25eecf5de 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -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 python/sdk-runtime/README.md -README.md: ee3791eddf26b526316d4f3952793a03cc48841e -README.zh.md: 59d40ee56688cb377902ff126b7fa77606c7ad8b +README.md: 0869b4a9dce0e261b168f21c90a80faf81ea4a64 +README.zh.md: 3f2342c4c66e24b8213c78d4e7530c3360497022 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index ee3791eddf..0869b4a9dc 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built and validated as one runtime product. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built as one runtime product, and ELF or thin Mach-O headers must match the target. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 59d40ee566..3f2342c4c6 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;构建与校验会把两者视作同一个运行时产物。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;两者作为一个运行时产物构建,且 ELF 或 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 108e77cf2c..f2df169cfc 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -16,6 +16,34 @@ _PLATFORMS = { _SPAWN_HELPER_SUFFIX = "-spawn-helper" +def _spawn_helper_binary_target(header: bytes) -> str | None: + if ( + len(header) >= 20 + and header[:4] == b"\x7fELF" + and header[4] == 2 + and header[5] == 1 + ): + machine = int.from_bytes(header[18:20], "little") + if machine == 62: + return "linux-x64" + if machine == 183: + return "linux-arm64" + if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": + if int.from_bytes(header[4:8], "little") == 0x0100000C: + return "macos-arm64" + return None + + +def _validate_spawn_helper(path: Path, expected_target: str) -> None: + with path.open("rb") as helper: + actual_target = _spawn_helper_binary_target(helper.read(20)) + if actual_target != expected_target: + raise RuntimeError( + f"runtime spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'} at {path}" + ) + + def _host_platform_tag() -> str: machine = platform.machine().lower() arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine @@ -39,13 +67,13 @@ class RuntimeBuildHook(BuildHookInterface): ) platform_tag = os.environ.get("DSH_RUNTIME_PLATFORM_TAG") or _host_platform_tag() - matches = [value for value in _PLATFORMS.values() if value[0] == platform_tag] + matches = [(key, value) for key, value in _PLATFORMS.items() if value[0] == platform_tag] if len(matches) != 1: supported = ", ".join(value[0] for value in _PLATFORMS.values()) raise RuntimeError( f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}" ) - expected_executable = matches[0][1] + expected_target, (_, expected_executable) = matches[0] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)] @@ -64,6 +92,7 @@ class RuntimeBuildHook(BuildHookInterface): for executable in [executables[0], helpers[0]]: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") + _validate_spawn_helper(helpers[0], expected_target) build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 7b7c8254b1..698676a636 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -16,6 +16,18 @@ SCRIPT = ROOT / "scripts" / "build-python-release.py" build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) +def helper_header(target: str) -> bytes: + header = bytearray(20) + if target.startswith("linux-"): + header[:6] = b"\x7fELF\x02\x01" + machine = 62 if target == "linux-x64" else 183 + header[18:20] = machine.to_bytes(2, "little") + else: + header[:4] = b"\xcf\xfa\xed\xfe" + header[4:8] = (0x0100000C).to_bytes(4, "little") + return bytes(header) + + def test_repository_version_matches_root_package_json() -> None: expected = json.loads((ROOT / "package.json").read_text())["version"] @@ -45,7 +57,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non executable.write_bytes(b"runtime") executable.chmod(0o755) spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(b"helper") + spawn_helper.write_bytes(helper_header("macos-arm64")) spawn_helper.chmod(0o751) destination = tmp_path / "staging" @@ -59,7 +71,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" assert (runtime_dir / executable.name).read_bytes() == b"runtime" copied_helper = runtime_dir / spawn_helper.name - assert copied_helper.read_bytes() == b"helper" + assert copied_helper.read_bytes() == helper_header("macos-arm64") assert copied_helper.stat().st_mode & stat.S_IXUSR @@ -75,3 +87,42 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: executable, executable.name, ) + + +@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64"]) +def test_spawn_helper_binary_target(target: str) -> None: + assert build_python_release.spawn_helper_binary_target(helper_header(target)) == target + + +def test_stage_runtime_rejects_mismatched_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(helper_header("linux-arm64")) + spawn_helper.chmod(0o755) + + with pytest.raises(ValueError, match="expected linux-x64, found linux-arm64"): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + executable.name, + ) + + +def test_stage_runtime_rejects_non_binary_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(b"helper") + spawn_helper.chmod(0o755) + + with pytest.raises(ValueError, match="unsupported format or architecture"): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + executable.name, + ) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 38400009ac..24cd6c0383 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -7,7 +7,7 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, statSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises' import { basename, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -58,6 +58,24 @@ interface RuntimeProduct { spawnHelper: string } +function spawnHelperBinaryTarget(path: string): string | undefined { + const header = readFileSync(path).subarray(0, 20) + if (header.length >= 20 + && header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + && header[4] === 2 + && header[5] === 1) { + const machine = header.readUInt16LE(18) + if (machine === 62) return 'linux-x64' + if (machine === 183) return 'linux-arm64' + } + if (header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf) { + const cpuType = header.readUInt32LE(4) + if (cpuType === 0x01000007) return 'macos-x64' + if (cpuType === 0x0100000c) return 'macos-arm64' + } + return undefined +} + function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -347,7 +365,17 @@ class SingleExeBuild { + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, ) } - if (statSync(helper).mode & 0o111) return helper + if (statSync(helper).mode & 0o111) { + const expected = `${target.platform}-${target.arch}` + const actual = spawnHelperBinaryTarget(helper) + if (actual !== expected) { + throw new Error( + `build-exe-for-python-sdk: node-pty spawn-helper binary mismatch: expected ${expected}, ` + + `found ${actual ?? 'unsupported format or architecture'} at ${helper}`, + ) + } + return helper + } throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 915968b30d..3011120da1 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -23,6 +23,35 @@ PLATFORMS = { "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } SPAWN_HELPER_SUFFIX = "-spawn-helper" +EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()} + + +def spawn_helper_binary_target(header: bytes) -> str | None: + if ( + len(header) >= 20 + and header[:4] == b"\x7fELF" + and header[4] == 2 + and header[5] == 1 + ): + machine = int.from_bytes(header[18:20], "little") + if machine == 62: + return "linux-x64" + if machine == 183: + return "linux-arm64" + if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": + if int.from_bytes(header[4:8], "little") == 0x0100000C: + return "macos-arm64" + return None + + +def validate_spawn_helper(path: Path, expected_target: str) -> None: + with path.open("rb") as helper: + actual_target = spawn_helper_binary_target(helper.read(20)) + if actual_target != expected_target: + raise ValueError( + f"runtime spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'} at {path}" + ) def main() -> None: @@ -142,6 +171,7 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") + validate_spawn_helper(spawn_helper, EXECUTABLE_TARGETS[executable_name]) copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -186,6 +216,13 @@ def verify_wheel( mode = archive.getinfo(executable).external_attr >> 16 if mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") + actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:20]) + expected_target = EXECUTABLE_TARGETS[platform[1]] + if actual_target != expected_target: + raise RuntimeError( + f"{wheel} spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'}" + ) elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 9939236dcbc87e6b2fc3d0fa79ec2ff482dade3a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:50:25 +0800 Subject: [PATCH 13/46] test(tools): prove persistent tool disposal --- .../fs/tool-str-replace-editor/tests/tools.spec.ts | 10 +++++++--- packages/pty/tool-bash-persistent/src/invariant.ts | 5 +++-- packages/pty/tool-bash-persistent/tests/tools.spec.ts | 6 +++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 64bdd481ee..f1fafcfa04 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -78,13 +78,13 @@ async function setup( await ctx.plugin(SandboxedFileSystem, { cwd: root }) } if (options.fsPolicy === true) await ctx.plugin(FsPolicy) - await ctx.plugin(ToolStrReplaceEditor, config) - return { ctx, root, owner: agent(ctx, root) } + const fiber = await ctx.plugin(ToolStrReplaceEditor, config) + return { ctx, root, fiber, owner: agent(ctx, root) } } describe('tool-str-replace-editor', () => { it('registers the standalone schema and configurable description', async () => { - const { ctx } = await setup({ description: 'custom editor description' }) + const { ctx, fiber } = await setup({ description: 'custom editor description' }) const schema = ctx.tools.schemas()[0] expect(ctx.tools.schemas().map(item => item.name)).toEqual(['str_replace_editor']) expect(schema?.description).toBe('custom editor description') @@ -147,6 +147,10 @@ describe('tool-str-replace-editor', () => { })).toMatchObject({ locations: [{ path: '/workspace/a.txt' }], }) + + await fiber.dispose() + expect(ctx.tools.schemas()).toEqual([]) + expect(ctx.tools.get('str_replace_editor')).toBeUndefined() }) it('creates, views, replaces, and inserts with the canonical model-facing output', async () => { diff --git a/packages/pty/tool-bash-persistent/src/invariant.ts b/packages/pty/tool-bash-persistent/src/invariant.ts index f6b5acfbc7..5e276d4c45 100644 --- a/packages/pty/tool-bash-persistent/src/invariant.ts +++ b/packages/pty/tool-bash-persistent/src/invariant.ts @@ -15,8 +15,9 @@ export const name = 'tool-bash-persistent-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the tool adapter owns no independent durable state; - * PTY ownership and filesystem mutation relations stay with their services. + * No runtime invariant: the adapter's private owner-to-shell cache has no + * observable event or data relation. Lifecycle tests prove its cleanup without + * adding a public surface solely for an invariant. */ const install: InvariantInstaller = () => {} diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 81a4f28b87..1a9d15c476 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -260,7 +260,7 @@ async function setup( describe('tool-bash-persistent', () => { it('registers a configurable schema and reuses one owner shell', async () => { - const { ctx, owner, stub } = await setup({ + const { ctx, owner, stub, fiber } = await setup({ backendType: 'stub', description: 'deployment-specific persistent shell', }) @@ -282,6 +282,10 @@ describe('tool-bash-persistent', () => { const ownerWithoutCwd = agent(ctx, undefined) expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub') expect(stub.sessions).toHaveLength(2) + + await fiber.dispose() + expect(ctx.tools.schemas()).toEqual([]) + expect(ctx.tools.get('bash')).toBeUndefined() }) it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { From 7fdde06cf959476fa9f86747b41f5163493820dd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:51:07 +0800 Subject: [PATCH 14/46] chore(pty): mark unsupported diagnostic claims --- docs/config-catalog.md | 2 +- packages/pty/tool-bash-persistent/src/index.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dc2a740349..fcb2bdd2bd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1593,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:395`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:397`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 9c6f755434..2ad2bd54b8 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -11,6 +11,7 @@ import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/ds import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' +// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' @@ -304,6 +305,7 @@ async function executeCommand( ) await shells.reset(owner, 'persistent bash command timed out') return [ + // TODO: Report a timeout only; this signal does not establish an OOM. `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`, partial, SHELL_RESET_MESSAGE, From c0bd88430ab065fe266024201693910afaa66b2b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:44:19 +0800 Subject: [PATCH 15/46] fix(build): stage platform-specific PTY artifacts --- ...cutable-sdk-runtime-distribution.i18n.yaml | 6 +- ...ile-executable-sdk-runtime-distribution.md | 6 +- ...-executable-sdk-runtime-distribution.zh.md | 6 +- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 4 +- ...9-persistent-bash-str-replace-editor.zh.md | 4 +- pnpm-workspace.yaml | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 29 +++---- python/sdk/tests/test_release_version.py | 38 ++++++---- scripts/build-exe-for-python-sdk.ts | 75 +++++++++++++------ scripts/build-python-release.py | 64 ++++++++-------- 14 files changed, 141 insertions(+), 105 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 1ab141230b..ce64f9d035 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 39cfb2999dea7767a18702ad7d160c9e88d7bf20 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: e1a21c40647e1418d4afd02c0bc6b44ef0d4a8cf +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +2026-07-10-single-file-executable-sdk-runtime-distribution.md: fac3d9527b496adaaffdbd8e78401a17c6f0cc0b +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ff6a8be16c5f8591efa9bf383cd47c8fe251fe39 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 39cfb2999d..fac3d9527b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -40,15 +40,15 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. CI treats them as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; the wheel-only runtime package contains exactly one exe and uses one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`. Its Hatch hook rejects sdists, universal tags, mixed executable payloads, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index e1a21c4064..ff6a8be16c 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -40,15 +40,15 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。CI 将这些文件作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR 添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用模拟 SSE 模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;只提供 wheel 包的运行时包恰好包含一个 exe,标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64`。其 Hatch 钩子拒绝 sdist、通用标签、混合可执行载荷以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 exe“必须显式配置”的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index cb1e7631fd..0e31711c21 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: b1be9cc40e11b07b722877e666de0e0328636a05 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 0bf100514e971dfa759f44dcca15a7ad6a2fdd8a +2026-07-29-persistent-bash-str-replace-editor.md: 6e8a1df7f04340f4a97c0b799aaace0a78526ba5 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 256ffad7945cbb4367b9cc8bb92e9b2968ab4501 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index b1be9cc40e..6e8a1df7f0 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. -Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The executable and runtime-wheel builders inspect ELF or thin Mach-O headers and fail before publication when the helper is absent, mismatched, or not executable. +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with an architecture-matched `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch resolves the sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The macOS executable and runtime-wheel builders inspect the thin Mach-O header and fail before publication when the helper is absent, mismatched, or not executable. ## Alternatives considered @@ -30,4 +30,4 @@ Both plugins are included in the Python runtime closure. The persistent Bash clo ## Consequences -Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation, but the wheel now contains a main executable plus its private native helper rather than one physical file. +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 0bf100514e..256ffad794 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -16,7 +16,7 @@ `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 -两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。可执行文件与运行时 wheel 包的构建器会检查 ELF 或 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。macOS 可执行文件与运行时 wheel 包的构建器会检查 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 ## 考虑过的替代方案 @@ -30,4 +30,4 @@ ## 后果 -Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。runtime wheel 的使用者仍不需要安装 Node,但 wheel 现在包含主可执行文件及其私有原生 helper,而不是单个物理文件。 +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8aad3a1f3d..eb7a7c6322 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,7 +40,7 @@ allowBuilds: # JSONL durability calls MoveFileExW with write-through publication on Windows. koffi: true # The Python runtime deploy includes the reviewed workspace postinstall that - # places node-pty's spawn helper beside the compiled PTY backend. + # restores the executable bit on node-pty's macOS spawn helper. '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true # The Landlock launcher family is our own sibling-repo release, consumed diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index c25eecf5de..ba56be2639 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -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 python/sdk-runtime/README.md -README.md: 0869b4a9dce0e261b168f21c90a80faf81ea4a64 -README.zh.md: 3f2342c4c66e24b8213c78d4e7530c3360497022 +README.md: 29ffc1dcc3ec3b273ccaee64734739f3c4f34b9c +README.zh.md: d79c87090867c60e09c50016bad4797170b34400 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 0869b4a9dc..29ffc1dcc3 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built as one runtime product, and ELF or thin Mach-O headers must match the target. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there, and its thin Mach-O header must match the target. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 3f2342c4c6..d79c870908 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--` 及其原生 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`)。`node-pty` 需要该 helper;两者作为一个运行时产物构建,且 ELF 或 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件,其 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index f2df169cfc..9b54e0c5ed 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -17,26 +17,18 @@ _SPAWN_HELPER_SUFFIX = "-spawn-helper" def _spawn_helper_binary_target(header: bytes) -> str | None: - if ( - len(header) >= 20 - and header[:4] == b"\x7fELF" - and header[4] == 2 - and header[5] == 1 - ): - machine = int.from_bytes(header[18:20], "little") - if machine == 62: - return "linux-x64" - if machine == 183: - return "linux-arm64" if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - if int.from_bytes(header[4:8], "little") == 0x0100000C: + cpu_type = int.from_bytes(header[4:8], "little") + if cpu_type == 0x01000007: + return "macos-x64" + if cpu_type == 0x0100000C: return "macos-arm64" return None def _validate_spawn_helper(path: Path, expected_target: str) -> None: with path.open("rb") as helper: - actual_target = _spawn_helper_binary_target(helper.read(20)) + actual_target = _spawn_helper_binary_target(helper.read(8)) if actual_target != expected_target: raise RuntimeError( f"runtime spawn helper binary mismatch: expected {expected_target}, " @@ -84,15 +76,18 @@ class RuntimeBuildHook(BuildHookInterface): f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}" ) expected_helper = f"{expected_executable}{_SPAWN_HELPER_SUFFIX}" - if [path.name for path in helpers] != [expected_helper]: + expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] + if [path.name for path in helpers] != expected_helpers: + expected = ", ".join(expected_helpers) or "none" found = ", ".join(path.name for path in helpers) or "none" raise RuntimeError( - f"runtime wheel {platform_tag} must contain only {expected_helper}; found {found}" + f"runtime wheel {platform_tag} helper payload mismatch: expected {expected}; found {found}" ) - for executable in [executables[0], helpers[0]]: + for executable in [executables[0], *helpers]: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") - _validate_spawn_helper(helpers[0], expected_target) + if helpers: + _validate_spawn_helper(helpers[0], expected_target) build_data["pure_python"] = False build_data["infer_tag"] = False diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 698676a636..fad1d01cda 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -17,14 +17,10 @@ build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) def helper_header(target: str) -> bytes: - header = bytearray(20) - if target.startswith("linux-"): - header[:6] = b"\x7fELF\x02\x01" - machine = 62 if target == "linux-x64" else 183 - header[18:20] = machine.to_bytes(2, "little") - else: - header[:4] = b"\xcf\xfa\xed\xfe" - header[4:8] = (0x0100000C).to_bytes(4, "little") + header = bytearray(8) + header[:4] = b"\xcf\xfa\xed\xfe" + cpu_type = 0x01000007 if target == "macos-x64" else 0x0100000C + header[4:8] = cpu_type.to_bytes(4, "little") return bytes(header) @@ -76,7 +72,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" executable.write_bytes(b"runtime") executable.chmod(0o755) @@ -89,20 +85,36 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: ) -@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64"]) +@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64"]) +def test_stage_runtime_copies_linux_executable_without_spawn_helper( + tmp_path: Path, target: str +) -> None: + executable = tmp_path / f"dsh-jsonrpc-agent-pkg-{target}" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + destination = tmp_path / "staging" + + build_python_release.stage_runtime(destination, "1.2.3", executable, executable.name) + + runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" + runtime_files = [path.name for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")] + assert runtime_files == [executable.name] + + +@pytest.mark.parametrize("target", ["macos-x64", "macos-arm64"]) def test_spawn_helper_binary_target(target: str) -> None: assert build_python_release.spawn_helper_binary_target(helper_header(target)) == target def test_stage_runtime_rejects_mismatched_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" + executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" executable.write_bytes(b"runtime") executable.chmod(0o755) spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(helper_header("linux-arm64")) + spawn_helper.write_bytes(helper_header("macos-x64")) spawn_helper.chmod(0o755) - with pytest.raises(ValueError, match="expected linux-x64, found linux-arm64"): + with pytest.raises(ValueError, match="expected macos-arm64, found macos-x64"): build_python_release.stage_runtime( tmp_path / "staging", "1.2.3", diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 24cd6c0383..b4b4d23720 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,8 +8,8 @@ import { spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' -import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises' -import { basename, join, resolve, sep } from 'node:path' +import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' const root = resolve(import.meta.dirname, '..') @@ -55,19 +55,11 @@ type Arch = (typeof ARCHES)[number] interface RuntimeProduct { executable: string - spawnHelper: string + spawnHelper?: string } function spawnHelperBinaryTarget(path: string): string | undefined { - const header = readFileSync(path).subarray(0, 20) - if (header.length >= 20 - && header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) - && header[4] === 2 - && header[5] === 1) { - const machine = header.readUInt16LE(18) - if (machine === 62) return 'linux-x64' - if (machine === 183) return 'linux-arm64' - } + const header = readFileSync(path).subarray(0, 8) if (header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf) { const cpuType = header.readUInt32LE(4) if (cpuType === 0x01000007) return 'macos-x64' @@ -76,6 +68,10 @@ function spawnHelperBinaryTarget(path: string): string | undefined { return undefined } +function runtimeProductFiles(product: RuntimeProduct): string[] { + return [product.executable, ...(product.spawnHelper === undefined ? [] : [product.spawnHelper])] +} + function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -317,7 +313,7 @@ class SingleExeBuild { */ async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) - const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` + await this.prepareNativePty(target) if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) await this.run(`pkg ${target.spec}`, pnpmBin(), [ 'dlx', @@ -332,6 +328,8 @@ class SingleExeBuild { if (!this.cli.dryRun && !existsSync(product)) { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } + if (target.platform !== 'macos') return { executable: product } + const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) } else { @@ -342,6 +340,38 @@ class SingleExeBuild { return { executable: product, spawnHelper } } + /** + * Put the target node-pty addon in the staged closure. Linux npm installs + * build it from source, but legacy deploy omits that side-effect directory. + * @param target - the pkg target whose native addon is being staged. + */ + private async prepareNativePty(target: Target): Promise { + const stagedRoot = join(this.staging, 'node_modules', 'node-pty') + const stagedBuild = join(stagedRoot, 'build') + if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) + else await rm(stagedBuild, { recursive: true, force: true }) + + const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' + const prebuilt = join(stagedRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'pty.node') + const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node') + const destination = join(stagedBuild, 'Release', 'pty.node') + if (this.cli.dryRun) { + if (target.platform === 'linux') console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) + return + } + if (existsSync(prebuilt)) return + + const host = Target.host() + if (target.platform !== host.platform || target.arch !== host.arch || !existsSync(source)) { + throw new Error( + `build-exe-for-python-sdk: node-pty native addon for ${target.platform}-${target.arch} is missing; ` + + `checked ${prebuilt}, ${source}. Build the Linux runtime on its target architecture.`, + ) + } + await mkdir(dirname(destination), { recursive: true }) + await copyFile(source, destination) + } + /** * Resolve the node-pty helper that matches a pkg target. * @param target - the pkg target whose helper must be shipped. @@ -349,14 +379,12 @@ class SingleExeBuild { */ private resolveSpawnHelper(target: Target): string { const nodePtyRoot = join(this.staging, 'node_modules', 'node-pty') - const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' const candidates = [ - join(nodePtyRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'spawn-helper'), + join(nodePtyRoot, 'prebuilds', `darwin-${target.arch}`, 'spawn-helper'), ] - const hostPlatform = process.platform === 'darwin' ? 'macos' : process.platform - const hostArch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined - if (target.platform === hostPlatform && target.arch === hostArch) { - candidates.push(join(nodePtyRoot, 'build', 'Release', 'spawn-helper')) + const host = Target.host() + if (target.platform === host.platform && target.arch === host.arch) { + candidates.push(join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'spawn-helper')) } const helper = candidates.find(candidate => existsSync(candidate)) if (helper === undefined) { @@ -387,11 +415,10 @@ class SingleExeBuild { console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:') for (const product of products) { if (this.cli.dryRun) { - console.log(` ${product.executable}`) - console.log(` ${product.spawnHelper}`) + for (const path of runtimeProductFiles(product)) console.log(` ${path}`) continue } - for (const path of [product.executable, product.spawnHelper]) { + for (const path of runtimeProductFiles(product)) { const megabytes = statSync(path).size / (1024 * 1024) console.log(` ${path} (${megabytes.toFixed(1)} MB)`) } @@ -407,7 +434,7 @@ class SingleExeBuild { const destDir = resolve(root, PYTHON_RUNTIME_DIR) if (this.cli.dryRun) { for (const product of products) { - for (const path of [product.executable, product.spawnHelper]) { + for (const path of runtimeProductFiles(product)) { console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) } } @@ -415,7 +442,7 @@ class SingleExeBuild { } mkdirSync(destDir, { recursive: true }) for (const product of products) { - for (const path of [product.executable, product.spawnHelper]) { + for (const path of runtimeProductFiles(product)) { const destination = join(destDir, basename(path)) await copyFile(path, destination) await chmod(destination, statSync(path).mode & 0o777) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 3011120da1..6bbe8bf512 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -27,26 +27,18 @@ EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()} def spawn_helper_binary_target(header: bytes) -> str | None: - if ( - len(header) >= 20 - and header[:4] == b"\x7fELF" - and header[4] == 2 - and header[5] == 1 - ): - machine = int.from_bytes(header[18:20], "little") - if machine == 62: - return "linux-x64" - if machine == 183: - return "linux-arm64" if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - if int.from_bytes(header[4:8], "little") == 0x0100000C: + cpu_type = int.from_bytes(header[4:8], "little") + if cpu_type == 0x01000007: + return "macos-x64" + if cpu_type == 0x0100000C: return "macos-arm64" return None def validate_spawn_helper(path: Path, expected_target: str) -> None: with path.open("rb") as helper: - actual_target = spawn_helper_binary_target(helper.read(20)) + actual_target = spawn_helper_binary_target(helper.read(8)) if actual_target != expected_target: raise ValueError( f"runtime spawn helper binary mismatch: expected {expected_target}, " @@ -166,12 +158,14 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ raise FileNotFoundError(f"runtime executable does not exist: {executable}") if executable.stat().st_mode & stat.S_IXUSR == 0: raise PermissionError(f"runtime executable is not executable: {executable}") + expected_target = EXECUTABLE_TARGETS[executable_name] spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}") - if not spawn_helper.is_file(): - raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") - if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: - raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") - validate_spawn_helper(spawn_helper, EXECUTABLE_TARGETS[executable_name]) + if expected_target.startswith("macos-"): + if not spawn_helper.is_file(): + raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") + if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") + validate_spawn_helper(spawn_helper, expected_target) copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -179,9 +173,10 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ destination_executable = runtime_dir / executable_name shutil.copyfile(executable, destination_executable) destination_executable.chmod(executable.stat().st_mode & 0o777) - destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" - shutil.copyfile(spawn_helper, destination_helper) - destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) + if expected_target.startswith("macos-"): + destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" + shutil.copyfile(spawn_helper, destination_helper) + destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) def verify_wheel( @@ -209,20 +204,27 @@ def verify_wheel( assert platform is not None if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"): raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}") + expected_target = EXECUTABLE_TARGETS[platform[1]] expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}" - if len(helpers) != 1 or not helpers[0].endswith(f"/runtime/{expected_helper}"): - raise RuntimeError(f"{wheel} must contain exactly {expected_helper}, found {helpers}") - for executable in [executables[0], helpers[0]]: + expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] + found_helpers = [Path(helper).name for helper in helpers] + if found_helpers != expected_helpers: + expected = ", ".join(expected_helpers) or "none" + found = ", ".join(found_helpers) or "none" + raise RuntimeError( + f"{wheel} runtime helper payload mismatch: expected {expected}; found {found}" + ) + for executable in [executables[0], *helpers]: mode = archive.getinfo(executable).external_attr >> 16 if mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") - actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:20]) - expected_target = EXECUTABLE_TARGETS[platform[1]] - if actual_target != expected_target: - raise RuntimeError( - f"{wheel} spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'}" - ) + if helpers: + actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:8]) + if actual_target != expected_target: + raise RuntimeError( + f"{wheel} spawn helper binary mismatch: expected {expected_target}, " + f"found {actual_target or 'unsupported format or architecture'}" + ) elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 5de69975f018a2bc1fb972bef2f6aae5141443e4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:56:14 +0800 Subject: [PATCH 16/46] test(python): stabilize executable snapshot --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- scripts/smoke-python-runtime.py | 9 +- .../advanced/result.json | 708 +++++++++++------- .../advanced/session.1.jsonl | 6 +- .../advanced/session.2.jsonl | 6 +- .../advanced/session.jsonl | 30 +- 8 files changed, 468 insertions(+), 299 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index ce64f9d035..27e43ef4b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: fac3d9527b496adaaffdbd8e78401a17c6f0cc0b -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ff6a8be16c5f8591efa9bf383cd47c8fe251fe39 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: f749d6a72b4c32a189a9f848595076457819d9b9 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2b511573bc68e5378279cec8d22ce960af0966e9 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index fac3d9527b..f749d6a72b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -62,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The comparison normalizes the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index ff6a8be16c..2b511573bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -62,7 +62,7 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在“决策”各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对模拟端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个由 spawn 提供方直接启动的 subagent(子 agent)和一个会通过 spawn 启动第二个子 agent 的工作流,随后卸载该插件。比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在“决策”各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对模拟端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个由 spawn 提供方直接启动的 subagent(子 agent)和一个会通过 spawn 启动第二个子 agent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 手工驱动注意:`bin` 将 stdin EOF 视为“客户端已离开”并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 346818e5fa..3ce800932f 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -72,6 +72,9 @@ CUSTOM_CORDIS = """\ name: '@deepseek-ai/dsh-agent-spine-demo' config: workspaceContext: false + skills: + enabled: false + toolBash: false tools: mode: both - id: sessions @@ -79,10 +82,6 @@ CUSTOM_CORDIS = """\ config: root: !!js process.env.DSH_SESSION_ROOT compression: 'none' -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - cwd: !!js process.env.DSH_CWD - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' - id: subagents @@ -874,6 +873,8 @@ def normalize_snapshot_value( normalized["createdAt"] = 0 if "seq" in normalized and "time" in normalized: normalized["time"] = 0 + if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "user"): + normalized["id"] = "{{messageId}}" scrub_snapshot_header(normalized) return normalized diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 07393e7f25..79daa0570c 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -30,7 +30,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" }, @@ -70,20 +72,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -176,17 +173,22 @@ "data": { "turn": 1, "step": 1, - "content": [ - { - "type": "tool-call", - "id": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -221,14 +223,27 @@ "data": { "turn": 1, "step": 1, - "callId": "advanced-mount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-mount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-mount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 11 @@ -266,21 +281,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -373,17 +383,22 @@ "data": { "turn": 1, "step": 2, - "content": [ - { - "type": "tool-call", - "id": "advanced-code", - "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -451,14 +466,27 @@ "data": { "turn": 1, "step": 2, - "callId": "advanced-code", - "content": [ - { - "type": "text", - "text": "42" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-code" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-code", + "content": [ + { + "type": "text", + "text": "42" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 22 @@ -570,17 +598,22 @@ "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": "smoke-model" + "message": { + "role": "assistant", + "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.\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -615,14 +648,27 @@ "data": { "turn": 1, "step": 3, - "callId": "advanced-direct-child", - "content": [ - { - "type": "text", - "text": "DIRECT_CHILD_OK" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-direct-child" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-direct-child", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 34 @@ -734,17 +780,22 @@ "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-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "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-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -779,14 +830,27 @@ "data": { "turn": 1, "step": 4, - "callId": "advanced-workflow", - "content": [ - { - "type": "text", - "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-workflow" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-workflow", + "content": [ + { + "type": "text", + "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 44 @@ -898,17 +962,22 @@ "data": { "turn": 1, "step": 5, - "content": [ - { - "type": "tool-call", - "id": "advanced-unmount", - "name": "cordis_unmount", - "arguments": "{\"id\": \"dyn-1\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -943,14 +1012,27 @@ "data": { "turn": 1, "step": 5, - "callId": "advanced-unmount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 was unmounted and removed." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-unmount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-unmount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 was unmounted and removed." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 54 @@ -988,20 +1070,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -1090,15 +1167,20 @@ "data": { "turn": 1, "step": 6, - "content": [ - { - "type": "text", - "text": "ADVANCED_EXECUTABLE_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1173,7 +1255,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" } @@ -1231,20 +1315,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -1373,17 +1452,22 @@ "data": { "turn": 1, "step": 1, - "content": [ - { - "type": "tool-call", - "id": "advanced-mount", - "name": "cordis_mount", - "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1430,14 +1514,27 @@ "data": { "turn": 1, "step": 1, - "callId": "advanced-mount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-mount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-mount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 11 @@ -1493,21 +1590,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -1636,17 +1728,22 @@ "data": { "turn": 1, "step": 2, - "content": [ - { - "type": "tool-call", - "id": "advanced-code", - "name": "run_code", - "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1738,14 +1835,27 @@ "data": { "turn": 1, "step": 2, - "callId": "advanced-code", - "content": [ - { - "type": "text", - "text": "42" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-code" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-code", + "content": [ + { + "type": "text", + "text": "42" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 22 @@ -1905,17 +2015,22 @@ "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": "smoke-model" + "message": { + "role": "assistant", + "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.\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -1995,7 +2110,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" } @@ -2053,21 +2170,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -2192,15 +2304,20 @@ "data": { "turn": 1, "step": 1, - "content": [ - { - "type": "text", - "text": "DIRECT_CHILD_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -2278,14 +2395,27 @@ "data": { "turn": 1, "step": 3, - "callId": "advanced-direct-child", - "content": [ - { - "type": "text", - "text": "DIRECT_CHILD_OK" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-direct-child" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-direct-child", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 34 @@ -2445,17 +2575,22 @@ "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-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "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-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -2535,7 +2670,9 @@ ], "source": { "kind": "user" - } + }, + "role": "user", + "id": "{{messageId}}" }, "surfaceOp": "append" } @@ -2593,21 +2730,16 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "snapshot_double", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "initial" @@ -2732,15 +2864,20 @@ "data": { "turn": 1, "step": 1, - "content": [ - { - "type": "text", - "text": "WORKFLOW_CHILD_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "WORKFLOW_CHILD_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -2818,14 +2955,27 @@ "data": { "turn": 1, "step": 4, - "callId": "advanced-workflow", - "content": [ - { - "type": "text", - "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-workflow" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-workflow", + "content": [ + { + "type": "text", + "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 44 @@ -2985,17 +3135,22 @@ "data": { "turn": 1, "step": 5, - "content": [ - { - "type": "tool-call", - "id": "advanced-unmount", - "name": "cordis_unmount", - "arguments": "{\"id\": \"dyn-1\"}" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, @@ -3042,14 +3197,27 @@ "data": { "turn": 1, "step": 5, - "callId": "advanced-unmount", - "content": [ - { - "type": "text", - "text": "Temporary Plugin dyn-1 was unmounted and removed." - } - ], - "isError": false + "message": { + "source": { + "kind": "tool", + "callId": "advanced-unmount" + }, + "content": [ + { + "type": "tool-result", + "toolCallId": "advanced-unmount", + "content": [ + { + "type": "text", + "text": "Temporary Plugin dyn-1 was unmounted and removed." + } + ], + "isError": false + } + ], + "role": "user", + "id": "{{messageId}}" + } }, "sourceEventSeqs": [ 54 @@ -3105,20 +3273,15 @@ }, "system": "{{system}}", "tools": [ - "bash", "cordis_inspect", "cordis_mount", "cordis_unmount", "run_code", - "skill", "subagent", "task_kill", "task_list", "task_output", "workflow" - ], - "messagePrefix": [ - "{{messagePrefix}}" ] }, "reason": "change" @@ -3243,15 +3406,20 @@ "data": { "turn": 1, "step": 6, - "content": [ - { - "type": "text", - "text": "ADVANCED_EXECUTABLE_OK" - } - ], - "provenance": { - "provider": "deepseek", - "model": "smoke-model" + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + ], + "source": { + "kind": "model", + "provider": "deepseek", + "model": "smoke-model" + }, + "id": "{{messageId}}" }, "usage": { "inputTokens": 3, diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 05998f8980..2929f8664c 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -1,14 +1,14 @@ {"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} {"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":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index 778c200078..a5da33d006 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -1,14 +1,14 @@ {"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} {"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":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index 1078fe4985..1f2f890b3c 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -1,30 +1,30 @@ {"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"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":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} {"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} {"type":"assistant/chunk","seq":18,"time":0,"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.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} {"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} {"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} {"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} {"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} -{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}} {"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -32,9 +32,9 @@ {"type":"assistant/chunk","seq":30,"time":0,"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":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":33,"time":0,"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":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} +{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","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.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} {"type":"tool/call","seq":34,"time":0,"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":35,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[34],"surfaceOp":"append"} +{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} {"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}} {"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -42,9 +42,9 @@ {"type":"assistant/chunk","seq":40,"time":0,"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-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} {"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":43,"time":0,"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-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} +{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","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-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} {"type":"tool/call","seq":44,"time":0,"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-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} -{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[44],"surfaceOp":"append"} +{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} {"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}} {"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -52,17 +52,17 @@ {"type":"assistant/chunk","seq":50,"time":0,"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":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} +{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} {"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}} {"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}} -{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"reason":"change"}} +{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} {"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} {"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} {"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} From 525b3aa6c98113420983e344a6943087d9758156 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:20:08 +0800 Subject: [PATCH 17/46] fix(python): resolve Linux runtime without helper --- python/sdk-runtime/README.i18n.yaml | 4 +-- python/sdk-runtime/README.md | 4 +-- python/sdk-runtime/README.zh.md | 4 +-- .../src/deepseek_harness_runtime/__init__.py | 27 ++++++++++--------- python/sdk/tests/test_runtime_resolution.py | 27 +++++++++++++++++++ 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index ba56be2639..129dac85d5 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -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 python/sdk-runtime/README.md -README.md: 29ffc1dcc3ec3b273ccaee64734739f3c4f34b9c -README.zh.md: d79c87090867c60e09c50016bad4797170b34400 +README.md: efdb5cf9f87e0831ef09a47e6ffdb99254a17f36 +README.zh.md: cafac7418608c416a9f291d62442c32b8787b1fc diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 29ffc1dcc3..efdb5cf9f8 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -15,12 +15,12 @@ Both carriers hold the same content, defined once: the [package.json](package.js A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. -Each wheel contains exactly one runtime executable and its matching native spawn helper. A missing sidecar makes the runtime installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools; old exe-only wheels are intentionally unsupported. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. +Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. ## Resolution API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. -- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; it validates that the required sibling `-spawn-helper` is also installed). The node carrier has no single-path equivalent and launches via the argv tuple above. +- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; on macOS it validates that the required sibling `-spawn-helper` is also installed). The node carrier has no single-path equivalent and launches via the argv tuple above. - `bundled_default_config_path() -> Path` — the checked-in default config (see below). - `bundled_package_dir() -> Path` — the installed package data root. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index d79c870908..cafac74186 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -15,12 +15,12 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 `node` 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件及其匹配的原生 spawn helper。缺少伴随文件意味着运行时安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此;旧的仅 exe wheel 有意不再兼容。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、运行时文件缺失或重复、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 ## 解析 API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,`node` 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 `node` 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 -- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体,并会校验必要的 `-spawn-helper` 伴随文件也已安装)。`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体,并会在 macOS 上校验必要的 `-spawn-helper` 伴随文件也已安装)。`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动。 - `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 - `bundled_package_dir() -> Path`——已安装包的数据根目录。 diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 9228281ab2..d6f8c497b6 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -5,8 +5,8 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's - **exe (production)**: single-file Node executables named ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in - {x64, arm64}) plus a sibling ``-spawn-helper`` used by ``node-pty``; the - target machine needs no Node installation. + {x64, arm64}); macOS also uses a sibling ``-spawn-helper``. The target machine + needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`` on a @@ -71,11 +71,11 @@ def bundled_default_config_path() -> Path: def bundled_runtime_path() -> Path: """Absolute path of the bundled single-file runtime executable for the current platform. - Raises FileNotFoundError when the platform is unsupported or the executable - has not been placed into this package; the message names the acquisition - routes (acquisition strategy is deliberately separate from this lookup - interface, so an on-demand download can replace it without touching - callers). + Raises FileNotFoundError when the platform is unsupported, the executable + has not been placed into this package, or the required macOS spawn helper is + missing; the message names the acquisition routes (acquisition strategy is + deliberately separate from this lookup interface, so an on-demand download + can replace it without touching callers). """ tag = _current_platform_tag() path = bundled_package_dir() / "runtime" / f"dsh-jsonrpc-agent-pkg-{tag}" @@ -84,12 +84,13 @@ def bundled_runtime_path() -> Path: f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + _EXE_ACQUISITION_HINT ) - helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") - if not helper.is_file(): - raise FileNotFoundError( - f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " - + _EXE_ACQUISITION_HINT - ) + if tag.startswith("macos-"): + helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") + if not helper.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " + + _EXE_ACQUISITION_HINT + ) return path diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 400394ae4e..e0411bb1fd 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -2,6 +2,9 @@ from __future__ import annotations +from pathlib import Path + +import deepseek_harness_runtime as runtime import pytest from deepseek_harness_runtime import ( @@ -39,3 +42,27 @@ def test_explicit_mode_wins_over_env_mode(monkeypatch: pytest.MonkeyPatch) -> No except FileNotFoundError: return # explicit 'exe' was honored; only the artifact is missing assert args[0].endswith(("-x64", "-arm64")) + + +@pytest.mark.parametrize( + ("platform_tag", "requires_helper"), + [("linux-x64", False), ("macos-arm64", True)], +) +def test_runtime_requires_spawn_helper_only_on_macos( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + platform_tag: str, + requires_helper: bool, +) -> None: + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + executable = runtime_dir / f"dsh-jsonrpc-agent-pkg-{platform_tag}" + executable.touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: platform_tag) + + if requires_helper: + with pytest.raises(FileNotFoundError, match="node-pty spawn helper"): + runtime.bundled_runtime_path() + else: + assert runtime.bundled_runtime_path() == executable From fbfe520471024a2da483e3322a94f96f7838ad8e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:48:43 +0800 Subject: [PATCH 18/46] fix(editor): preserve literal replacement text --- packages/fs/tool-str-replace-editor/src/index.ts | 5 +++-- .../tool-str-replace-editor/tests/tools.spec.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 801e9200b4..071ada3ec6 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -308,7 +308,8 @@ async function replaceInFile( } const before = await ctx.fs.readText(target, exec.signal) const offsets = matchOffsets(before, oldValue) - if (offsets.length === 0) { + const offset = offsets[0] + if (offset === undefined) { throw new FsError( `No replacement was performed, old_str \`${oldValue}\` did not appear verbatim in ${target.displayPath}.`, 'FS_EDIT_NOT_FOUND', @@ -325,7 +326,7 @@ async function replaceInFile( try { outcome = await ctx.fs.writeText( target, - before.replace(oldValue, newValue), + before.slice(0, offset) + newValue + before.slice(offset + oldValue.length), intent === undefined ? { kind: 'replaceIfVersion', version: info.version } : { kind: 'replaceIfVersion', version: intent.version }, diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index f1fafcfa04..90b182a43f 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -194,6 +194,21 @@ describe('tool-str-replace-editor', () => { expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n') }) + it('writes replacement text literally', async () => { + const { ctx, root, owner } = await setup() + const sample = join(root, 'literal.txt') + const replacement = "$&|$`|$'|$$" + await writeFile(sample, 'before OLD after') + + expect((await call(ctx, owner, { + command: 'str_replace', + path: sample, + old_str: 'OLD', + new_str: replacement, + })).isError).toBe(false) + expect(await readFile(sample, 'utf8')).toBe(`before ${replacement} after`) + }) + it('lists visible entries to depth two and clips at the configured view limit', async () => { const { ctx, root, owner } = await setup({ maxOutputChars: 10_000 }) await mkdir(join(root, 'dir', 'nested', 'third'), { recursive: true }) From 7f5aa2d053ff7c2068dd9b7b90a2d53a8d8713b3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:50:12 +0800 Subject: [PATCH 19/46] fix(persistent-bash): wait for complete status markers --- .../pty/tool-bash-persistent/src/index.ts | 10 +++++---- .../tool-bash-persistent/tests/tools.spec.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 2ad2bd54b8..90cf1b4774 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -93,16 +93,18 @@ function stripPrompt(text: string): string { function commandOutput( snapshot: RetainedOutput, marker: CommandMarkers, -): CapturedOutput { +): CapturedOutput | undefined { const text = snapshot.text const end = text.lastIndexOf(marker.end) - const exitCode = Number.parseInt(text.slice(end + marker.end.length), 10) + if (end < 0) return undefined + const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1] + if (status === undefined) return undefined const startMarker = text.lastIndexOf(marker.start, end) const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), incomplete: startMarker < 0, - exitCode, + exitCode: Number(status), } } @@ -317,7 +319,7 @@ async function executeCommand( } if (latest.text.includes(marker.end)) { const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker) - return renderCaptured(complete, config.maxOutputChars) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { const snapshot = retainedScrollback(ctx, owner, id, latest) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 1a9d15c476..f3bd7bf40b 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -84,6 +84,8 @@ type StubMode = | 'idle-then-normal' | 'large' | 'nonzero' + | 'torn-status' + | 'finish-torn-status' | 'end-only' | 'init-exit' | 'init-timeout' @@ -160,6 +162,17 @@ class StubPtySession implements PtyBackendSession { this.pendingText = '' const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0] const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0] + if (this.mode === 'torn-status') { + const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` + this.scrollback += output + this.mode = 'finish-torn-status' + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + if (this.mode === 'finish-torn-status') { + const output = `7\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } if (this.mode === 'end-only') { const output = `recovered output\n${end ?? ''}0\n${this.motd}` this.scrollback += output @@ -345,6 +358,15 @@ describe('tool-bash-persistent', () => { expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) + it('waits for status digits after a torn completion marker', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'torn-status' + stub.sessions[0]!.scrollback = '' + + expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') + }) + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up') From 84394e596d684e7445715c44cc482323dccec2a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:50:52 +0800 Subject: [PATCH 20/46] cleanup(editor): remove dead path-base plumbing --- packages/fs/tool-str-replace-editor/src/index.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 071ada3ec6..c003d0915b 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -105,15 +105,13 @@ class MutationPolicy { async function resolveTarget( ctx: Context, path: string, - exec: ToolRunContext, - workspaceRoot?: string, + signal: AbortSignal, ): Promise { if (path.trim().length === 0) throw new Error('path must be a non-empty string') if (!isAbsolute(path)) { throw new Error(`The path ${path} is not an absolute path, it should start with \`/\`. Maybe you meant /${path}?`) } - const cwd = exec.agent?.session.header.cwd ?? workspaceRoot - return ctx.fs.resolve(path, cwd === undefined ? { signal: exec.signal } : { cwd, signal: exec.signal }) + return ctx.fs.resolve(path, { signal }) } async function statExisting( @@ -238,7 +236,7 @@ async function viewPath( maxOutputChars: number, exec: ToolRunContext, ): Promise { - const target = await resolveTarget(ctx, path, exec) + const target = await resolveTarget(ctx, path, exec.signal) const info = await statExisting(ctx, target, 'view', exec) if (info.type === 'directory') { if (viewRange !== undefined) { @@ -263,7 +261,7 @@ async function createFile( ): Promise { const content = requiredForCommand(fileText, 'file_text', 'create') const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec.signal) if (await ctx.fs.stat(target, exec.signal) !== undefined) { throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`) } @@ -298,7 +296,7 @@ async function replaceInFile( exec: ToolRunContext, ): Promise { const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec.signal) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const oldValue = requiredForCommand(oldStr, 'old_str', 'str_replace', false) const newValue = newStr ?? '' @@ -351,7 +349,7 @@ async function insertInFile( if (insertLine === undefined) throw new Error('Parameter `insert_line` is required for command: insert') const value = requiredForCommand(newStr, 'new_str', 'insert') const sandboxPolicy = policy.resolve(exec) - const target = await resolveTarget(ctx, path, exec, sandboxPolicy?.workspaceRoot) + const target = await resolveTarget(ctx, path, exec.signal) const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const info = await statExisting(ctx, target, 'insert', exec) if (info.type !== 'file') { From d8b70df00c62c0aa4cf9ddd238a45d0d87a59261 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:53:59 +0800 Subject: [PATCH 21/46] test(snapshot): isolate persistent tool artifacts --- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 56 ++++++++++++------- .../notifications.expected.jsonl | 22 ++++---- .../snapshots/persistent-tools/session.jsonl | 22 ++++---- 3 files changed, 57 insertions(+), 43 deletions(-) diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 26a6461586..8f8da33611 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -11,7 +11,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { delimiter, isAbsolute, join } from 'node:path' +import { basename, delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { @@ -55,7 +55,7 @@ interface SdkScenario { children: number /** Optional scenario-specific live and replay compositions. */ configs?: { live: string; replay: string } - /** Files whose final contents are part of the scenario contract. */ + /** Cwd-relative files whose final contents are part of the scenario contract. */ expectedFiles?: Readonly> } @@ -80,13 +80,11 @@ const SCENARIOS: SdkScenario[] = [ }, { name: 'persistent-tools', - prompt: 'Prove that bash state persists, then create and edit note.txt.', + prompt: 'Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt.', sessionId: 'persistent-tools-snapshot', children: 0, configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, - // Replay returns recorded tool arguments verbatim, so this cross-platform - // POSIX fixture uses one stable absolute path and cleans it around the run. - expectedFiles: { '/tmp/dsh-persistent-tools-snapshot-note.txt': 'beta\n' }, + expectedFiles: { 'note.txt': 'beta\n' }, }, ] @@ -96,6 +94,10 @@ interface PersistedLog { readonly header: Record } +interface MissingFile { + readonly missing: true +} + async function jsonlFiles(dir: string): Promise { const entries = await readdir(dir, { recursive: true }) return entries.filter(entry => entry.endsWith('.jsonl')).map(entry => join(dir, entry)).sort() @@ -125,6 +127,25 @@ function contextOfContents(contents: readonly string[]): NormalizeContext { } } +async function hydrateReplayFixtures(scenario: SdkScenario, cwd: string): Promise { + const root = join(cwd, '.replay-fixtures') + await mkdir(root, { recursive: true }) + return Promise.all(fixtureFiles(scenario).map(async (source) => { + const destination = join(root, basename(source)) + await writeFile(destination, (await readFile(source, 'utf8')).replaceAll('{{cwd}}', cwd)) + return destination + })) +} + +async function readExpectedFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch (error: unknown) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return { missing: true } + throw error + } +} + /** * Normalize the SDK-visible notification stream: embedded `session.event` * envelopes get the session-log treatment (times zeroed, headers tokenized), @@ -163,24 +184,18 @@ async function runScenario(scenario: SdkScenario): Promise<{ result: TurnResult notifications: HarnessNotification[] logs: PersistedLog[] - observedFiles: Record + observedFiles: Record cwd: string }> { const cwd = await mkdtemp(join(tmpdir(), `sdk-snapshot-${scenario.name}-`)) const sessionsRoot = join(cwd, '.sessions') - const scenarioDir = join(snapshotsDir, scenario.name) - const expectedFilePaths = Object.keys(scenario.expectedFiles ?? {}).map(path => - isAbsolute(path) ? path : join(cwd, path)) - await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true }))) + const replayFixtures = recording ? [] : await hydrateReplayFixtures(scenario, cwd) const launch = resolveExampleLaunch({ srcBin: runtimeBin, configArgs: [], tsconfigPath: repoTsconfig, }) - const childFixtures = Array.from( - { length: scenario.children }, - (_, index) => join(scenarioDir, `session.${index + 1}.jsonl`), - ) + const [parentFixture, ...childFixtures] = replayFixtures const env: Record = { ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, @@ -191,8 +206,8 @@ async function runScenario(scenario: SdkScenario): Promise<{ DSH_CWD: cwd, DSH_SNAPSHOT: mode, NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - ...recording ? {} : { - DSH_SNAPSHOT_FILE: join(scenarioDir, 'session.jsonl'), + ...parentFixture === undefined ? {} : { + DSH_SNAPSHOT_FILE: parentFixture, ...childFixtures.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: childFixtures.join(delimiter) } : {}, }, } @@ -211,22 +226,21 @@ async function runScenario(scenario: SdkScenario): Promise<{ }) try { const notifications: HarnessNotification[] = [] - const result = await harness.run(scenario.prompt, { + const result = await harness.run(scenario.prompt.replaceAll('{{cwd}}', cwd), { sessionId: scenario.sessionId, onNotification: (notification) => { notifications.push(notification) }, }) await harness.close() const logs = await persistedLogs(sessionsRoot) const observedFiles = Object.fromEntries(await Promise.all( - Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string]> => [ + Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string | MissingFile]> => [ path, - await readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8'), + await readExpectedFile(join(cwd, path)), ]), )) return { result, notifications, logs, observedFiles, cwd } } finally { await harness.close() - await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true }))) await rm(cwd, { recursive: true, force: true }) } } diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl index 481e0a3d08..264835dd0a 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl @@ -1,5 +1,5 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit note.txt."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} @@ -24,23 +24,23 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"file_text\":\"alpha\\n\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: /tmp/dsh-{{sessionId}}-note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[31],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[31],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-{{sessionId}}-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file /tmp/dsh-{{sessionId}}-note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl index d4c36a93ac..cdab76078e 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"persistent-tools-snapshot","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785331618311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit note.txt."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785331618312,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785331618313,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -25,23 +25,23 @@ {"type":"step/end","seq":23,"time":1785331618759,"data":{"turn":1,"step":2}} {"type":"step/start","seq":24,"time":1785331618759,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":25,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":26,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}}} -{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"type":"assistant/chunk","seq":26,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}} {"type":"assistant/chunk","seq":28,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":29,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6407aec3-f75c-427a-8783-a61bd99327bb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} -{"type":"tool/call","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"file_text\":\"alpha\\n\"}"}} -{"type":"tool/result","seq":32,"time":1785331618782,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: /tmp/dsh-persistent-tools-snapshot-note.txt"}],"isError":false}],"role":"user","id":"121833da-381d-492e-9d6c-82eaa9694ef1"}},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"assistant/message","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6407aec3-f75c-427a-8783-a61bd99327bb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}} +{"type":"tool/result","seq":32,"time":1785331618782,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"121833da-381d-492e-9d6c-82eaa9694ef1"}},"sourceEventSeqs":[31],"surfaceOp":"append"} {"type":"step/end","seq":33,"time":1785331618782,"data":{"turn":1,"step":3}} {"type":"step/start","seq":34,"time":1785331618782,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":35,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":36,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}} -{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"type":"assistant/chunk","seq":36,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}} +{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} {"type":"assistant/chunk","seq":38,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":39,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1cf1d34c-faee-464d-bdd7-413ba7233e23"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} -{"type":"tool/call","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"/tmp/dsh-persistent-tools-snapshot-note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}} -{"type":"tool/result","seq":42,"time":1785331618799,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file /tmp/dsh-persistent-tools-snapshot-note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"c88746c2-208d-46aa-8c3d-79ccc88c7f6d"}},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"assistant/message","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1cf1d34c-faee-464d-bdd7-413ba7233e23"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}} +{"type":"tool/result","seq":42,"time":1785331618799,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"c88746c2-208d-46aa-8c3d-79ccc88c7f6d"}},"sourceEventSeqs":[41],"surfaceOp":"append"} {"type":"step/end","seq":43,"time":1785331618799,"data":{"turn":1,"step":4}} {"type":"step/start","seq":44,"time":1785331618799,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":45,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} From 38e4a7bf7fc03c0f84c51f53326099cee1506620 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:56:28 +0800 Subject: [PATCH 22/46] test(snapshot): pin persistent tool schemas --- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 8f8da33611..a716a834b8 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -57,6 +57,8 @@ interface SdkScenario { configs?: { live: string; replay: string } /** Cwd-relative files whose final contents are part of the scenario contract. */ expectedFiles?: Readonly> + /** Assembled model-facing tool names and required argument keys. */ + expectedTools?: Readonly> } const SCENARIOS: SdkScenario[] = [ @@ -85,6 +87,7 @@ const SCENARIOS: SdkScenario[] = [ children: 0, configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, expectedFiles: { 'note.txt': 'beta\n' }, + expectedTools: { bash: ['command'], str_replace_editor: ['command', 'path'] }, }, ] @@ -112,6 +115,20 @@ async function persistedLogs(sessionsRoot: string): Promise { })) } +interface LoggedRequestHeader { + type?: string + data?: { header?: { tools?: Array<{ name: string; parameters: { required?: string[] } }> } } +} + +function assembledToolRequirements(log: PersistedLog): Record { + const event = log.content.trimEnd().split('\n') + .map(line => JSON.parse(line) as LoggedRequestHeader) + .find(candidate => candidate.type === 'request/header') + const tools = event?.data?.header?.tools + if (tools === undefined) throw new Error('session log has no request/header tools') + return Object.fromEntries(tools.map(tool => [tool.name, tool.parameters.required ?? []])) +} + function contextOf(logs: readonly { content: string; header: Record }[], cwd: string): NormalizeContext { return { sessionIds: logs.flatMap(log => typeof log.header.id === 'string' ? [log.header.id] : []), @@ -337,6 +354,11 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { expect(result.status).toBe('ok') expect(notifications.at(-1)?.method).toBe('session.finished') expect(observedFiles).toEqual(scenario.expectedFiles ?? {}) + if (scenario.expectedTools !== undefined) { + const parent = ordered[0] + if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) + expect(assembledToolRequirements(parent)).toEqual(scenario.expectedTools) + } if (scenario.children > 0) { expect(notifications.some(n => n.method === 'subagent.started')).toBe(true) expect(notifications.some(n => n.method === 'subagent.finished')).toBe(true) From 514238ee47c5cccd1dc16de6ee39d90a6b6bda22 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:05:58 +0800 Subject: [PATCH 23/46] fix(editor): preserve tabs in file views --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 4 +- .../notifications.expected.jsonl | 46 +++++++++++-------- .../snapshots/persistent-tools/session.jsonl | 46 +++++++++++-------- .../tool-str-replace-editor/README.i18n.yaml | 4 +- packages/fs/tool-str-replace-editor/README.md | 2 +- .../fs/tool-str-replace-editor/README.zh.md | 2 +- .../fs/tool-str-replace-editor/src/index.ts | 23 ++-------- .../tests/tools.spec.ts | 6 ++- 11 files changed, 73 insertions(+), 68 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 0e31711c21..59f972d856 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 6e8a1df7f04340f4a97c0b799aaace0a78526ba5 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 256ffad7945cbb4367b9cc8bb92e9b2968ab4501 +2026-07-29-persistent-bash-str-replace-editor.md: 006031248fbe74d72cbecb3dad88deb9f035e023 +2026-07-29-persistent-bash-str-replace-editor.zh.md: f134c3499648ef6c544bc251084136984c384bb2 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 6e8a1df7f0..006031248f 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -12,7 +12,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. -`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute, mutations preserve tabs outside the requested edit, and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. +`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute; file views preserve content tabs so copied text remains valid literal replacement input; mutations preserve tabs outside the requested edit; and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 256ffad794..f134c34996 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -12,7 +12,7 @@ `@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 -`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径,变更会保留请求编辑范围之外的制表符,且公开 schema 与错误只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 +`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径;文件查看会保留内容中的制表符,因此复制的文本仍可作为有效的字面量替换输入;变更会保留请求编辑范围之外的制表符;公开 schema 与错误则只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index a716a834b8..a750ddc5e4 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -82,11 +82,11 @@ const SCENARIOS: SdkScenario[] = [ }, { name: 'persistent-tools', - prompt: 'Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt.', + prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line.', sessionId: 'persistent-tools-snapshot', children: 0, configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, - expectedFiles: { 'note.txt': 'beta\n' }, + expectedFiles: { 'note.txt': 'target:\n\tnew\n' }, expectedTools: { bash: ['command'], str_replace_editor: ['command', 'path'] }, }, ] diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl index 264835dd0a..b1494b95ca 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl @@ -1,6 +1,6 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Prove that bash state persists.","messageSeqs":[1],"source":{"kind":"fallback"}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} @@ -24,31 +24,41 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[31],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-view","name":"str_replace_editor","argumentsDelta":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-view"},"content":[{"type":"tool-result","toolCallId":"editor-view","content":[{"type":"text","text":"Here's the content of {{cwd}}/note.txt with line numbers (which has a total of 3 lines):\n 1 target:\n 2 \told\n 3 \n"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":51,"time":0,"data":{"turn":1,"step":5}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":52,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":51,"time":0,"data":{"turn":1,"step":5,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":52,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[51],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":53,"time":0,"data":{"turn":1,"step":5}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":54,"time":0,"data":{"turn":1,"step":6}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":61,"time":0,"data":{"turn":1,"step":6}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":62,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl index cdab76078e..b4094876ea 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"persistent-tools-snapshot","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785331618311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists,","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists.","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785331618312,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785331618313,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785331618325,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -25,30 +25,40 @@ {"type":"step/end","seq":23,"time":1785331618759,"data":{"turn":1,"step":2}} {"type":"step/start","seq":24,"time":1785331618759,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":25,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":26,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}} -{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}}}} +{"type":"assistant/chunk","seq":26,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}} {"type":"assistant/chunk","seq":28,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":29,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6407aec3-f75c-427a-8783-a61bd99327bb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} -{"type":"tool/call","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"alpha\\n\"}"}} +{"type":"assistant/message","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6407aec3-f75c-427a-8783-a61bd99327bb"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}} {"type":"tool/result","seq":32,"time":1785331618782,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"121833da-381d-492e-9d6c-82eaa9694ef1"}},"sourceEventSeqs":[31],"surfaceOp":"append"} {"type":"step/end","seq":33,"time":1785331618782,"data":{"turn":1,"step":3}} {"type":"step/start","seq":34,"time":1785331618782,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":35,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":36,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}} -{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}}}} +{"type":"assistant/chunk","seq":36,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-view","name":"str_replace_editor","argumentsDelta":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}} +{"type":"assistant/chunk","seq":37,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}} {"type":"assistant/chunk","seq":38,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":39,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1cf1d34c-faee-464d-bdd7-413ba7233e23"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} -{"type":"tool/call","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"alpha\",\"new_str\":\"beta\"}"}} -{"type":"tool/result","seq":42,"time":1785331618799,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"c88746c2-208d-46aa-8c3d-79ccc88c7f6d"}},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"assistant/message","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1cf1d34c-faee-464d-bdd7-413ba7233e23"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}} +{"type":"tool/result","seq":42,"time":1785331618799,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-view"},"content":[{"type":"tool-result","toolCallId":"editor-view","content":[{"type":"text","text":"Here's the content of {{cwd}}/note.txt with line numbers (which has a total of 3 lines):\n 1 target:\n 2 \told\n 3 \n"}],"isError":false}],"role":"user","id":"c88746c2-208d-46aa-8c3d-79ccc88c7f6d"}},"sourceEventSeqs":[41],"surfaceOp":"append"} {"type":"step/end","seq":43,"time":1785331618799,"data":{"turn":1,"step":4}} {"type":"step/start","seq":44,"time":1785331618799,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":45,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":46,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} -{"type":"assistant/chunk","seq":47,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":45,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}} +{"type":"assistant/chunk","seq":47,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}} {"type":"assistant/chunk","seq":48,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":49,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":50,"time":1785331618802,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b8832049-1795-4127-b0e0-e31528da0e99"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} -{"type":"step/end","seq":51,"time":1785331618802,"data":{"turn":1,"step":5}} -{"type":"turn/end","seq":52,"time":1785331618802,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":49,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1785331618802,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b8832049-1795-4127-b0e0-e31528da0e99"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1785331618802,"data":{"turn":1,"step":5,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}} +{"type":"tool/result","seq":52,"time":1785331618803,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"ee874ae7-c4d9-4075-9b40-45e643a4b159"}},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1785331618803,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":54,"time":1785331618803,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":55,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":56,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} +{"type":"assistant/chunk","seq":57,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":58,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":59,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":60,"time":1785331618805,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"8e39f4fe-5538-46be-b24a-84296d638c44"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1785331618805,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":62,"time":1785331618805,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/fs/tool-str-replace-editor/README.i18n.yaml b/packages/fs/tool-str-replace-editor/README.i18n.yaml index 9c7a2190c4..7d08a33f9a 100644 --- a/packages/fs/tool-str-replace-editor/README.i18n.yaml +++ b/packages/fs/tool-str-replace-editor/README.i18n.yaml @@ -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 packages/fs/tool-str-replace-editor/README.md -README.md: 12224537ab2ca2d2ba97e93fe8dc2192fa9ac1aa -README.zh.md: 5481723f8a3077ee329ec202b12a67b678abc691 +README.md: 97e9e0ab9ade7c7241c1aac3e2489e055d01ff8f +README.zh.md: 48358eb3c9d81ddad6a83c4ff3ef0cf6542096b1 diff --git a/packages/fs/tool-str-replace-editor/README.md b/packages/fs/tool-str-replace-editor/README.md index 12224537ab..97e9e0ab9a 100644 --- a/packages/fs/tool-str-replace-editor/README.md +++ b/packages/fs/tool-str-replace-editor/README.md @@ -13,7 +13,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w ## Tool -The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit. +The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit. ## Model Experience diff --git a/packages/fs/tool-str-replace-editor/README.zh.md b/packages/fs/tool-str-replace-editor/README.zh.md index 5481723f8a..48358eb3c9 100644 --- a/packages/fs/tool-str-replace-editor/README.zh.md +++ b/packages/fs/tool-str-replace-editor/README.zh.md @@ -13,7 +13,7 @@ ## 工具 -Schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。 +Schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从一开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。 ## 模型体验 diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index c003d0915b..6d0f65c48c 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -35,23 +35,6 @@ function maybeTruncate(content: string, maxOutputChars: number): string { : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE } -function expandTabs(content: string, tabSize = 8): string { - let column = 0 - let result = '' - for (const character of content) { - if (character === '\t') { - const spaces = tabSize - (column % tabSize) - result += ' '.repeat(spaces) - column += spaces - continue - } - result += character - if (character === '\n' || character === '\r') column = 0 - else column += 1 - } - return result -} - function codepointCompare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0 } @@ -192,9 +175,9 @@ function formatFileView( : allLines.slice(initialLine - 1, finalLine) prompt += ` with view_range=[${initialLine}, ${finalLine}]` } - const numbered = expandTabs(lines - .map((line, index) => `${String(initialLine + index).padStart(6, ' ')}\t${line}`) - .join('\n')) + const numbered = lines + .map((line, index) => `${String(initialLine + index).padStart(6, ' ')} ${line}`) + .join('\n') return maybeTruncate(`${prompt}:\n${numbered}\n`, maxOutputChars) } diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 90b182a43f..9a948ed164 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -470,11 +470,13 @@ describe('tool-str-replace-editor', () => { const { ctx, root, owner } = await setup() const path = join(root, 'Makefile') await writeFile(path, 'target:\n\told\nremove\n') + expect(text(await call(ctx, owner, { command: 'view', path }))) + .toContain(' 2 \told') await call(ctx, owner, { command: 'str_replace', path, - old_str: 'old', - new_str: 'new', + old_str: '\told', + new_str: '\tnew', }) await call(ctx, owner, { command: 'str_replace', From 93ae5ea8676acbdb831891d2e343cf5cf176c26e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:08:17 +0800 Subject: [PATCH 24/46] fix(python): name unsupported release executables --- python/sdk/tests/test_release_version.py | 21 +++++++++++++++++++++ scripts/build-python-release.py | 14 ++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index fad1d01cda..ce54185ba5 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -85,6 +85,27 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: ) +def test_stage_runtime_rejects_unsupported_executable_name(tmp_path: Path) -> None: + executable = tmp_path / "custom-runtime" + executable.write_bytes(b"runtime") + executable.chmod(0o755) + + with pytest.raises( + ValueError, + match=( + "unsupported runtime executable 'custom-runtime'; expected one of: " + "dsh-jsonrpc-agent-pkg-linux-arm64, dsh-jsonrpc-agent-pkg-linux-x64, " + "dsh-jsonrpc-agent-pkg-macos-arm64" + ), + ): + build_python_release.stage_runtime( + tmp_path / "staging", + "1.2.3", + executable, + executable.name, + ) + + @pytest.mark.parametrize("target", ["linux-x64", "linux-arm64"]) def test_stage_runtime_copies_linux_executable_without_spawn_helper( tmp_path: Path, target: str diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 6bbe8bf512..dc53853b39 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -26,6 +26,16 @@ SPAWN_HELPER_SUFFIX = "-spawn-helper" EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()} +def executable_target(executable_name: str) -> str: + try: + return EXECUTABLE_TARGETS[executable_name] + except KeyError as error: + supported = ", ".join(sorted(EXECUTABLE_TARGETS)) + raise ValueError( + f"unsupported runtime executable {executable_name!r}; expected one of: {supported}" + ) from error + + def spawn_helper_binary_target(header: bytes) -> str | None: if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": cpu_type = int.from_bytes(header[4:8], "little") @@ -158,7 +168,7 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ raise FileNotFoundError(f"runtime executable does not exist: {executable}") if executable.stat().st_mode & stat.S_IXUSR == 0: raise PermissionError(f"runtime executable is not executable: {executable}") - expected_target = EXECUTABLE_TARGETS[executable_name] + expected_target = executable_target(executable_name) spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}") if expected_target.startswith("macos-"): if not spawn_helper.is_file(): @@ -204,7 +214,7 @@ def verify_wheel( assert platform is not None if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"): raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}") - expected_target = EXECUTABLE_TARGETS[platform[1]] + expected_target = executable_target(platform[1]) expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}" expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] found_helpers = [Path(helper).name for helper in helpers] From 9b9d3838baab000a865f420ccf3798395c3739e2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:10:32 +0800 Subject: [PATCH 25/46] docs(example): update persistent-tools snapshot command --- examples/jsonrpc-agent/README.i18n.yaml | 4 ++-- examples/jsonrpc-agent/README.md | 4 ++-- examples/jsonrpc-agent/README.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index 1ff308b60f..bc0cd4facd 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -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 examples/jsonrpc-agent/README.md -README.md: 9a4c715e8988f52b647dbbd6b14a478cc1357d92 -README.zh.md: fa792f9cf6bdad4f32a980f478a131f19de97611 +README.md: 5e7b7d79415a4af0b0d16c61b0a4590dce57e545 +README.zh.md: 8f2cca582853d420a542606c8a0f3e534e9c9bec diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index 9a4c715e89..5e7b7d7941 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -33,8 +33,8 @@ Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CON - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the real local PTY, filesystem intent policy, and session sandbox policy. The keyless behavior snapshot drives the shipped JSON-RPC runtime through both tools and proves that shell cwd/environment survive across calls: +It composes the real local PTY, filesystem intent policy, and session sandbox policy. The keyless SDK snapshot drives the shipped JSON-RPC runtime through both tools, proves that shell cwd/environment survive across calls, and pins the notification stream, turn result, and persisted JSONL: ```bash -pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +pnpm exec vitest run --config vitest.snapshot.config.ts -t persistent-tools ``` diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index fa792f9cf6..8f2cca5828 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -33,8 +33,8 @@ - agent 独占、状态持久的 `bash` - 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -它组合真实本地 PTY、文件系统 intent 策略与 session 沙箱策略。无密钥行为快照会通过正式 JSON-RPC runtime 驱动这两个工具,并验证 shell 的 cwd 与环境变量能跨调用保留: +它组合真实本地 PTY、文件系统 intent 策略与 session 沙箱策略。无密钥 SDK 快照会通过正式 JSON-RPC runtime 驱动这两个工具,验证 shell 的 cwd 与环境变量能跨调用保留,并锁定通知流、轮次结果与已持久化的 JSONL: ```bash -pnpm exec vitest run examples/jsonrpc-agent/tests/persistent-tools.snapshot.spec.ts +pnpm exec vitest run --config vitest.snapshot.config.ts -t persistent-tools ``` From 9549c73f30860b0a74553d63171ec98602f2329a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:15:39 +0800 Subject: [PATCH 26/46] fix(persistent-bash): distinguish shell exit status --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +-- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- examples/jsonrpc-agent/tests/sdk.snapshot.ts | 2 +- .../notifications.expected.jsonl | 26 +++++++++++++------ .../snapshots/persistent-tools/session.jsonl | 26 +++++++++++++------ .../pty/tool-bash-persistent/README.i18n.yaml | 4 +-- packages/pty/tool-bash-persistent/README.md | 6 ++--- .../pty/tool-bash-persistent/README.zh.md | 6 ++--- .../pty/tool-bash-persistent/src/index.ts | 25 +++++++++++------- .../tool-bash-persistent/tests/tools.spec.ts | 21 ++++++++++++--- 11 files changed, 82 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 59f972d856..e50f6eacd3 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 006031248fbe74d72cbecb3dad88deb9f035e023 -2026-07-29-persistent-bash-str-replace-editor.zh.md: f134c3499648ef6c544bc251084136984c384bb2 +2026-07-29-persistent-bash-str-replace-editor.md: 2babbe75b791254722b5a26f1cbf09fc33acab50 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 3e40a610dacc2ba4fb87c01df5e377df0ba6b0bf diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 006031248f..2babbe75b7 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -10,7 +10,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m ## Decision -`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. +`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. A nonzero wrapped command appends `[exit code: N]`; a shell that dies before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither. `maxOutputChars` bounds retained command output, while fixed diagnostics can extend the returned string. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. `@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute; file views preserve content tabs so copied text remains valid literal replacement input; mutations preserve tabs outside the requested edit; and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index f134c34996..3e40a610da 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -10,7 +10,7 @@ ## 决策 -`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 +`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。经封装的命令以非零状态结束时,会追加 `[exit code: N]`;若 shell 在报告该状态前终止,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`。`maxOutputChars` 限制保留的命令输出,而固定诊断可能使返回字符串更长。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 `@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径;文件查看会保留内容中的制表符,因此复制的文本仍可作为有效的字面量替换输入;变更会保留请求编辑范围之外的制表符;公开 schema 与错误则只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index a750ddc5e4..11c7615c48 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -82,7 +82,7 @@ const SCENARIOS: SdkScenario[] = [ }, { name: 'persistent-tools', - prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line.', + prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9.', sessionId: 'persistent-tools-snapshot', children: 0, configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl index b1494b95ca..e69b5d95ee 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl @@ -1,5 +1,5 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Prove that bash state persists.","messageSeqs":[1],"source":{"kind":"fallback"}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} @@ -53,12 +53,22 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":52,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[51],"surfaceOp":"append"}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":53,"time":0,"data":{"turn":1,"step":5}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":54,"time":0,"data":{"turn":1,"step":6}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-exit","name":"bash","argumentsDelta":"{\"command\":\"exit 9\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":61,"time":0,"data":{"turn":1,"step":6}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":62,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":61,"time":0,"data":{"turn":1,"step":6,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":62,"time":0,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"bash-exit"},"content":[{"type":"tool-result","toolCallId":"bash-exit","content":[{"type":"text","text":"exit\n[shell exited: code 9]\nThe persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[61],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":64,"time":0,"data":{"turn":1,"step":7}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":70,"time":0,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":71,"time":0,"data":{"turn":1,"step":7}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":72,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl index b4094876ea..8a288888d5 100644 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"persistent-tools-snapshot","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785331618311,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, and replace that literal tab-indented line."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785331618311,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"d0534fe8-a74b-4fcf-913f-d78e36f486bb"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785331618312,"data":{"title":"Prove that bash state persists.","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785331618312,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785331618313,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} @@ -54,11 +54,21 @@ {"type":"tool/result","seq":52,"time":1785331618803,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"ee874ae7-c4d9-4075-9b40-45e643a4b159"}},"sourceEventSeqs":[51],"surfaceOp":"append"} {"type":"step/end","seq":53,"time":1785331618803,"data":{"turn":1,"step":5}} {"type":"step/start","seq":54,"time":1785331618803,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":55,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":56,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} -{"type":"assistant/chunk","seq":57,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":55,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":56,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-exit","name":"bash","argumentsDelta":"{\"command\":\"exit 9\"}"}}} +{"type":"assistant/chunk","seq":57,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}} {"type":"assistant/chunk","seq":58,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":59,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":60,"time":1785331618805,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"8e39f4fe-5538-46be-b24a-84296d638c44"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} -{"type":"step/end","seq":61,"time":1785331618805,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":62,"time":1785331618805,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":59,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":60,"time":1785331618805,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"8e39f4fe-5538-46be-b24a-84296d638c44"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"tool/call","seq":61,"time":1785331618805,"data":{"turn":1,"step":6,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}} +{"type":"tool/result","seq":62,"time":1785331618806,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"bash-exit"},"content":[{"type":"tool-result","toolCallId":"bash-exit","content":[{"type":"text","text":"exit\n[shell exited: code 9]\nThe persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment."}],"isError":false}],"role":"user","id":"cb4bf07d-474f-46de-a945-94666c849a5f"}},"sourceEventSeqs":[61],"surfaceOp":"append"} +{"type":"step/end","seq":63,"time":1785331618806,"data":{"turn":1,"step":6}} +{"type":"step/start","seq":64,"time":1785331618806,"data":{"turn":1,"step":7}} +{"type":"assistant/chunk","seq":65,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":66,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} +{"type":"assistant/chunk","seq":67,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} +{"type":"assistant/chunk","seq":68,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":69,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":70,"time":1785331618808,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"42e7f4c0-f936-4616-8af3-4f486f27fbb5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"} +{"type":"step/end","seq":71,"time":1785331618808,"data":{"turn":1,"step":7}} +{"type":"turn/end","seq":72,"time":1785331618808,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/pty/tool-bash-persistent/README.i18n.yaml b/packages/pty/tool-bash-persistent/README.i18n.yaml index 2f15d109c1..16c3e69523 100644 --- a/packages/pty/tool-bash-persistent/README.i18n.yaml +++ b/packages/pty/tool-bash-persistent/README.i18n.yaml @@ -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 packages/pty/tool-bash-persistent/README.md -README.md: 04c714d5489dbae8572e9339a4387a148450a0e9 -README.zh.md: adfb38b10409174d9558b963f5a2359cf819f04b +README.md: 3f5ffbe50bed4a6ab8f7f11ebad47bf933bba1e8 +README.zh.md: 94882534b3a2f8aedf382db0376a19540e2ea0d1 diff --git a/packages/pty/tool-bash-persistent/README.md b/packages/pty/tool-bash-persistent/README.md index 04c714d548..3f5ffbe50b 100644 --- a/packages/pty/tool-bash-persistent/README.md +++ b/packages/pty/tool-bash-persistent/README.md @@ -10,7 +10,7 @@ Model-facing `bash(command)` backed by one owner-scoped `ctx.pty` shell. The pac |---|---:|---| | `backendType` | `shell` | Registered PTY backend used for each Agent shell. | | `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | -| `maxOutputChars` | `16000` | Prefix characters retained before the clipping notice. | +| `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. | | `description` | Persistent-shell description | Model-facing environment contract. | ## Model Experience @@ -33,11 +33,11 @@ Prefix-stable while the configured description and schema remain unchanged. #### What the model sees -Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and tells the model that the next call starts fresh. +Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. A nonzero wrapped command appends `[exit code: N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. #### Token effect -Data-dependent and bounded by `maxOutputChars` plus the fixed clipping notice. +Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result. #### KV Cache effect diff --git a/packages/pty/tool-bash-persistent/README.zh.md b/packages/pty/tool-bash-persistent/README.zh.md index adfb38b104..94882534b3 100644 --- a/packages/pty/tool-bash-persistent/README.zh.md +++ b/packages/pty/tool-bash-persistent/README.zh.md @@ -10,7 +10,7 @@ |---|---:|---| | `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY 后端。 | | `timeoutMs` | `300000` | 单条命令的墙钟时间上限;超时会关闭 shell。 | -| `maxOutputChars` | `16000` | 截断提示前保留的前缀字符数。 | +| `maxOutputChars` | `16000` | 命令输出最多保留的字符数;固定诊断会在此后追加。 | | `description` | 持久 shell 描述 | 面向模型的环境契约。 | ## 模型体验 @@ -33,11 +33,11 @@ #### 模型所见 -每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并告知模型下次调用从新 shell 开始。 +每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。经封装的命令以非零状态结束时,结果会追加 `[exit code: N]`;若 shell 在报告该状态前退出,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`;随后重置 shell,并告知模型下次调用从新 shell 开始。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并报告该重置。 #### Token 影响 -随数据变化,并受 `maxOutputChars` 与固定截断提示约束。 +随数据变化。`maxOutputChars` 限制保留的命令输出;固定的截断、前缀丢失、状态、超时与重置诊断可能使结果更长。 #### KV Cache 影响 diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 90cf1b4774..f2a5e5d0d3 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -174,21 +174,28 @@ function renderCaptured(output: CapturedOutput, maxOutputChars: number): string const withPrefix = output.incomplete && output.text.length > 0 ? LOST_PREFIX_MESSAGE + rendered : rendered - return renderExitStatus(withPrefix, output.exitCode ?? 0, null) + const marker = output.exitCode !== undefined && output.exitCode !== 0 + ? `[exit code: ${output.exitCode}]` + : undefined + return appendStatusMarker(withPrefix, marker) } -function renderExitStatus( +function appendStatusMarker(content: string, marker: string | undefined): string { + if (marker === undefined) return content + return content.length === 0 ? marker : `${content}\n${marker}` +} + +function renderShellExitStatus( content: string, exitCode: number | null, signal: NodeJS.Signals | null, ): string { const marker = signal !== null - ? `[killed by signal: ${signal}]` - : exitCode !== null && exitCode !== 0 - ? `[exit code: ${exitCode}]` - : undefined - if (marker === undefined) return content - return content.length === 0 ? marker : `${content}\n${marker}` + ? `[shell killed by signal: ${signal}]` + : exitCode !== null + ? `[shell exited: code ${exitCode}]` + : '[shell exited]' + return appendStatusMarker(content, marker) } function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { @@ -325,7 +332,7 @@ async function executeCommand( const snapshot = retainedScrollback(ctx, owner, id, latest) await shells.reset(owner, 'persistent bash shell exited') return [ - renderExitStatus( + renderShellExitStatus( renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), result.sessionStatus.exitCode, result.sessionStatus.signal, diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index f3bd7bf40b..4b5401a256 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -79,6 +79,7 @@ type StubMode = | 'stalled-read' | 'exit' | 'signal-exit' + | 'unknown-exit' | 'wait-for-abort' | 'end-on-abort' | 'idle-then-normal' @@ -184,12 +185,14 @@ class StubPtySession implements PtyBackendSession { const exitCode = this.mode === 'nonzero' ? 7 : 0 const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` this.scrollback += output - if (this.mode === 'exit' || this.mode === 'signal-exit') { + if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') { const exitedOutput = `${start ?? ''}\nhello from stub\n` this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput this.statusValue = this.mode === 'signal-exit' ? { kind: 'exited', exitCode: null, signal: 'SIGTERM' } - : { kind: 'exited', exitCode: 9, signal: null } + : this.mode === 'exit' + ? { kind: 'exited', exitCode: 9, signal: null } + : { kind: 'exited', exitCode: null, signal: null } return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) } return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) @@ -339,7 +342,8 @@ describe('tool-bash-persistent', () => { session.mode = 'exit' const exited = text(await call(ctx, owner, 'exit')) expect(exited).toContain('hello from') - expect(exited).toContain('[exit code: 9]') + expect(exited).toContain('[shell exited: code 9]') + expect(exited).not.toContain('[exit code: 9]') expect(exited).toContain('next bash call starts from the workspace') expect(session.closed).toContain('persistent bash shell exited') @@ -347,7 +351,8 @@ describe('tool-bash-persistent', () => { expect(stub.sessions).toHaveLength(2) const replacement = stub.sessions[1]! replacement.mode = 'signal-exit' - expect(text(await call(ctx, owner, 'kill shell'))).toContain('[killed by signal: SIGTERM]') + expect(text(await call(ctx, owner, 'kill shell'))) + .toContain('[shell killed by signal: SIGTERM]') await call(ctx, owner, 'another shell') expect(stub.sessions).toHaveLength(3) @@ -367,6 +372,14 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') }) + it('reports a shell exit when the backend has no code or signal', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'unknown-exit' + + expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]') + }) + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up') From a3caf4b2117225262c793b19bf12d470769d2ef3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:16:26 +0800 Subject: [PATCH 27/46] cleanup(persistent-bash): drop impossible marker branch --- packages/pty/tool-bash-persistent/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index f2a5e5d0d3..24cc998cf2 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -96,7 +96,6 @@ function commandOutput( ): CapturedOutput | undefined { const text = snapshot.text const end = text.lastIndexOf(marker.end) - if (end < 0) return undefined const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1] if (status === undefined) return undefined const startMarker = text.lastIndexOf(marker.start, end) From 92052c1220390b6f78fa19ae809a9c7595d5c7b9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:18:55 +0800 Subject: [PATCH 28/46] docs(persistent-bash): define cancellation reset --- .../2026-07-29-persistent-bash-str-replace-editor.i18n.yaml | 4 ++-- .../feature/2026-07-29-persistent-bash-str-replace-editor.md | 2 +- .../2026-07-29-persistent-bash-str-replace-editor.zh.md | 2 +- packages/pty/tool-bash-persistent/README.i18n.yaml | 4 ++-- packages/pty/tool-bash-persistent/README.md | 2 +- packages/pty/tool-bash-persistent/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index e50f6eacd3..70909fe8f4 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: 2babbe75b791254722b5a26f1cbf09fc33acab50 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 3e40a610dacc2ba4fb87c01df5e377df0ba6b0bf +2026-07-29-persistent-bash-str-replace-editor.md: ed9e772259b12f8d94abddd56cb36140099c1f9e +2026-07-29-persistent-bash-str-replace-editor.zh.md: 4a0348ef0ec9120e37799b69a39d49c4f8adce57 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index 2babbe75b7..ed9e772259 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -10,7 +10,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m ## Decision -`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. A nonzero wrapped command appends `[exit code: N]`; a shell that dies before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither. `maxOutputChars` bounds retained command output, while fixed diagnostics can extend the returned string. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. +`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.pty` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. A nonzero wrapped command appends `[exit code: N]`; a shell that dies before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither. `maxOutputChars` bounds retained command output, while fixed diagnostics can extend the returned string. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. Cancellation always resets and discards the result, even when a complete status marker is already observable, so state changes the model never saw cannot survive. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned. `@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute; file views preserve content tabs so copied text remains valid literal replacement input; mutations preserve tabs outside the requested edit; and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 3e40a610da..4a0348ef0e 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -10,7 +10,7 @@ ## 决策 -`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。经封装的命令以非零状态结束时,会追加 `[exit code: N]`;若 shell 在报告该状态前终止,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`。`maxOutputChars` 限制保留的命令输出,而固定诊断可能使返回字符串更长。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 +`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.pty` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。经封装的命令以非零状态结束时,会追加 `[exit code: N]`;若 shell 在报告该状态前终止,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`。`maxOutputChars` 限制保留的命令输出,而固定诊断可能使返回字符串更长。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。取消始终会重置 shell 并丢弃结果,即使已经能观察到完整状态标记也是如此,从而不会让模型未曾看到的状态变更得以保留。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。 `@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径;文件查看会保留内容中的制表符,因此复制的文本仍可作为有效的字面量替换输入;变更会保留请求编辑范围之外的制表符;公开 schema 与错误则只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。 diff --git a/packages/pty/tool-bash-persistent/README.i18n.yaml b/packages/pty/tool-bash-persistent/README.i18n.yaml index 16c3e69523..aec30c2b4a 100644 --- a/packages/pty/tool-bash-persistent/README.i18n.yaml +++ b/packages/pty/tool-bash-persistent/README.i18n.yaml @@ -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 packages/pty/tool-bash-persistent/README.md -README.md: 3f5ffbe50bed4a6ab8f7f11ebad47bf933bba1e8 -README.zh.md: 94882534b3a2f8aedf382db0376a19540e2ea0d1 +README.md: cfb3acf41803f5b06ecb3f56ee8ffc29ce9147b1 +README.zh.md: a525485933344a8f9bc218456c099efdef572902 diff --git a/packages/pty/tool-bash-persistent/README.md b/packages/pty/tool-bash-persistent/README.md index 3f5ffbe50b..cfb3acf418 100644 --- a/packages/pty/tool-bash-persistent/README.md +++ b/packages/pty/tool-bash-persistent/README.md @@ -46,5 +46,5 @@ Append-only tool results follow the reusable request prefix. ## Known Limitations and Deferred Work - The tool requires an owning Agent and a real PTY backend. -- Explicit `exit`, timeout, or cancellation discards shell state; the next call starts a fresh shell. +- Explicit `exit` and timeout discard shell state. Cancellation also resets and discards the result, even when a complete status marker is already observable; the next call starts a fresh shell. - Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default. diff --git a/packages/pty/tool-bash-persistent/README.zh.md b/packages/pty/tool-bash-persistent/README.zh.md index 94882534b3..a525485933 100644 --- a/packages/pty/tool-bash-persistent/README.zh.md +++ b/packages/pty/tool-bash-persistent/README.zh.md @@ -46,5 +46,5 @@ ## 已知限制与延后工作 - 工具需要拥有它的 Agent 和真实 PTY 后端。 -- 显式 `exit`、超时或取消会丢弃 shell 状态;下次调用创建新 shell。 +- 显式 `exit` 与超时会丢弃 shell 状态。取消同样会重置 shell 并丢弃结果,即使已经能观察到完整状态标记也是如此;下次调用创建新 shell。 - 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。 From 91a104acef134ff944f40bfaee82baf41c9363dd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:20:44 +0800 Subject: [PATCH 29/46] docs(pty): explain spawn-helper override precedence --- .../2026-07-29-persistent-bash-str-replace-editor.i18n.yaml | 4 ++-- .../feature/2026-07-29-persistent-bash-str-replace-editor.md | 2 +- .../2026-07-29-persistent-bash-str-replace-editor.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 70909fe8f4..93776643da 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: ed9e772259b12f8d94abddd56cb36140099c1f9e -2026-07-29-persistent-bash-str-replace-editor.zh.md: 4a0348ef0ec9120e37799b69a39d49c4f8adce57 +2026-07-29-persistent-bash-str-replace-editor.md: aee566f2adee94cea88b2ab802d95e405ee8b26c +2026-07-29-persistent-bash-str-replace-editor.zh.md: 86a4b11ff18ec8c4ef276565f242f60270c4bfb0 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index ed9e772259..aee566f2ad 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. -Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with an architecture-matched `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch resolves the sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The macOS executable and runtime-wheel builders inspect the thin Mach-O header and fail before publication when the helper is absent, mismatched, or not executable. +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with an architecture-matched `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS executable and runtime-wheel builders inspect the thin Mach-O header and fail before publication when the helper is absent, mismatched, or not executable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 4a0348ef0e..86a4b11ff1 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -16,7 +16,7 @@ `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 -两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。macOS 可执行文件与运行时 wheel 包的构建器会检查 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。macOS 可执行文件与运行时 wheel 包的构建器会检查 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 ## 考虑过的替代方案 From 758dc73ed99d2611072202346f3f2c2db5ddcf2c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:24:59 +0800 Subject: [PATCH 30/46] docs: refresh generated config anchors --- docs/config-catalog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index fcb2bdd2bd..4aca3810c8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1593,7 +1593,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:397`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:405`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -1767,7 +1767,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-str-replace-editor/src/index.ts:514`](../packages/fs/tool-str-replace-editor/src/index.ts) +Source: [`packages/fs/tool-str-replace-editor/src/index.ts:496`](../packages/fs/tool-str-replace-editor/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` From 0e53b7a8aabc2565238a76c52570a7399739bc98 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:07:47 +0800 Subject: [PATCH 31/46] cleanup(build): drop helper architecture parsing --- ...rsistent-bash-str-replace-editor.i18n.yaml | 4 +- ...7-29-persistent-bash-str-replace-editor.md | 2 +- ...9-persistent-bash-str-replace-editor.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 23 --------- python/sdk/tests/test_release_version.py | 51 +------------------ scripts/build-exe-for-python-sdk.ts | 24 +-------- scripts/build-python-release.py | 28 ---------- 10 files changed, 12 insertions(+), 130 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 93776643da..a16f07a63c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: aee566f2adee94cea88b2ab802d95e405ee8b26c -2026-07-29-persistent-bash-str-replace-editor.zh.md: 86a4b11ff18ec8c4ef276565f242f60270c4bfb0 +2026-07-29-persistent-bash-str-replace-editor.md: a97af750bdd80ddf38dc2d126e70c245ed035f35 +2026-07-29-persistent-bash-str-replace-editor.zh.md: 0c2ab26693d90c91d5c41a128ebb77a3c6cc2e7f diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index aee566f2ad..a97af750bd 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. -Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with an architecture-matched `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS executable and runtime-wheel builders inspect the thin Mach-O header and fail before publication when the helper is absent, mismatched, or not executable. +Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 86a4b11ff1..0c2ab26693 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -16,7 +16,7 @@ `dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。 -两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。macOS 可执行文件与运行时 wheel 包的构建器会检查 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。 +两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 ## 考虑过的替代方案 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 129dac85d5..f39bfa8f13 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -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 python/sdk-runtime/README.md -README.md: efdb5cf9f87e0831ef09a47e6ffdb99254a17f36 -README.zh.md: cafac7418608c416a9f291d62442c32b8787b1fc +README.md: 07bb3c574b3cd49f1dc74f0e9d9bd1bb7ca9b216 +README.zh.md: 9eb03352505f7cad3e6bf6f253ed6564fdb06ca0 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index efdb5cf9f8..07bb3c574b 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there, and its thin Mach-O header must match the target. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index cafac74186..9eb0335250 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件,其 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 9b54e0c5ed..cf56dd4138 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -16,26 +16,6 @@ _PLATFORMS = { _SPAWN_HELPER_SUFFIX = "-spawn-helper" -def _spawn_helper_binary_target(header: bytes) -> str | None: - if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - cpu_type = int.from_bytes(header[4:8], "little") - if cpu_type == 0x01000007: - return "macos-x64" - if cpu_type == 0x0100000C: - return "macos-arm64" - return None - - -def _validate_spawn_helper(path: Path, expected_target: str) -> None: - with path.open("rb") as helper: - actual_target = _spawn_helper_binary_target(helper.read(8)) - if actual_target != expected_target: - raise RuntimeError( - f"runtime spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'} at {path}" - ) - - def _host_platform_tag() -> str: machine = platform.machine().lower() arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine @@ -86,9 +66,6 @@ class RuntimeBuildHook(BuildHookInterface): for executable in [executables[0], *helpers]: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") - if helpers: - _validate_spawn_helper(helpers[0], expected_target) - build_data["pure_python"] = False build_data["infer_tag"] = False build_data["tag"] = f"py3-none-{platform_tag}" diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index ce54185ba5..b8fa5484c2 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -16,14 +16,6 @@ SCRIPT = ROOT / "scripts" / "build-python-release.py" build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) -def helper_header(target: str) -> bytes: - header = bytearray(8) - header[:4] = b"\xcf\xfa\xed\xfe" - cpu_type = 0x01000007 if target == "macos-x64" else 0x0100000C - header[4:8] = cpu_type.to_bytes(4, "little") - return bytes(header) - - def test_repository_version_matches_root_package_json() -> None: expected = json.loads((ROOT / "package.json").read_text())["version"] @@ -53,7 +45,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non executable.write_bytes(b"runtime") executable.chmod(0o755) spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(helper_header("macos-arm64")) + spawn_helper.write_bytes(b"helper") spawn_helper.chmod(0o751) destination = tmp_path / "staging" @@ -67,7 +59,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" assert (runtime_dir / executable.name).read_bytes() == b"runtime" copied_helper = runtime_dir / spawn_helper.name - assert copied_helper.read_bytes() == helper_header("macos-arm64") + assert copied_helper.read_bytes() == b"helper" assert copied_helper.stat().st_mode & stat.S_IXUSR @@ -120,42 +112,3 @@ def test_stage_runtime_copies_linux_executable_without_spawn_helper( runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = [path.name for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")] assert runtime_files == [executable.name] - - -@pytest.mark.parametrize("target", ["macos-x64", "macos-arm64"]) -def test_spawn_helper_binary_target(target: str) -> None: - assert build_python_release.spawn_helper_binary_target(helper_header(target)) == target - - -def test_stage_runtime_rejects_mismatched_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(helper_header("macos-x64")) - spawn_helper.chmod(0o755) - - with pytest.raises(ValueError, match="expected macos-arm64, found macos-x64"): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) - - -def test_stage_runtime_rejects_non_binary_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(b"helper") - spawn_helper.chmod(0o755) - - with pytest.raises(ValueError, match="unsupported format or architecture"): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index b4b4d23720..e9ae59edbc 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -7,7 +7,7 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' +import { existsSync, mkdirSync, statSync } from 'node:fs' import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -58,16 +58,6 @@ interface RuntimeProduct { spawnHelper?: string } -function spawnHelperBinaryTarget(path: string): string | undefined { - const header = readFileSync(path).subarray(0, 8) - if (header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf) { - const cpuType = header.readUInt32LE(4) - if (cpuType === 0x01000007) return 'macos-x64' - if (cpuType === 0x0100000c) return 'macos-arm64' - } - return undefined -} - function runtimeProductFiles(product: RuntimeProduct): string[] { return [product.executable, ...(product.spawnHelper === undefined ? [] : [product.spawnHelper])] } @@ -393,17 +383,7 @@ class SingleExeBuild { + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, ) } - if (statSync(helper).mode & 0o111) { - const expected = `${target.platform}-${target.arch}` - const actual = spawnHelperBinaryTarget(helper) - if (actual !== expected) { - throw new Error( - `build-exe-for-python-sdk: node-pty spawn-helper binary mismatch: expected ${expected}, ` - + `found ${actual ?? 'unsupported format or architecture'} at ${helper}`, - ) - } - return helper - } + if (statSync(helper).mode & 0o111) return helper throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index dc53853b39..be5125cbcd 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -36,26 +36,6 @@ def executable_target(executable_name: str) -> str: ) from error -def spawn_helper_binary_target(header: bytes) -> str | None: - if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe": - cpu_type = int.from_bytes(header[4:8], "little") - if cpu_type == 0x01000007: - return "macos-x64" - if cpu_type == 0x0100000C: - return "macos-arm64" - return None - - -def validate_spawn_helper(path: Path, expected_target: str) -> None: - with path.open("rb") as helper: - actual_target = spawn_helper_binary_target(helper.read(8)) - if actual_target != expected_target: - raise ValueError( - f"runtime spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'} at {path}" - ) - - def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--package", choices=("sdk", "runtime"), required=True) @@ -175,7 +155,6 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") - validate_spawn_helper(spawn_helper, expected_target) copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" @@ -228,13 +207,6 @@ def verify_wheel( mode = archive.getinfo(executable).external_attr >> 16 if mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") - if helpers: - actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:8]) - if actual_target != expected_target: - raise RuntimeError( - f"{wheel} spawn helper binary mismatch: expected {expected_target}, " - f"found {actual_target or 'unsupported format or architecture'}" - ) elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 7118b4cfe136d3a92427ddb47315de332cae3456 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:09:54 +0800 Subject: [PATCH 32/46] cleanup(build): collapse native runtime payloads --- python/sdk-runtime/hatch_build.py | 25 +++----- python/sdk/tests/test_release_version.py | 30 +--------- scripts/build-exe-for-python-sdk.ts | 75 ++++++++---------------- scripts/build-python-release.py | 69 ++++++++-------------- 4 files changed, 60 insertions(+), 139 deletions(-) diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index cf56dd4138..19ec962257 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -39,31 +39,24 @@ class RuntimeBuildHook(BuildHookInterface): ) platform_tag = os.environ.get("DSH_RUNTIME_PLATFORM_TAG") or _host_platform_tag() - matches = [(key, value) for key, value in _PLATFORMS.items() if value[0] == platform_tag] + matches = [value for value in _PLATFORMS.values() if value[0] == platform_tag] if len(matches) != 1: supported = ", ".join(value[0] for value in _PLATFORMS.values()) raise RuntimeError( f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}" ) - expected_target, (_, expected_executable) = matches[0] + expected_executable = matches[0][1] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) - executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)] - helpers = [path for path in runtime_files if path.name.endswith(_SPAWN_HELPER_SUFFIX)] - if [path.name for path in executables] != [expected_executable]: - found = ", ".join(path.name for path in executables) or "none" + expected_files = [expected_executable] + if "-macos-" in expected_executable: + expected_files.append(f"{expected_executable}{_SPAWN_HELPER_SUFFIX}") + found_files = [path.name for path in runtime_files] + if found_files != expected_files: raise RuntimeError( - f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}" + f"runtime wheel {platform_tag} payload must be {expected_files}; found {found_files}" ) - expected_helper = f"{expected_executable}{_SPAWN_HELPER_SUFFIX}" - expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] - if [path.name for path in helpers] != expected_helpers: - expected = ", ".join(expected_helpers) or "none" - found = ", ".join(path.name for path in helpers) or "none" - raise RuntimeError( - f"runtime wheel {platform_tag} helper payload mismatch: expected {expected}; found {found}" - ) - for executable in [executables[0], *helpers]: + for executable in runtime_files: if executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") build_data["pure_python"] = False diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index b8fa5484c2..68a9aac993 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -68,7 +68,7 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: executable.write_bytes(b"runtime") executable.chmod(0o755) - with pytest.raises(FileNotFoundError, match="spawn helper"): + with pytest.raises(FileNotFoundError, match="spawn-helper"): build_python_release.stage_runtime( tmp_path / "staging", "1.2.3", @@ -77,32 +77,8 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: ) -def test_stage_runtime_rejects_unsupported_executable_name(tmp_path: Path) -> None: - executable = tmp_path / "custom-runtime" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - - with pytest.raises( - ValueError, - match=( - "unsupported runtime executable 'custom-runtime'; expected one of: " - "dsh-jsonrpc-agent-pkg-linux-arm64, dsh-jsonrpc-agent-pkg-linux-x64, " - "dsh-jsonrpc-agent-pkg-macos-arm64" - ), - ): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) - - -@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64"]) -def test_stage_runtime_copies_linux_executable_without_spawn_helper( - tmp_path: Path, target: str -) -> None: - executable = tmp_path / f"dsh-jsonrpc-agent-pkg-{target}" +def test_stage_runtime_copies_linux_executable_without_spawn_helper(tmp_path: Path) -> None: + executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" executable.write_bytes(b"runtime") executable.chmod(0o755) destination = tmp_path / "staging" diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index e9ae59edbc..bbfa2402d0 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -53,15 +53,6 @@ const ARCHES = ['x64', 'arm64'] as const type Platform = (typeof PLATFORMS)[number] type Arch = (typeof ARCHES)[number] -interface RuntimeProduct { - executable: string - spawnHelper?: string -} - -function runtimeProductFiles(product: RuntimeProduct): string[] { - return [product.executable, ...(product.spawnHelper === undefined ? [] : [product.spawnHelper])] -} - function isPlatform(value: string): value is Platform { return (PLATFORMS as readonly string[]).includes(value) } @@ -299,9 +290,9 @@ class SingleExeBuild { /** * Package one target; SEA mode accepts one target per invocation. * @param target - the pkg target triple to build. - * @returns the canonical product path `/dsh-jsonrpc-agent-pkg--`. + * @returns the executable path and, on macOS, its helper path. */ - async pack(target: Target): Promise { + async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) await this.prepareNativePty(target) if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) @@ -318,7 +309,7 @@ class SingleExeBuild { if (!this.cli.dryRun && !existsSync(product)) { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } - if (target.platform !== 'macos') return { executable: product } + if (target.platform !== 'macos') return [product] const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) @@ -327,7 +318,7 @@ class SingleExeBuild { await copyFile(source, spawnHelper) await chmod(spawnHelper, statSync(source).mode & 0o777) } - return { executable: product, spawnHelper } + return [product, spawnHelper] } /** @@ -341,21 +332,19 @@ class SingleExeBuild { if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) else await rm(stagedBuild, { recursive: true, force: true }) - const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux' - const prebuilt = join(stagedRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'pty.node') const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node') const destination = join(stagedBuild, 'Release', 'pty.node') if (this.cli.dryRun) { if (target.platform === 'linux') console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) return } - if (existsSync(prebuilt)) return + if (target.platform === 'macos') return const host = Target.host() if (target.platform !== host.platform || target.arch !== host.arch || !existsSync(source)) { throw new Error( `build-exe-for-python-sdk: node-pty native addon for ${target.platform}-${target.arch} is missing; ` - + `checked ${prebuilt}, ${source}. Build the Linux runtime on its target architecture.`, + + `checked ${source}. Build the Linux runtime on its target architecture.`, ) } await mkdir(dirname(destination), { recursive: true }) @@ -368,19 +357,11 @@ class SingleExeBuild { * @returns a physical executable outside pkg's virtual snapshot. */ private resolveSpawnHelper(target: Target): string { - const nodePtyRoot = join(this.staging, 'node_modules', 'node-pty') - const candidates = [ - join(nodePtyRoot, 'prebuilds', `darwin-${target.arch}`, 'spawn-helper'), - ] - const host = Target.host() - if (target.platform === host.platform && target.arch === host.arch) { - candidates.push(join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'spawn-helper')) - } - const helper = candidates.find(candidate => existsSync(candidate)) - if (helper === undefined) { + const helper = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') + if (!existsSync(helper)) { throw new Error( `build-exe-for-python-sdk: node-pty spawn-helper for ${target.platform}-${target.arch} is missing; ` - + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, + + `checked ${helper}. Build each runtime on its target platform and architecture.`, ) } if (statSync(helper).mode & 0o111) return helper @@ -391,43 +372,37 @@ class SingleExeBuild { * Print each product path and, outside dry-run mode, its size. * @param products - the product paths returned by {@link pack}. */ - printProducts(products: RuntimeProduct[]): void { + printProducts(products: string[]): void { console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:') - for (const product of products) { + for (const path of products) { if (this.cli.dryRun) { - for (const path of runtimeProductFiles(product)) console.log(` ${path}`) + console.log(` ${path}`) continue } - for (const path of runtimeProductFiles(product)) { - const megabytes = statSync(path).size / (1024 * 1024) - console.log(` ${path} (${megabytes.toFixed(1)} MB)`) - } + const megabytes = statSync(path).size / (1024 * 1024) + console.log(` ${path} (${megabytes.toFixed(1)} MB)`) } } /** - * Copy each executable into the Python runtime package. The deployed node + * Copy each product into the Python runtime package. The deployed node * carrier is already in place, and `dist-exe/` retains upload copies. * @param products - the product paths returned by {@link pack}. */ - async syncToPythonRuntime(products: RuntimeProduct[]): Promise { + async syncToPythonRuntime(products: string[]): Promise { const destDir = resolve(root, PYTHON_RUNTIME_DIR) if (this.cli.dryRun) { - for (const product of products) { - for (const path of runtimeProductFiles(product)) { - console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) - } + for (const path of products) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`) } return } mkdirSync(destDir, { recursive: true }) - for (const product of products) { - for (const path of runtimeProductFiles(product)) { - const destination = join(destDir, basename(path)) - await copyFile(path, destination) - await chmod(destination, statSync(path).mode & 0o777) - console.log(`build-exe-for-python-sdk: synced ${destination}`) - } + for (const path of products) { + const destination = join(destDir, basename(path)) + await copyFile(path, destination) + await chmod(destination, statSync(path).mode & 0o777) + console.log(`build-exe-for-python-sdk: synced ${destination}`) } } @@ -476,8 +451,8 @@ async function main(): Promise { await pipeline.build() await pipeline.deployStaging() await pipeline.injectPkgConfig() - const products: RuntimeProduct[] = [] - for (const target of cli.targets) products.push(await pipeline.pack(target)) + const products: string[] = [] + for (const target of cli.targets) products.push(...await pipeline.pack(target)) pipeline.printProducts(products) await pipeline.syncToPythonRuntime(products) } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index be5125cbcd..4fe7d62980 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -23,17 +23,6 @@ PLATFORMS = { "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } SPAWN_HELPER_SUFFIX = "-spawn-helper" -EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()} - - -def executable_target(executable_name: str) -> str: - try: - return EXECUTABLE_TARGETS[executable_name] - except KeyError as error: - supported = ", ".join(sorted(EXECUTABLE_TARGETS)) - raise ValueError( - f"unsupported runtime executable {executable_name!r}; expected one of: {supported}" - ) from error def main() -> None: @@ -144,28 +133,24 @@ def stage_sdk(destination: Path, version: str) -> None: def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: - if not executable.is_file(): - raise FileNotFoundError(f"runtime executable does not exist: {executable}") - if executable.stat().st_mode & stat.S_IXUSR == 0: - raise PermissionError(f"runtime executable is not executable: {executable}") - expected_target = executable_target(executable_name) - spawn_helper = Path(f"{executable}{SPAWN_HELPER_SUFFIX}") - if expected_target.startswith("macos-"): - if not spawn_helper.is_file(): - raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") - if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: - raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") + payload = [(executable, executable_name)] + if "-macos-" in executable_name: + payload.append( + (Path(f"{executable}{SPAWN_HELPER_SUFFIX}"), f"{executable_name}{SPAWN_HELPER_SUFFIX}") + ) + for source, _ in payload: + if not source.is_file(): + raise FileNotFoundError(f"runtime file does not exist: {source}") + if source.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime file is not executable: {source}") copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir.mkdir(parents=True, exist_ok=True) - destination_executable = runtime_dir / executable_name - shutil.copyfile(executable, destination_executable) - destination_executable.chmod(executable.stat().st_mode & 0o777) - if expected_target.startswith("macos-"): - destination_helper = runtime_dir / f"{executable_name}{SPAWN_HELPER_SUFFIX}" - shutil.copyfile(spawn_helper, destination_helper) - destination_helper.chmod(spawn_helper.stat().st_mode & 0o777) + for source, name in payload: + target = runtime_dir / name + shutil.copyfile(source, target) + target.chmod(source.stat().st_mode & 0o777) def verify_wheel( @@ -187,26 +172,18 @@ def verify_wheel( runtime_files = [ name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name ] - helpers = [name for name in runtime_files if name.endswith(SPAWN_HELPER_SUFFIX)] - executables = [name for name in runtime_files if not name.endswith(SPAWN_HELPER_SUFFIX)] if package == "runtime": assert platform is not None - if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"): - raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}") - expected_target = executable_target(platform[1]) - expected_helper = f"{platform[1]}{SPAWN_HELPER_SUFFIX}" - expected_helpers = [expected_helper] if expected_target.startswith("macos-") else [] - found_helpers = [Path(helper).name for helper in helpers] - if found_helpers != expected_helpers: - expected = ", ".join(expected_helpers) or "none" - found = ", ".join(found_helpers) or "none" - raise RuntimeError( - f"{wheel} runtime helper payload mismatch: expected {expected}; found {found}" - ) - for executable in [executables[0], *helpers]: - mode = archive.getinfo(executable).external_attr >> 16 + expected_files = [platform[1]] + if "-macos-" in platform[1]: + expected_files.append(f"{platform[1]}{SPAWN_HELPER_SUFFIX}") + found_files = sorted(Path(name).name for name in runtime_files) + if found_files != expected_files: + raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") + for runtime_file in runtime_files: + mode = archive.getinfo(runtime_file).external_attr >> 16 if mode & stat.S_IXUSR == 0: - raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") + raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}") elif runtime_files: raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": From 8a9d882a11930a1348f039a5e8f783eecb6616fa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:29:18 +0800 Subject: [PATCH 33/46] cleanup(build): minimize native payload handling --- python/README.i18n.yaml | 4 +- python/README.md | 2 +- python/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 3 +- .../src/deepseek_harness_runtime/__init__.py | 4 +- python/sdk/tests/test_release_version.py | 54 +++++-------------- python/sdk/tests/test_runtime_resolution.py | 25 ++++----- scripts/build-exe-for-python-sdk.ts | 47 +++++----------- scripts/build-python-release.py | 25 +++------ 9 files changed, 47 insertions(+), 119 deletions(-) diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index f0d6c67967..59f3e836cf 100644 --- a/python/README.i18n.yaml +++ b/python/README.i18n.yaml @@ -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 python/README.md -README.md: aee682e25fc33287c49131d0f5b92b136ed16bae -README.zh.md: 4404114fcdab78468991769a4657370f85997a88 +README.md: dfd9d909122f9245a19fe91d8a394156795b13ae +README.zh.md: 151b2cdab4f28384a20d16c86d398f669fbdc818 diff --git a/python/README.md b/python/README.md index aee682e25f..dfd9d90912 100644 --- a/python/README.md +++ b/python/README.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -Products land in `dist-exe/` and are synced into this package as `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` plus the matching `-spawn-helper` required by `node-pty` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the runtime with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same products. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). +Products land in `dist-exe/` and are synced into this package as `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`); macOS builds also sync the matching `-spawn-helper` required by `node-pty`. After a local build the SDK finds the runtime with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same products. A full three-target run retains four release wheels; a subset dispatch retains the SDK wheel and selected platform wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). ## Validating the SDK against the executable diff --git a/python/README.zh.md b/python/README.zh.md index 4404114fcd..151b2cdab4 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -22,7 +22,7 @@ pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifac pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 ``` -产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` 及 `node-pty` 所需的同名 `-spawn-helper` 伴随文件(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到运行时。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的产物。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 +产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`);macOS 构建还会同步 `node-pty` 所需的同名 `-spawn-helper` 伴随文件。本地构建完成后 SDK 不需要额外设置就能找到运行时。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的产物。完整构建三个目标时保留 4 个发布用 wheel 包;手动选择部分目标时保留 SDK wheel 与所选平台的 wheel。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 ## 用可执行文件验证 SDK diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 19ec962257..b0f9a79550 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -13,7 +13,6 @@ _PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } -_SPAWN_HELPER_SUFFIX = "-spawn-helper" def _host_platform_tag() -> str: @@ -50,7 +49,7 @@ class RuntimeBuildHook(BuildHookInterface): runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) expected_files = [expected_executable] if "-macos-" in expected_executable: - expected_files.append(f"{expected_executable}{_SPAWN_HELPER_SUFFIX}") + expected_files.append(f"{expected_executable}-spawn-helper") found_files = [path.name for path in runtime_files] if found_files != expected_files: raise RuntimeError( diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index d6f8c497b6..a3aa53ae80 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -28,7 +28,6 @@ import sys from pathlib import Path PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" -SPAWN_HELPER_SUFFIX = "-spawn-helper" RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" @@ -85,7 +84,7 @@ def bundled_runtime_path() -> Path: + _EXE_ACQUISITION_HINT ) if tag.startswith("macos-"): - helper = Path(f"{path}{SPAWN_HELPER_SUFFIX}") + helper = Path(f"{path}-spawn-helper") if not helper.is_file(): raise FileNotFoundError( f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " @@ -153,7 +152,6 @@ def _node_launch_args() -> tuple[str, str]: __all__ = [ "PACKAGE_METADATA_FILENAME", "RUNTIME_MODE_ENV_VAR", - "SPAWN_HELPER_SUFFIX", "bundled_default_config_path", "bundled_package_dir", "bundled_runtime_path", diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 68a9aac993..7e5f660070 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -4,7 +4,6 @@ from __future__ import annotations import json import runpy -import stat from pathlib import Path from types import SimpleNamespace @@ -40,51 +39,22 @@ def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: build_python_release.repository_version(tmp_path) -def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - spawn_helper = Path(f"{executable}-spawn-helper") - spawn_helper.write_bytes(b"helper") - spawn_helper.chmod(0o751) - destination = tmp_path / "staging" - - build_python_release.stage_runtime( - destination, - "1.2.3", - executable, - executable.name, - ) - - runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" - assert (runtime_dir / executable.name).read_bytes() == b"runtime" - copied_helper = runtime_dir / spawn_helper.name - assert copied_helper.read_bytes() == b"helper" - assert copied_helper.stat().st_mode & stat.S_IXUSR - - -def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64" - executable.write_bytes(b"runtime") - executable.chmod(0o755) - - with pytest.raises(FileNotFoundError, match="spawn-helper"): - build_python_release.stage_runtime( - tmp_path / "staging", - "1.2.3", - executable, - executable.name, - ) - - -def test_stage_runtime_copies_linux_executable_without_spawn_helper(tmp_path: Path) -> None: - executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64" +@pytest.mark.parametrize(("target", "with_helper"), [("linux-x64", False), ("macos-arm64", True)]) +def test_stage_runtime_copies_platform_payload( + tmp_path: Path, target: str, with_helper: bool +) -> None: + executable = tmp_path / f"dsh-jsonrpc-agent-pkg-{target}" executable.write_bytes(b"runtime") executable.chmod(0o755) + expected = {executable.name: b"runtime"} + if with_helper: + spawn_helper = Path(f"{executable}-spawn-helper") + spawn_helper.write_bytes(b"helper") + spawn_helper.chmod(0o755) + expected[spawn_helper.name] = b"helper" destination = tmp_path / "staging" build_python_release.stage_runtime(destination, "1.2.3", executable, executable.name) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" - runtime_files = [path.name for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")] - assert runtime_files == [executable.name] + assert {path.name: path.read_bytes() for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")} == expected diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index e0411bb1fd..778203f4d1 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -44,25 +44,18 @@ def test_explicit_mode_wins_over_env_mode(monkeypatch: pytest.MonkeyPatch) -> No assert args[0].endswith(("-x64", "-arm64")) -@pytest.mark.parametrize( - ("platform_tag", "requires_helper"), - [("linux-x64", False), ("macos-arm64", True)], -) def test_runtime_requires_spawn_helper_only_on_macos( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - platform_tag: str, - requires_helper: bool, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: runtime_dir = tmp_path / "runtime" runtime_dir.mkdir() - executable = runtime_dir / f"dsh-jsonrpc-agent-pkg-{platform_tag}" - executable.touch() + linux = runtime_dir / "dsh-jsonrpc-agent-pkg-linux-x64" + linux.touch() + (runtime_dir / "dsh-jsonrpc-agent-pkg-macos-arm64").touch() monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) - monkeypatch.setattr(runtime, "_current_platform_tag", lambda: platform_tag) - if requires_helper: - with pytest.raises(FileNotFoundError, match="node-pty spawn helper"): - runtime.bundled_runtime_path() - else: - assert runtime.bundled_runtime_path() == executable + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "macos-arm64") + with pytest.raises(FileNotFoundError, match="node-pty spawn helper"): + runtime.bundled_runtime_path() + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") + assert runtime.bundled_runtime_path() == linux diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index bbfa2402d0..d2cdbeec4d 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -7,7 +7,7 @@ */ import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, statSync } from 'node:fs' +import { existsSync, statSync } from 'node:fs' import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -19,7 +19,6 @@ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' /** The app entry inside the deployed closure. */ const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js' const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' -const SPAWN_HELPER_SUFFIX = '-spawn-helper' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' /** Pinned for reproducible builds. */ @@ -295,7 +294,7 @@ class SingleExeBuild { async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) await this.prepareNativePty(target) - if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) + if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true }) await this.run(`pkg ${target.spec}`, pnpmBin(), [ 'dlx', PKG_SPEC, @@ -310,13 +309,13 @@ class SingleExeBuild { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } if (target.platform !== 'macos') return [product] - const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}` + const spawnHelper = `${product}-spawn-helper` + const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') if (this.cli.dryRun) { - console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`) + console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${spawnHelper}`) } else { - const source = this.resolveSpawnHelper(target) await copyFile(source, spawnHelper) - await chmod(spawnHelper, statSync(source).mode & 0o777) + await chmod(spawnHelper, 0o755) } return [product, spawnHelper] } @@ -327,47 +326,27 @@ class SingleExeBuild { * @param target - the pkg target whose native addon is being staged. */ private async prepareNativePty(target: Target): Promise { - const stagedRoot = join(this.staging, 'node_modules', 'node-pty') - const stagedBuild = join(stagedRoot, 'build') + const stagedBuild = join(this.staging, 'node_modules', 'node-pty', 'build') if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) else await rm(stagedBuild, { recursive: true, force: true }) - + if (target.platform !== 'linux') return const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node') const destination = join(stagedBuild, 'Release', 'pty.node') if (this.cli.dryRun) { - if (target.platform === 'linux') console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) + console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) return } - if (target.platform === 'macos') return - const host = Target.host() - if (target.platform !== host.platform || target.arch !== host.arch || !existsSync(source)) { + if (target.platform !== host.platform || target.arch !== host.arch) { throw new Error( - `build-exe-for-python-sdk: node-pty native addon for ${target.platform}-${target.arch} is missing; ` - + `checked ${source}. Build the Linux runtime on its target architecture.`, + 'build-exe-for-python-sdk: build the Linux runtime on its target architecture; ' + + `target ${target.platform}-${target.arch} does not match host ${host.platform}-${host.arch}.`, ) } await mkdir(dirname(destination), { recursive: true }) await copyFile(source, destination) } - /** - * Resolve the node-pty helper that matches a pkg target. - * @param target - the pkg target whose helper must be shipped. - * @returns a physical executable outside pkg's virtual snapshot. - */ - private resolveSpawnHelper(target: Target): string { - const helper = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') - if (!existsSync(helper)) { - throw new Error( - `build-exe-for-python-sdk: node-pty spawn-helper for ${target.platform}-${target.arch} is missing; ` - + `checked ${helper}. Build each runtime on its target platform and architecture.`, - ) - } - if (statSync(helper).mode & 0o111) return helper - throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) - } - /** * Print each product path and, outside dry-run mode, its size. * @param products - the product paths returned by {@link pack}. @@ -397,7 +376,7 @@ class SingleExeBuild { } return } - mkdirSync(destDir, { recursive: true }) + await mkdir(destDir, { recursive: true }) for (const path of products) { const destination = join(destDir, basename(path)) await copyFile(path, destination) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 4fe7d62980..ec049cdd4f 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -22,7 +22,10 @@ PLATFORMS = { "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), } -SPAWN_HELPER_SUFFIX = "-spawn-helper" + + +def runtime_suffixes(executable_name: str) -> tuple[str, ...]: + return ("", "-spawn-helper") if "-macos-" in executable_name else ("",) def main() -> None: @@ -133,24 +136,12 @@ def stage_sdk(destination: Path, version: str) -> None: def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: - payload = [(executable, executable_name)] - if "-macos-" in executable_name: - payload.append( - (Path(f"{executable}{SPAWN_HELPER_SUFFIX}"), f"{executable_name}{SPAWN_HELPER_SUFFIX}") - ) - for source, _ in payload: - if not source.is_file(): - raise FileNotFoundError(f"runtime file does not exist: {source}") - if source.stat().st_mode & stat.S_IXUSR == 0: - raise PermissionError(f"runtime file is not executable: {source}") copy_package(ROOT / "python" / "sdk-runtime", destination) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir.mkdir(parents=True, exist_ok=True) - for source, name in payload: - target = runtime_dir / name - shutil.copyfile(source, target) - target.chmod(source.stat().st_mode & 0o777) + for suffix in runtime_suffixes(executable_name): + shutil.copy2(Path(f"{executable}{suffix}"), runtime_dir / f"{executable_name}{suffix}") def verify_wheel( @@ -174,9 +165,7 @@ def verify_wheel( ] if package == "runtime": assert platform is not None - expected_files = [platform[1]] - if "-macos-" in platform[1]: - expected_files.append(f"{platform[1]}{SPAWN_HELPER_SUFFIX}") + expected_files = [f"{platform[1]}{suffix}" for suffix in runtime_suffixes(platform[1])] found_files = sorted(Path(name).name for name in runtime_files) if found_files != expected_files: raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") From 4b4f7752c183b7a77dd228e82e0ce969084d2ec0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:23:05 +0800 Subject: [PATCH 34/46] ci: gate web browser snapshots --- .../2026-07-20-gui-testing-system.i18n.yaml | 6 ++-- .../process/2026-07-20-gui-testing-system.md | 7 ++-- .../2026-07-20-gui-testing-system.zh.md | 7 ++-- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +-- .../2026-07-24-web-gui-browser-e2e-lane.md | 7 ++-- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 7 ++-- ...-30-web-browser-snapshot-ci-gate.i18n.yaml | 6 ++++ ...2026-07-30-web-browser-snapshot-ci-gate.md | 33 +++++++++++++++++++ ...6-07-30-web-browser-snapshot-ci-gate.zh.md | 33 +++++++++++++++++++ .github/workflows/ci.yml | 32 +++++++++++++++++- docs/testing.i18n.yaml | 4 +-- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- package.json | 3 +- packages/client/AGENTS.md | 2 +- scripts/run-gates.spec.ts | 11 +++++-- scripts/run-gates.ts | 10 ++++++ vitest.web.config.ts | 12 +++---- 18 files changed, 151 insertions(+), 37 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md create mode 100644 .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index ffa1e87103..df3de464d3 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -2026-07-20-gui-testing-system.md: 546f65f065c0c2266773acc3c28b2833a094ba9b -2026-07-20-gui-testing-system.zh.md: 6601ae0a1c2bd1671af6f02961fbda81d30ab971 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-20-gui-testing-system.md +2026-07-20-gui-testing-system.md: 12699313b6be2aa6e290916979a67364d9d07293 +2026-07-20-gui-testing-system.zh.md: 34a08feec71c472b5df829c8d93c1ed925f91b53 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index 546f65f065..12699313b6 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -20,7 +20,7 @@ Cut along the architecture's natural test seams into three tiers, bottom-up: |---|---|---|---| | 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | | 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` | -| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | +| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md), [required CI gate](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2. @@ -34,6 +34,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes | Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source | | Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery | | Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 browser set: the two-level smoke (fixture level + real-host level self-skip) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=record`/`refresh` re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery | +| Browser expected-output gate | `DSH_SNAPSHOT=replay pnpm run test:web:built` | Reuses CI-built artifacts and compares every committed browser golden without writing | Every Linux pull request and default-branch push | | Gate | `pnpm run test:coverage` | The repo-wide gate (host and client GUI packages included, except annotated browser-grade exclusions) | The PR window | **Division of labor between the browser scripts and vitest**: Playwright owns browser/carrier black-box regression and long sequential user journeys; ordinary vitest owns data-layer semantics such as reference stability, timing, and wire shapes; snapshot vitest owns stable app-level semantic output through the built composition. These lanes complement each other rather than duplicating assertions. @@ -46,7 +47,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes ## Consequences -Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gui` feedback, wire/object-layer semantics assert in milliseconds in Node, built-composition snapshots pin deterministic user-visible projection, and the browser carries wiring and carrier acceptance. The accepted cost is that inter-tier discipline is upheld by review rather than a machine gate and every new app snapshot must avoid unstable layout or clock output. +Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gui` feedback, wire/object-layer semantics assert in milliseconds in Node, built-composition snapshots pin deterministic user-visible projection, and the browser carries wiring and carrier acceptance. Inter-tier discipline remains review-owned, while Linux CI mechanically enforces browser-golden freshness. Every new app snapshot must avoid unstable layout or clock output. ## Alternatives considered @@ -56,4 +57,4 @@ Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gu | Migrating the verify scripts to vitest | An ordered script shares one browser session; splitting the cases either formalizes it (sequential + shared page) or re-runs the preamble × N; streaming PASS/FAIL output is exactly the agent's locating interface | | Reusing FixtureApiClient in tests | The demo script runs on a real clock, tests need deferred hand-controlled timing — orthogonal purposes; forced reuse chains the tests to the demo's rhythm | | A standalone vitest config for GUI packages (once designed as vitest.gui.config.ts) | Package-level tests/ are already scanned by the root include; `vitest run packages/client packages/host` path filtering is the tight loop — zero new config | -| Deferring hooks/component-layer unit tests (the original ruling) | Once deferred as "components are consumables, revisit after the redo"; overturned by the user on 2026-07-20 — **the jsdom mainline enters coverage** (no browser infrastructure in CI is the decisive reason, playwright demoted to a local enhancement), the RTL dependencies entered devDependencies, the first spec landed | +| Deferring hooks/component-layer unit tests | jsdom remains the coverage mainline because it gives fast per-file component behavior; the required browser replay gate complements it at the assembled tier rather than replacing it ([CI gate decision](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index 6601ae0a1c..34a08feec7 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -20,7 +20,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 |---|---|---|---| | 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD类型/SSE 流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | | 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` | -| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture,与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | +| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture,与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)、[必需 CI 门禁](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | 层间纪律:**下层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。 @@ -34,6 +34,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 | 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 | | 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 | | 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层浏览器全集:双级 smoke(fixture 级 + 真 host 级 self-skip)加上无密钥回放 e2e 场景(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture / 重写期望输出) | 改构建面/boot/承载后;交付前 | +| 浏览器预期输出门禁 | `DSH_SNAPSHOT=replay pnpm run test:web:built` | 复用 CI 构建的产物,并在不写入的情况下比较每份已提交的浏览器预期输出 | 每个 Linux 拉取请求和默认分支推送 | | 门禁 | `pnpm run test:coverage` | 全仓 gate(host 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR 窗口 | **浏览器脚本与 vitest 的分工**:Playwright 负责浏览器/承载层黑盒回归和较长的连续用户操作流程;普通 vitest 负责引用稳定性、时序和 wire 结构等数据层语义;快照 vitest 通过构建后的组合负责稳定的应用层语义输出。这些车道彼此互补,而不重复断言。 @@ -46,7 +47,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 ## Consequences -各车道各测各层:改动任意 GUI 源码后都能获得秒级 `test:gui` 反馈,wire/对象层语义在 Node 环境中进行毫秒级断言,基于构建后组合的快照固定确定性的用户可见投影,浏览器负责接线与承载层验收。接受的代价是层间纪律由评审而非机器门禁维持,而且每个新的应用快照都必须避开不稳定的布局或时钟输出。 +各车道各测各层:改动任意 GUI 源码后都能获得秒级 `test:gui` 反馈,wire/对象层语义在 Node 环境中进行毫秒级断言,基于构建后组合的快照固定确定性的用户可见投影,浏览器负责接线与承载层验收。层间纪律仍由评审负责,而 Linux CI 通过机器门禁确保浏览器预期输出的新鲜度。每个新的应用快照都必须避开不稳定的布局或时钟输出。 ## Alternatives considered @@ -56,4 +57,4 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 | verify 脚本迁 vitest | 有序剧本共享浏览器会话,拆 case 要么形式化(sequential+共享 page)要么重走前置×N;PASS/FAIL 流式输出正是 agent 定位接口 | | 测试复用 FixtureApiClient | 演示脚本走真实时钟,测试需要 deferred 手控时序——用途正交,硬复用把测试绑死在演示节奏上 | | GUI 包独立 vitest config(曾设计 vitest.gui.config.ts) | 包级 tests/ 本就被根 include 扫到,`vitest run packages/client packages/host` 路径过滤即窄循环——零新 config | -| hooks/组件层暂缓单测(原裁决) | 曾以「组件是耗材、等重做后再议」暂缓;2026-07-20 用户改判——**jsdom 主线进覆盖率**(CI 无浏览器基建是决定性理由,playwright 降级为本地增强),RTL 依赖入 devDeps、首个 spec 已落 | +| hooks/组件层暂缓单测 | jsdom 仍是覆盖率主线,因为它能快速验证逐文件组件行为;必需的浏览器回放门禁在组装层与之互补,而非取代它([CI 门禁决策](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 0283559c9c..1b71b36ddc 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -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 .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: ce59dcce270d548c91e3719eee8e9c83aea0c154 -2026-07-24-web-gui-browser-e2e-lane.zh.md: bad3dd15ed7b98cc17340666a6c1094d0de057b1 +2026-07-24-web-gui-browser-e2e-lane.md: 74522e070e9a26e61db02ea4f71c2b205f222037 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 4ab1e70b3fedb1eae88be457aa0d533c1d193bb1 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index ce59dcce27..74522e070e 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -46,7 +46,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e ### CI stance -The lane ships gate-exempt inside `pnpm run test:web`, exactly as that config's header records. Adding chromium to CI would reverse the "no browser infrastructure in CI" premise in the [GUI testing note](../process/2026-07-20-gui-testing-system.md) and therefore requires its own Agent Note cross-linked from there, staged as a non-required job first with measured promotion criteria (consecutive green runs, wall time, zero-retry flake budget, runner browser-cache strategy). `TODO(ci-browser)` marks the seam. Scenarios are POSIX-oriented (the lane is not in the Windows matrix). +The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The static job publishes `apps/web/dist` with the package build artifacts; the consumer job installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. CI never records or refreshes goldens. The default-branch Linux serial jobs run the same comparison and seed the hosted browser cache. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. ## Prior art @@ -76,12 +76,11 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot ## Testing -`pnpm run test:web` runs the lane keylessly. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh` rewrites aria goldens keylessly. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position. +`pnpm run test:web` builds and runs the lane keylessly; `test:web:built` runs it against existing build artifacts. `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` records a prompting scenario against the live model, and `DSH_SNAPSHOT=refresh pnpm run test:web` rewrites aria goldens keylessly. CI explicitly selects replay mode. `dsh-llm-replay` unit coverage pins pacing, cancellation, consumption diagnostics, sidecar validation, indexed replacement, and the single append position. ## Deferred - **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins the web composition's prompt/tool schemas (`TODO(web-header-pin)` — the scaffold `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors. -- **CI browser provisioning**: reversal of the no-browser-in-CI ruling, staged criteria above (`TODO(ci-browser)`). - **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. - **Web error surface**: the client consumes no `agent/error` frames and a pre-chunk failure freezes no partial, so a non-retryable provider failure renders no error copy — the user sees the send simply stop. The AUTH scenario pins the current contract (no crash, composer recovers, turn logged `error`) and `FIXME(web-error-surface)` marks where visible error text gets asserted once the UI grows an error rendering. - **Composer steering gesture**: the input locks while running (stop-or-wait), so the steering scenario steers over the wire from the page; `TODO(web-steer-composer)` upgrades the drive step to a real composer gesture when the product grows one. @@ -89,4 +88,4 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot ## Consequences -The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the lane guards regressions only where it runs (locally, `test:web`) until the CI reversal is separately decided. +The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and required Linux CI pays for Chromium provisioning and the serial browser run so the PR that changes the assembled UI owns its expected-output diff. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index bad3dd15ed..4ab1e70b3f 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -46,7 +46,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### CI 立场 -车道随 `pnpm run test:web` 交付、豁免门禁,与该配置头部注释所记一致。往 CI 加 chromium 会推翻 [GUI 测试笔记](../process/2026-07-20-gui-testing-system.md)中「CI 无浏览器基础设施」的前提,因此需要自己的 Agent Note 并从那里交叉链接,分阶段推进:先作为非必需任务,再以量化标准晋升(连续绿色运行次数、耗时、零重试的抖动预算、runner 浏览器缓存策略)。`TODO(ci-browser)` 标记该接缝。场景目前面向 POSIX(车道不在 Windows 矩阵中)。 +根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;消费方任务安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。CI 从不录制或刷新预期输出。默认分支上的 Linux 串行任务执行同样的比较,并填充托管浏览器缓存。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 ## 业界先例 @@ -76,12 +76,11 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## Testing -`pnpm run test:web` 无密钥运行该车道。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh` 则无密钥重写 aria 预期输出。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。 +`pnpm run test:web` 构建并无密钥运行该车道;`test:web:built` 基于现有构建产物运行。`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型录制一个发起提示的场景,`DSH_SNAPSHOT=refresh pnpm run test:web` 则无密钥重写 aria 预期输出。CI 显式选择回放模式。`dsh-llm-replay` 单元覆盖率钉住节奏控制、取消、消费诊断、sidecar 校验、按索引替换与唯一的追加位置。 ## 暂缓 - **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 web 组合的提示词/工具 schema(`TODO(web-header-pin)`——scaffold 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。 -- **CI 浏览器供给**:推翻 CI 无浏览器裁定,分阶段标准见上(`TODO(ci-browser)`)。 - **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 - **Web 错误表面**:客户端不消费任何 `agent/error` 帧,分片前的失败也没有可冻结的部分输出,因此不可重试的提供方失败不渲染任何错误文案——用户看到的只是发送就此停住。AUTH 场景钉住当前契约(不崩溃、输入框恢复可用、轮次记录为 `error`),`FIXME(web-error-surface)` 标记了待 UI 长出错误渲染后断言可见错误文本的位置。 - **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer;`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。 @@ -89,4 +88,4 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## 后果 -Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;在 CI 反转被单独决策之前,车道只在其运行之处(本地,`test:web`)把守回归。 +Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;必需的 Linux CI 承担 Chromium 供给与串行浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml new file mode 100644 index 0000000000..8a8a916c21 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +2026-07-30-web-browser-snapshot-ci-gate.md: 71d6bcdbfad11032ea6f8e7d570780f780cfe071 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: bb28b42c5d2684d29e7e18646d9756b40557eb11 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md new file mode 100644 index 0000000000..71d6bcdbfa --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -0,0 +1,33 @@ +# Agent Note: Required CI gate for web browser expected outputs + +Status: implemented + +English | [中文](2026-07-30-web-browser-snapshot-ci-gate.zh.md) + +## Problem + +The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs only under the local `pnpm run test:web` command, and PR CI does not compare `apps/web/tests/snapshots/**/*.expected.md`. A PR that changes user-visible web output can therefore remain green when its expected outputs are not refreshed; when any later branch explicitly runs `DSH_SNAPSHOT=refresh`, it backfills the earlier change and produces a diff unrelated to that branch. Ordinary local runs already default to read-only replay, so the gap is mandatory enforcement at the PR level, not a ban on writes in refresh mode. + +## Decision + +For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. `scripts/run-gates.ts` registers `test:web:built` as a `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. + +The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. The consumer job installs Chromium and its system dependencies at the Playwright version in the lockfile, and caches the browser by operating system and the `pnpm-lock.yaml` hash. The default branch's serial Linux job runs the same compare command and populates the default-branch cache, which subsequent PRs can restore directly. + +Local `pnpm run test:web` continues to build first and then run the full browser suite; `test:web:built` is the entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. + +The gate remains Linux-only: these scenarios target POSIX, and the Windows and macOS serial reference jobs do not run them again. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. + +## Alternatives considered + +**Continue requiring only local runs.** Rejected: execution depends on developer memory, which is precisely why stale goldens drift across PRs, and cannot guarantee that the PR introducing a behavior change carries its own expected-output diff. + +**Run CI in `refresh` mode and then check the working tree.** Rejected: checking after writing turns the assertion mechanism into a generator; if the working-tree check is wired incorrectly, it can turn a regression into a passing expected-output update. Replay compares the existing goldens directly and has a smaller failure surface. + +**Create a standalone browser job and rebuild the entire repository.** Rejected: it would duplicate dependency installation and the publishable build. The existing Linux consumer job already consumes the same built-tree artifact and is part of the unified required verdict. + +**Replace real Chromium with jsdom snapshots.** Rejected: jsdom does not cover the browser, HTTP/SSE carriage, or the composition of real client plugin bundles. It remains useful for fast lower-layer feedback, but cannot replace the assembled browser chain. + +## Consequences + +Before merge, every PR proves that the current web assembly matches all committed browser expected outputs, turning a missed refresh from an “unrelated change in a later PR” into a failure in the PR that introduced it. The cost is Chromium provisioning and one serial pass through the browser scenarios in Linux CI; built-artifact reuse and the default-branch browser cache avoid duplicate builds and routine downloads. The gate still makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn. diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md new file mode 100644 index 0000000000..bb28b42c5d --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web 浏览器预期输出的必需 CI 门禁 + +Status: implemented + +[English](2026-07-30-web-browser-snapshot-ci-gate.md) | 中文 + +## 问题 + +[无密钥 Web 浏览器 e2e 车道](2026-07-24-web-gui-browser-e2e-lane.md)只由本地 `pnpm run test:web` 运行,PR CI 不比较 `apps/web/tests/snapshots/**/*.expected.md`。因此,改变用户可见 Web 输出的 PR 可以在漏刷预期输出时保持绿色;后来任意分支显式运行 `DSH_SNAPSHOT=refresh`,都会替前序变更补账并产生与本分支无关的 diff。普通本地运行已经默认使用只读 replay,缺口是 PR 级的强制执行,而不是禁止 refresh 写入。 + +## 决策 + +Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。`scripts/run-gates.ts` 把 `test:web:built` 作为 `ci-consumers` 的一个 gate,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的 golden 与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 + +静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 连同 package `lib/` 放进 built-tree artifact,consumer job 复用该 artifact 而不重复全仓构建。consumer job 按 lockfile 中的 Playwright 版本安装 Chromium 及系统依赖,并以操作系统和 `pnpm-lock.yaml` 哈希缓存浏览器。默认分支的 Linux 串行 job 运行同一 compare 命令并产出默认分支缓存,后续 PR 可直接恢复。 + +本地 `pnpm run test:web` 仍先构建再运行浏览器全集;`test:web:built` 是已有构建产物的执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处 expected diff,再以 replay 模式复验不再写文件。 + +门禁保持 Linux-only:这些场景面向 POSIX,Windows 与 macOS 的串行参考 job 不重复运行。PR 的 `all checks passed` 已依赖 consumer job,因此浏览器 compare 失败会阻止合并,无需新增 branch-protection check 名称。 + +## 曾考虑的替代方案 + +**继续只要求本地运行。** 已否决:执行依赖开发者记忆,正是旧 golden 跨 PR 漂移的原因,不能保证产生行为变化的 PR 自己携带 expected diff。 + +**让 CI 以 `refresh` 模式运行后检查工作树。** 已否决:写后比较把断言机制变成生成器,若工作树检查接线失效就会把回归更新成绿色;replay 直接比较已有 golden,失败面更小。 + +**新建独立 browser job 并重新构建全仓。** 已否决:它会重复依赖安装和发布构建。现有 Linux consumer job 已消费同一 built-tree artifact,并已被统一的 required verdict 聚合。 + +**用 jsdom 快照代替真实 Chromium。** 已否决:jsdom 不覆盖浏览器、HTTP/SSE 承载及真实 client plugin bundle 组合;它保留为快速的下层反馈,不能替代 assembled browser chain。 + +## 后果 + +每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器 expected 一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是 Linux CI 增加 Chromium 供给和一轮串行浏览器场景;built artifact 复用与默认分支浏览器缓存避免重复构建和常态下载。门禁仍不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 aria 格式,升级 PR 必须显式 refresh 并评审 churn。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6782024240..00e85f57ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: - name: Pack built tree run: >- tar -czf "$RUNNER_TEMP/node-24-built-tree.tar.gz" - apps/*/lib packages/*/*/lib vendor/*/lib + apps/*/lib apps/web/dist packages/*/*/lib vendor/*/lib - uses: actions/upload-artifact@v7 with: @@ -224,6 +224,12 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: actions/cache@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + - name: Install dependencies and prepare bubblewrap run: | pnpm install --frozen-lockfile & @@ -237,6 +243,9 @@ jobs: if (( install_status != 0 )); then exit "$install_status"; fi exit "$sandbox_status" + - name: Install Playwright Chromium + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium + - name: Run compatibility, snapshot, and artifact gates run: pnpm run check:ci:consumers @@ -448,9 +457,17 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + - name: Install (immutable) run: pnpm install --frozen-lockfile + - name: Install Playwright Chromium + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium + - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -465,6 +482,11 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci + - name: Compare web browser snapshots + env: + DSH_SNAPSHOT: replay + run: pnpm run test:web:built + # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, # continuously proving that environment can take over a required lane if @@ -505,6 +527,9 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile + - name: Install Playwright Chromium + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium + - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -519,6 +544,11 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci + - name: Compare web browser snapshots + env: + DSH_SNAPSHOT: replay + run: pnpm run test:web:built + serial-macos: if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / macos diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 976f52c779..14e710f5ba 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -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 docs/testing.md -testing.md: 04bd7782fa4328b6b693f13f60f4e33b463f8a18 -testing.zh.md: 5712fd8ce7b0cd46ebeb237bdd12c6c572ebe3de +testing.md: fd4879158d7aa1f4726043b0e519f1d2827f41ec +testing.zh.md: bd78afb4fa29e8cc505bae12701f1651f7ba7b5e diff --git a/docs/testing.md b/docs/testing.md index 04bd7782fa..fd4879158d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,7 +10,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **Web browser snapshot** (gate-exempt `pnpm run test:web`): real chromium over the in-process web composition replays recorded fixtures against conversation aria goldens (`apps/web/tests/snapshots/`); record/refresh semantics and the deferred CI browser decision: [web e2e lane Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). [Runs `build` first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md): plugin CSS ships per plugin. +- **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header. In-flight branches carrying older fixture edits merge current `master` and run the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) through `pnpm run migrate:packed-session-fixtures`; the [removal proposal](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) retires that command and these links after all affected branches converge. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 5712fd8ce7..bd78afb4fa 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -10,7 +10,7 @@ - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 - **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 -- **Web 浏览器快照**(豁免门禁的 `pnpm run test:web`):真实 chromium 在进程内 web 组装之上回放已录制 fixture,与会话区 aria 预期输出比对(`apps/web/tests/snapshots/`);`DSH_SNAPSHOT=record`/`refresh` 的语义与暂缓的 CI 浏览器决策见 [web e2e 车道 Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)。[先跑 `build`](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md):插件 CSS 按插件分别发布。 +- **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture。仍携带旧版 fixture 改动的在途分支应合并当前 `master`,并通过 `pnpm run migrate:packed-session-fixtures` 运行[临时迁移器](../scripts/migrate-packed-session-fixtures.ts);待所有受影响分支收敛后,[移除提案](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)会移除该命令及这些链接。 diff --git a/package.json b/package.json index ecb7215e66..465d13e18f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts", - "test:web": "npm run build && vitest run --config vitest.web.config.ts", + "test:web": "npm run build && npm run test:web:built", + "test:web:built": "vitest run --config vitest.web.config.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index a7d80b3232..03ffbab1ab 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -76,7 +76,7 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy Run the narrowest rung that covers what you touched; escalate only when the change surface demands it. 1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck. -2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key). +2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios. Linux PR CI pins replay mode and compares the committed goldens; it never records or refreshes. Use `DSH_SNAPSHOT=refresh` locally after an intentional conversation-UI change, or `DSH_SNAPSHOT=record` with a key to re-record fixtures. 3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit. If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep. diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index ab80a7329b..7126316e4b 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -135,17 +135,18 @@ describe('Oxlint gate', () => { }) describe('Node 24 consumer graph', () => { - it('owns the seven-command pool and orders restored-artifact consumers', () => { + it('owns the eight-command pool and orders restored-artifact consumers', () => { const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({ - workers: 7, + workers: 8, source: 'ci-consumers gate count', }) expect(subject.map(item => item.id)).toEqual([ 'lint-and-duplication', 'node-compat', 'snapshot', + 'web-snapshot', 'publint', 'node-next-types', 'built-package-invariants', @@ -154,10 +155,14 @@ describe('Node 24 consumer graph', () => { expect(subject.find(item => item.id === 'publint')?.needs).toBeUndefined() expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) - for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) { + for (const id of ['snapshot', 'web-snapshot', 'node-next-types', 'built-bin-smoke']) { expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) } expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' }) + expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({ + displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', + env: { DSH_SNAPSHOT: 'replay' }, + }) }) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 900b67247e..5fd444de87 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -331,6 +331,7 @@ function ciConsumerGates(): Gate[] { }), pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), snapshotGate(restoredBuild), + webSnapshotGate(restoredBuild), pnpmScript('publint', 'publint'), pnpmScript('node-next-types', 'verify-node-next-types', { label: 'node-next types', @@ -341,6 +342,15 @@ function ciConsumerGates(): Gate[] { ] } +function webSnapshotGate(needs: string[]): Gate { + return pnpmScript('web-snapshot', 'test:web:built', { + label: 'web browser snapshot', + displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', + env: { DSH_SNAPSHOT: 'replay' }, + needs, + }) +} + function ciWindowsBlockingGates(): Gate[] { return [ pnpmScript('windows-build', 'build', { label: 'build' }), diff --git a/vitest.web.config.ts b/vitest.web.config.ts index 485a493d55..fd9ca502bc 100644 --- a/vitest.web.config.ts +++ b/vitest.web.config.ts @@ -1,14 +1,10 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -// Web browser lane (GUI, gate-exempt — not part of the CI sequence yet): real -// host entry points, built-client interaction snapshots, and the replayed -// keyless e2e scenarios, outside the unit/e2e includes. Real-model cases -// self-skip without DEEPSEEK_API_KEY; fixture branches and replay stay -// keyless and deterministic. -// TODO(ci-browser): running this lane in CI requires chromium provisioning -// and reverses the no-browser-in-CI ruling — staged criteria in -// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md. +// Web browser lane: real host entry points, built-client interaction snapshots, +// and replayed keyless e2e scenarios outside the unit/e2e includes. Linux PR CI +// pins DSH_SNAPSHOT=replay and compares committed goldens; record/refresh remain +// explicit local workflows. Real-model cases self-skip without DEEPSEEK_API_KEY. try { // Node >= 21.7 native; throws when the file does not exist. process.loadEnvFile(new URL('.env', import.meta.url).pathname) From 0dbe631c3a9532fbb6931345c4cd72e7c2336064 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:23:23 +0800 Subject: [PATCH 35/46] test(web): refresh browser expected outputs --- .../tests/snapshots/code-mode-round/ui.expected.md | 9 ++------- .../snapshots/cordis-tool-round/ui.expected.md | 9 ++------- .../tests/snapshots/fresh-round-trip/ui.expected.md | 13 +++++-------- .../snapshots/lifecycle-chrome/hero.expected.md | 6 +----- .../snapshots/lifecycle-chrome/reloaded.expected.md | 9 ++------- .../snapshots/live-interactions/cancel.expected.md | 9 ++------- .../live-interactions/error-auth.expected.md | 9 ++------- .../snapshots/live-interactions/retry.expected.md | 9 ++------- .../tests/snapshots/message-actions/ui.expected.md | 8 ++------ .../question-composer/answered.expected.md | 9 ++------- .../tests/snapshots/seeded-history/ui.expected.md | 8 ++------ .../tests/snapshots/steering/mid-steer.expected.md | 3 +-- .../tests/snapshots/steering/settled.expected.md | 9 ++------- 13 files changed, 27 insertions(+), 83 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index ccbf531e96..ff53c0c23a 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - 'button "Think The user wants me to write a single `run_code` program that:"': - img - img @@ -38,14 +37,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 5e1a148f9e..4af132b7a2 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to:": - img - img @@ -62,14 +61,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index d0810ea315..b25ff5487c 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - img @@ -22,7 +21,9 @@ - img - text: {{clock}} - img -- text: Bash Echo the test string +- text: Bash Echo the test string 已完成 workspace echo WEB_E2E_OK +- button "复制" +- text: WEB_E2E_OK - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": - img - img @@ -33,14 +34,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index ee6c1a7475..e357ebcdd8 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -28,11 +28,7 @@ - textbox "Describe what you want to build" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 6d601aad87..8058490303 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to reply with a single word. Let me comply.": - img - img @@ -22,14 +21,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index a141ad147c..4b584df800 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - paragraph: partial - text: 已停止 - button "复制": @@ -19,14 +18,10 @@ - button "在新对话中分支": - img - text: {{clock}} 0 tokens · 1 turns · 1 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 7fd444dc9d..30233ef5cb 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -11,15 +11,10 @@ - img - button "编辑": - img -- button "▸ 上下文注入" -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index d857b6663c..c3160c1b51 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img @@ -22,14 +21,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 7,869 tokens · 1 turns · 1 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index bd833b6c3c..748f0e1109 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -37,14 +37,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index bd03429898..443636d80d 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img @@ -35,14 +34,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 95% · 8,769 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index c65bf7e712..c54c6ac3f5 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -36,14 +36,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index b70aa38d2c..eb5d00dea4 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -24,7 +23,7 @@ - button: - img - img -- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} cache hit 98% · 7,946 tokens · 1 turns · 1 steps" +- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}" - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 790bd022f2..08a230af9a 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -11,7 +11,6 @@ - img - button "编辑": - img -- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -35,14 +34,10 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,967 tokens · 1 turns · 2 steps -- textbox "Message the agent" +- textbox "给智能体发消息" - button "Add attachment": - img -- text: Danger Full Access -- combobox "Access mode": - - option "Read Only" - - option "Workspace Write" - - option "Danger Full Access" [selected] +- 'button "Access mode, current: Danger Full Access"': Danger Full Access - button "选择模型,当前 DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img From 72c9d40ad9026b12eb917b766e1423f098fbd6bb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:54:51 +0800 Subject: [PATCH 36/46] ci: isolate web snapshots to consumer lane --- .../2026-07-20-gui-testing-system.i18n.yaml | 4 ++-- .../process/2026-07-20-gui-testing-system.md | 2 +- .../2026-07-20-gui-testing-system.zh.md | 2 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 ++-- .../2026-07-24-web-gui-browser-e2e-lane.md | 4 ++-- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 4 ++-- ...-30-web-browser-snapshot-ci-gate.i18n.yaml | 4 ++-- ...2026-07-30-web-browser-snapshot-ci-gate.md | 6 +++--- ...6-07-30-web-browser-snapshot-ci-gate.zh.md | 6 +++--- .github/workflows/ci.yml | 21 ------------------- 10 files changed, 18 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index df3de464d3..05d448c79b 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-07-20-gui-testing-system.md -2026-07-20-gui-testing-system.md: 12699313b6be2aa6e290916979a67364d9d07293 -2026-07-20-gui-testing-system.zh.md: 34a08feec71c472b5df829c8d93c1ed925f91b53 +2026-07-20-gui-testing-system.md: 8c6dafb18fc207fc4eac780ba18e108267bc28b1 +2026-07-20-gui-testing-system.zh.md: 9a0de4bfa8fa2f8de55beef53bedde51649c5d9c diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index 12699313b6..8c6dafb18f 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -34,7 +34,7 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes | Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source | | Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery | | Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 browser set: the two-level smoke (fixture level + real-host level self-skip) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=record`/`refresh` re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery | -| Browser expected-output gate | `DSH_SNAPSHOT=replay pnpm run test:web:built` | Reuses CI-built artifacts and compares every committed browser golden without writing | Every Linux pull request and default-branch push | +| Browser expected-output gate | `DSH_SNAPSHOT=replay pnpm run test:web:built` | Reuses CI-built artifacts and compares every committed browser golden without writing | Every Linux pull request | | Gate | `pnpm run test:coverage` | The repo-wide gate (host and client GUI packages included, except annotated browser-grade exclusions) | The PR window | **Division of labor between the browser scripts and vitest**: Playwright owns browser/carrier black-box regression and long sequential user journeys; ordinary vitest owns data-layer semantics such as reference stability, timing, and wire shapes; snapshot vitest owns stable app-level semantic output through the built composition. These lanes complement each other rather than duplicating assertions. diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index 34a08feec7..9a0de4bfa8 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -34,7 +34,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 | 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 | | 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 | | 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层浏览器全集:双级 smoke(fixture 级 + 真 host 级 self-skip)加上无密钥回放 e2e 场景(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture / 重写期望输出) | 改构建面/boot/承载后;交付前 | -| 浏览器预期输出门禁 | `DSH_SNAPSHOT=replay pnpm run test:web:built` | 复用 CI 构建的产物,并在不写入的情况下比较每份已提交的浏览器预期输出 | 每个 Linux 拉取请求和默认分支推送 | +| 浏览器预期输出门禁 | `DSH_SNAPSHOT=replay pnpm run test:web:built` | 复用 CI 构建的产物,并在不写入的情况下比较每份已提交的浏览器预期输出 | 每个 Linux 拉取请求 | | 门禁 | `pnpm run test:coverage` | 全仓 gate(host 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR 窗口 | **浏览器脚本与 vitest 的分工**:Playwright 负责浏览器/承载层黑盒回归和较长的连续用户操作流程;普通 vitest 负责引用稳定性、时序和 wire 结构等数据层语义;快照 vitest 通过构建后的组合负责稳定的应用层语义输出。这些车道彼此互补,而不重复断言。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 1b71b36ddc..2c9adcdc07 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -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 .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 74522e070e9a26e61db02ea4f71c2b205f222037 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 4ab1e70b3fedb1eae88be457aa0d533c1d193bb1 +2026-07-24-web-gui-browser-e2e-lane.md: 08227a83bfb45348f85e6c306e0a2cc09a876713 +2026-07-24-web-gui-browser-e2e-lane.zh.md: e1cdb6e26ac9345161031df10fc0c0a753cb2ba9 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 74522e070e..08227a83bf 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -46,7 +46,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e ### CI stance -The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The static job publishes `apps/web/dist` with the package build artifacts; the consumer job installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. CI never records or refreshes goldens. The default-branch Linux serial jobs run the same comparison and seed the hosted browser cache. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. +The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The static job publishes `apps/web/dist` with the package build artifacts; the `node 24 / snapshots and artifacts` consumer job installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. CI never records or refreshes goldens. Default-branch serial jobs do not provision Chromium or run the lane. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. ## Prior art @@ -88,4 +88,4 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot ## Consequences -The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and required Linux CI pays for Chromium provisioning and the serial browser run so the PR that changes the assembled UI owns its expected-output diff. +The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium provisioning and one browser run so the PR that changes the assembled UI owns its expected-output diff. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 4ab1e70b3f..e1cdb6e26a 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -46,7 +46,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### CI 立场 -根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;消费方任务安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。CI 从不录制或刷新预期输出。默认分支上的 Linux 串行任务执行同样的比较,并填充托管浏览器缓存。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 +根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;`node 24 / snapshots and artifacts` 消费方任务安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。CI 从不录制或刷新预期输出。默认分支串行任务不供给 Chromium,也不运行该车道。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 ## 业界先例 @@ -88,4 +88,4 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## 后果 -Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;必需的 Linux CI 承担 Chromium 供给与串行浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。 +Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;必需的消费方任务承担 Chromium 供给与一次浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml index 8a8a916c21..7b3e6e7bb4 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml @@ -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 .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md -2026-07-30-web-browser-snapshot-ci-gate.md: 71d6bcdbfad11032ea6f8e7d570780f780cfe071 -2026-07-30-web-browser-snapshot-ci-gate.zh.md: bb28b42c5d2684d29e7e18646d9756b40557eb11 +2026-07-30-web-browser-snapshot-ci-gate.md: eb7d2fa159cec0c0e11adc28239cf6b3b4f0f593 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: ff3d49e5b49b4f63e1b1ce52f690f80cdee70b2d diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md index 71d6bcdbfa..eb7d2fa159 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -12,11 +12,11 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. `scripts/run-gates.ts` registers `test:web:built` as a `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. -The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. The consumer job installs Chromium and its system dependencies at the Playwright version in the lockfile, and caches the browser by operating system and the `pnpm-lock.yaml` hash. The default branch's serial Linux job runs the same compare command and populates the default-branch cache, which subsequent PRs can restore directly. +The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. The `node 24 / snapshots and artifacts` consumer job installs Chromium and its system dependencies at the Playwright version in the lockfile, and caches the browser by operating system and the `pnpm-lock.yaml` hash. Default-branch serial jobs do not provision Chromium or run the suite. Local `pnpm run test:web` continues to build first and then run the full browser suite; `test:web:built` is the entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. -The gate remains Linux-only: these scenarios target POSIX, and the Windows and macOS serial reference jobs do not run them again. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. +The gate runs only in the Linux PR consumer job: these scenarios target POSIX, and serial reference jobs do not provision Chromium or run the suite. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. ## Alternatives considered @@ -30,4 +30,4 @@ The gate remains Linux-only: these scenarios target POSIX, and the Windows and m ## Consequences -Before merge, every PR proves that the current web assembly matches all committed browser expected outputs, turning a missed refresh from an “unrelated change in a later PR” into a failure in the PR that introduced it. The cost is Chromium provisioning and one serial pass through the browser scenarios in Linux CI; built-artifact reuse and the default-branch browser cache avoid duplicate builds and routine downloads. The gate still makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn. +Before merge, every PR proves that the current web assembly matches all committed browser expected outputs, turning a missed refresh from an “unrelated change in a later PR” into a failure in the PR that introduced it. The cost is Chromium provisioning and one serial pass through the browser scenarios in the consumer job; built-artifact reuse and the browser cache avoid duplicate builds and downloads on reruns. The gate still makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn. diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md index bb28b42c5d..ff3d49e5b4 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -12,11 +12,11 @@ Status: implemented Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。`scripts/run-gates.ts` 把 `test:web:built` 作为 `ci-consumers` 的一个 gate,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的 golden 与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 -静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 连同 package `lib/` 放进 built-tree artifact,consumer job 复用该 artifact 而不重复全仓构建。consumer job 按 lockfile 中的 Playwright 版本安装 Chromium 及系统依赖,并以操作系统和 `pnpm-lock.yaml` 哈希缓存浏览器。默认分支的 Linux 串行 job 运行同一 compare 命令并产出默认分支缓存,后续 PR 可直接恢复。 +静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 连同 package `lib/` 放进 built-tree artifact,consumer job 复用该 artifact 而不重复全仓构建。`node 24 / snapshots and artifacts` 消费方 job 按 lockfile 中的 Playwright 版本安装 Chromium 及系统依赖,并以操作系统和 `pnpm-lock.yaml` 哈希缓存浏览器。默认分支串行 job 不供给 Chromium,也不运行该套件。 本地 `pnpm run test:web` 仍先构建再运行浏览器全集;`test:web:built` 是已有构建产物的执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处 expected diff,再以 replay 模式复验不再写文件。 -门禁保持 Linux-only:这些场景面向 POSIX,Windows 与 macOS 的串行参考 job 不重复运行。PR 的 `all checks passed` 已依赖 consumer job,因此浏览器 compare 失败会阻止合并,无需新增 branch-protection check 名称。 +门禁仅在 Linux PR 消费方 job 中运行:这些场景面向 POSIX,串行参考 job 不供给 Chromium,也不运行该套件。PR 的 `all checks passed` 已依赖 consumer job,因此浏览器 compare 失败会阻止合并,无需新增 branch-protection check 名称。 ## 曾考虑的替代方案 @@ -30,4 +30,4 @@ Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览 ## 后果 -每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器 expected 一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是 Linux CI 增加 Chromium 供给和一轮串行浏览器场景;built artifact 复用与默认分支浏览器缓存避免重复构建和常态下载。门禁仍不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 aria 格式,升级 PR 必须显式 refresh 并评审 churn。 +每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器 expected 一致,漏刷从“后续 PR 的无关变化”变成引入 PR 自己的失败。成本是消费方 job 需要供给 Chromium,并串行运行一轮浏览器场景;built artifact 复用与浏览器缓存避免重跑时重复构建和下载。门禁仍不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 aria 格式,升级 PR 必须显式 refresh 并评审 churn。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00e85f57ed..ec7fbf34c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -457,17 +457,9 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - - name: Install (immutable) run: pnpm install --frozen-lockfile - - name: Install Playwright Chromium - run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium - - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -482,11 +474,6 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci - - name: Compare web browser snapshots - env: - DSH_SNAPSHOT: replay - run: pnpm run test:web:built - # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, # continuously proving that environment can take over a required lane if @@ -527,9 +514,6 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile - - name: Install Playwright Chromium - run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium - - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -544,11 +528,6 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci - - name: Compare web browser snapshots - env: - DSH_SNAPSHOT: replay - run: pnpm run test:web:built - serial-macos: if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / macos From 6303b22487f6961377405a34de9ed28491135005 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:16:37 +0800 Subject: [PATCH 37/46] ci: run web replay on master linux --- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +-- .../process/2026-07-26-ci-failover-runbook.md | 2 +- .../2026-07-26-ci-failover-runbook.zh.md | 2 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +-- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- ...-30-web-browser-snapshot-ci-gate.i18n.yaml | 4 +-- ...2026-07-30-web-browser-snapshot-ci-gate.md | 4 +-- ...6-07-30-web-browser-snapshot-ci-gate.zh.md | 4 +-- .github/workflows/ci.yml | 35 ++++++++++++++++--- package.json | 1 + packages/client/AGENTS.md | 6 ++-- scripts/run-gates.spec.ts | 14 ++++++++ scripts/run-gates.ts | 6 +++- 14 files changed, 68 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index a7b9e827fa..5fb7527463 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -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 .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 4e4f8ea7fc60cf76fd8308147bbf7cc0bac74798 -2026-07-26-ci-failover-runbook.zh.md: bb7e43fe55c9cced51f042de6503978ec9349d6b +2026-07-26-ci-failover-runbook.md: 72261f95ea74b61e3915a1a6419b2c2e616efbd9 +2026-07-26-ci-failover-runbook.zh.md: fdce40ffac5036cb4caf8eb86d20b7bb5bae4fc8 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 4e4f8ea7fc..72261f95ea 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -14,7 +14,7 @@ Each of the three required Linux worker jobs — and the `all checks passed` ver ### What the in-house pool is -`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Check the latest `serial / linux (self-hosted standby)` run before switching: a green standby is verified-yesterday capacity. +`vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Its image must preinstall Playwright Chromium's Linux system packages; CI downloads the lockfile-selected browser but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite. ### Switch (any repository writer, ~1 minute, no merge) diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index bb7e43fe55..fdce40ffac 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 自有池是什么 -`vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:绿色 = 这套环境昨天刚被全量验证过。 +`vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。其镜像必须预装 Playwright Chromium 的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。 ### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 2c9adcdc07..0b1f79f342 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -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 .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 08227a83bfb45348f85e6c306e0a2cc09a876713 -2026-07-24-web-gui-browser-e2e-lane.zh.md: e1cdb6e26ac9345161031df10fc0c0a753cb2ba9 +2026-07-24-web-gui-browser-e2e-lane.md: b57273c1c7bb957eeb4de2cf786370c18e6e735c +2026-07-24-web-gui-browser-e2e-lane.zh.md: 53e6550b9b1ac7612908e6b464eadd40704ee05f diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 08227a83bf..b57273c1c7 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -46,7 +46,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e ### CI stance -The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The static job publishes `apps/web/dist` with the package build artifacts; the `node 24 / snapshots and artifacts` consumer job installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. CI never records or refreshes goldens. Default-branch serial jobs do not provision Chromium or run the lane. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. +The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The static job publishes `apps/web/dist` with the package build artifacts; the `node 24 / snapshots and artifacts` consumer job installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The hosted and self-hosted default-branch Linux serial jobs run the same gate; the hosted job produces the browser cache consumed by pull requests, while the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. ## Prior art diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index e1cdb6e26a..53e6550b9b 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -46,7 +46,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### CI 立场 -根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;`node 24 / snapshots and artifacts` 消费方任务安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。CI 从不录制或刷新预期输出。默认分支串行任务不供给 Chromium,也不运行该车道。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 +根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。static 任务会把 `apps/web/dist` 与包构建产物一同发布;`node 24 / snapshots and artifacts` 消费方任务安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动契约](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 ## 业界先例 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml index 7b3e6e7bb4..dec7f26e7f 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml @@ -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 .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md -2026-07-30-web-browser-snapshot-ci-gate.md: eb7d2fa159cec0c0e11adc28239cf6b3b4f0f593 -2026-07-30-web-browser-snapshot-ci-gate.zh.md: ff3d49e5b49b4f63e1b1ce52f690f80cdee70b2d +2026-07-30-web-browser-snapshot-ci-gate.md: b4e78e72d6eadacb18fb0e22d9c6d28a01211717 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: d2383ca9c7a074bffb2f34f736541baaa9e04df9 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md index eb7d2fa159..b4e78e72d6 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -12,11 +12,11 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. `scripts/run-gates.ts` registers `test:web:built` as a `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. -The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. The `node 24 / snapshots and artifacts` consumer job installs Chromium and its system dependencies at the Playwright version in the lockfile, and caches the browser by operating system and the `pnpm-lock.yaml` hash. Default-branch serial jobs do not provision Chromium or run the suite. +The static CI job already builds all publishable artifacts; it puts `apps/web/dist` and the package `lib/` directories in the built-tree artifact, which the consumer job reuses without rebuilding the entire repository. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. The hosted default-branch Linux serial job runs the suite and produces the operating-system-and-lockfile-keyed browser cache; pull requests restore it without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. The self-hosted standby runs the same comparison without hosted cache actions. Local `pnpm run test:web` continues to build first and then run the full browser suite; `test:web:built` is the entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. -The gate runs only in the Linux PR consumer job: these scenarios target POSIX, and serial reference jobs do not provision Chromium or run the suite. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. +For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The hosted and self-hosted default-branch Linux serial aggregates also include the comparison, while the macOS and Windows serial jobs remain browser-free. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md index ff3d49e5b4..d2383ca9c7 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -12,11 +12,11 @@ Status: implemented Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。`scripts/run-gates.ts` 把 `test:web:built` 作为 `ci-consumers` 的一个 gate,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的 golden 与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 -静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 连同 package `lib/` 放进 built-tree artifact,consumer job 复用该 artifact 而不重复全仓构建。`node 24 / snapshots and artifacts` 消费方 job 按 lockfile 中的 Playwright 版本安装 Chromium 及系统依赖,并以操作系统和 `pnpm-lock.yaml` 哈希缓存浏览器。默认分支串行 job 不供给 Chromium,也不运行该套件。 +静态 CI job 已经构建全部发布产物;它把 `apps/web/dist` 和包的 `lib/` 目录放进 built-tree 产物,消费方 job 复用该产物而不重复全仓构建。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。托管的默认分支 Linux 串行 job 运行该套件,并生成以操作系统和锁文件为键的浏览器缓存;PR 恢复该缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。自托管热备运行相同的比较,但不执行托管缓存操作。 本地 `pnpm run test:web` 仍先构建再运行浏览器全集;`test:web:built` 是已有构建产物的执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处 expected diff,再以 replay 模式复验不再写文件。 -门禁仅在 Linux PR 消费方 job 中运行:这些场景面向 POSIX,串行参考 job 不供给 Chromium,也不运行该套件。PR 的 `all checks passed` 已依赖 consumer job,因此浏览器 compare 失败会阻止合并,无需新增 branch-protection check 名称。 +对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不供给 Chromium。托管和自托管的默认分支 Linux 串行聚合作业也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。 ## 曾考虑的替代方案 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec7fbf34c1..c9800a0d62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,11 +224,15 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache@v4 + # Pull requests restore the cache produced by serial-linux on master; + # they do not pay compression and upload on the required path. + - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- - name: Install dependencies and prepare bubblewrap run: | @@ -243,9 +247,16 @@ jobs: if (( install_status != 0 )); then exit "$install_status"; fi exit "$sandbox_status" - - name: Install Playwright Chromium + - name: Install Playwright Chromium and hosted dependencies + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium + # The persistent VM image owns Playwright's Linux system packages; do + # not mutate the shared host with apt on every failover run. + - name: Install Playwright Chromium on the failover VM + if: vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install chromium + - name: Run compatibility, snapshot, and artifact gates run: pnpm run check:ci:consumers @@ -457,9 +468,20 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + # Master produces the hosted Chromium cache restored by pull requests. + - uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install (immutable) run: pnpm install --frozen-lockfile + - name: Install Playwright Chromium and system dependencies + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install --with-deps chromium + - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -472,7 +494,7 @@ jobs: DSH_OXLINT_THREADS: '1' DSH_PUBLINT_CONCURRENCY: '1' DSH_SNAPSHOT_MAX_CONCURRENCY: '1' - run: pnpm run check:ci + run: pnpm run check:ci:linux-primary # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, @@ -514,6 +536,11 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile + # The persistent VM image owns Playwright's Linux system packages; this + # step also proves that browser provisioning remains usable for failover. + - name: Install Playwright Chromium + run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install chromium + - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh @@ -526,7 +553,7 @@ jobs: DSH_OXLINT_THREADS: '1' DSH_PUBLINT_CONCURRENCY: '1' DSH_SNAPSHOT_MAX_CONCURRENCY: '1' - run: pnpm run check:ci + run: pnpm run check:ci:linux-primary serial-macos: if: github.event_name == 'push' && github.ref == 'refs/heads/master' diff --git a/package.json b/package.json index 465d13e18f..fc315cb146 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", + "check:ci:linux-primary": "tsx scripts/run-gates.ts ci-linux-primary", "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint": "tsx scripts/run-gates.ts ci-lint", "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 03ffbab1ab..2b09179410 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -76,8 +76,8 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy Run the narrowest rung that covers what you touched; escalate only when the change surface demands it. 1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck. -2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios. Linux PR CI pins replay mode and compares the committed goldens; it never records or refreshes. Use `DSH_SNAPSHOT=refresh` locally after an intentional conversation-UI change, or `DSH_SNAPSHOT=record` with a key to re-record fixtures. -3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit. +2. **Any change that can alter the assembled browser or visible conversation/UI output** (client components or copy, `apps/web`, Vite, `dsh-host-webserver`, connection/handler/SSE) — additionally `DSH_SNAPSHOT=replay pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios. Linux PR CI uses the same read-only replay mode. Use `DSH_SNAPSHOT=refresh` only after confirming an intentional output change, or `DSH_SNAPSHOT=record` with a key to re-record fixtures. +3. **Before a PR** — use [dsh-pre-push-checks](../../.agents/skills/dsh-pre-push-checks/SKILL.md) to select the narrow checks for the outgoing diff; there is no repo-wide pre-push aggregate. If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep. @@ -97,5 +97,5 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is the l 2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. 3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery. 4. Tokens only in CSS; Chinese product copy; English comments. -5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface). +5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`. 6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend. diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 7126316e4b..9a67e64929 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -57,6 +57,7 @@ function withEnv(name: string, value: string | undefined, action: () => T): T describe('gate graph validation', () => { it.each([ 'ci-primary', + 'ci-linux-primary', 'ci-static', 'ci-lint', 'ci-coverage', @@ -166,6 +167,19 @@ describe('Node 24 consumer graph', () => { }) }) +describe('Linux primary graph', () => { + it('adds the same compare-only web gate after built client artifacts', () => { + const subject = withPnpmEntrypoint(() => gatesForMode('ci-linux-primary')) + const web = subject.find(item => item.id === 'web-snapshot') + + expect(web).toMatchObject({ + displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', + env: { DSH_SNAPSHOT: 'replay' }, + needs: ['built-package-invariants'], + }) + }) +}) + describe('gate process outcomes', () => { it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => { const result = await runGate(gate('terminated', { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 5fd444de87..d4cc5cb557 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -13,6 +13,7 @@ import { performance } from 'node:perf_hooks' /** A named aggregate exposed by the gate runner. */ export type Mode = | 'ci-primary' + | 'ci-linux-primary' | 'ci-static' | 'ci-lint' | 'ci-coverage' @@ -97,6 +98,7 @@ async function main(args: string[]): Promise { function parseMode(raw: string | undefined): Mode { switch (raw) { case 'ci-primary': + case 'ci-linux-primary': case 'ci-static': case 'ci-lint': case 'ci-coverage': @@ -112,7 +114,7 @@ function parseMode(raw: string | undefined): Mode { return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`, ) } } @@ -190,6 +192,8 @@ export function gatesForMode(selected: Mode): Gate[] { switch (selected) { case 'ci-primary': return ciPrimaryGates() + case 'ci-linux-primary': + return [...ciPrimaryGates(), webSnapshotGate(['built-package-invariants'])] case 'ci-static': return ciStaticGates() case 'ci-lint': From d9fa79a1e1d52d872ce4a4436d919205c02c5a4b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:39:11 +0800 Subject: [PATCH 38/46] test(web): pin browser replay locale --- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +-- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- apps/web/tests/code-mode-round.e2e.ts | 4 +-- apps/web/tests/cordis-tool-round.e2e.ts | 4 +-- .../tests/details-session-lifecycle.e2e.ts | 4 +-- apps/web/tests/lifecycle-chrome.e2e.ts | 4 +-- apps/web/tests/live-interactions.e2e.ts | 4 +-- apps/web/tests/message-actions.e2e.ts | 6 ++--- apps/web/tests/navigation-panes.e2e.ts | 4 +-- apps/web/tests/question-composer.e2e.ts | 4 +-- apps/web/tests/replay-round-trip.e2e.ts | 6 ++--- apps/web/tests/seeded-history.e2e.ts | 6 ++--- apps/web/tests/sidebar-scrollbar.e2e.ts | 4 +-- apps/web/tests/skill-invocation-policy.e2e.ts | 4 +-- apps/web/tests/smoke-real.e2e.ts | 4 +-- apps/web/tests/steering.e2e.ts | 4 +-- apps/web/tests/support.ts | 17 +++++++++++- apps/web/tests/workspace-management.e2e.ts | 26 +++++++++---------- 19 files changed, 64 insertions(+), 49 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 0b1f79f342..530296f0ff 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -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 .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: b57273c1c7bb957eeb4de2cf786370c18e6e735c -2026-07-24-web-gui-browser-e2e-lane.zh.md: 53e6550b9b1ac7612908e6b464eadd40704ee05f +2026-07-24-web-gui-browser-e2e-lane.md: 97be6d5d70d12f783e2c80b1bcd546cfa4582ca8 +2026-07-24-web-gui-browser-e2e-lane.zh.md: f55b4f8011cc42aa4e0f8c11d9900525a565e1a7 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index b57273c1c7..97be6d5d70 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -28,7 +28,7 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. -Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. +Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; `settings-chrome.e2e.ts` alone leaves storage unset to cover the default Chinese state and both switch directions. ### Expected outputs diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 53e6550b9b..f55b4f8011 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -28,7 +28,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 -每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。 +每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景在客户端启动前设置 `dsh.locale=en`,使本地化的 role 定位器和预期输出统一采用明确指定的语言;只有 `settings-chrome.e2e.ts` 不预设该存储项,以覆盖默认中文状态及双向切换。 ### 预期输出 diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 9826d35f7c..f772f7eedf 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -18,7 +18,7 @@ import { captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url)) @@ -44,7 +44,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index 558fa1bfc3..e0ad2d26e1 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -12,7 +12,7 @@ import { captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url)) @@ -62,7 +62,7 @@ describe('web e2e: Cordis tools use the generic row variants', () => { }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index c4d6483245..bcdf510111 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -8,7 +8,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url)) const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) @@ -40,7 +40,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 }) await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed') browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await appFrame(page).waitFor({ timeout: 30_000 }) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index a07db275fd..d4d684b1de 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -20,7 +20,7 @@ import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -43,7 +43,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 692210b352..ca05e46a22 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -23,7 +23,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -90,7 +90,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index a453d53cbc..aa14308d43 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -12,7 +12,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url)) // Borrowed read-only: this scenario needs any settled user+assistant pair, not @@ -40,7 +40,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -75,7 +75,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) await page.getByRole('button', { - name: '选择模型,当前 deepseek-v4-flash', + name: 'Select model, current deepseek-v4-flash', }).waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 0f42d911aa..fadbef1e9e 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -18,7 +18,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') @@ -56,7 +56,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await seedSession(scaffold, raw, SEED_ID) } browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) slotErrors = [] page.on('console', (message) => { diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 8689a74fc2..ac4be25299 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -18,7 +18,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -44,7 +44,7 @@ describe('web e2e: resident question composer round trip', () => { scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 17ac5eb03b..01544ad71f 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -18,7 +18,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url)) const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) @@ -43,7 +43,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -106,7 +106,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { await expect(page.getByRole('textbox').first().isVisible()).resolves.toBe(true) expect(await page.getByText('WEB_E2E_OK', { exact: false }).count()).toBeGreaterThanOrEqual(1) await page.getByRole('button', { - name: '选择模型,当前 DeepSeek-V4-Flash', + name: 'Select model, current DeepSeek-V4-Flash', }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 80d84d7b2f..f998c974b8 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -17,7 +17,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url)) const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) @@ -49,7 +49,7 @@ describe('web e2e: seeded history renders through cold resume', () => { await seedSession(scaffold, raw, SEED_ID) } browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -125,7 +125,7 @@ describe('web e2e: seeded history renders through cold resume', () => { // This scenario deliberately leaves the LLM seam open to prove zero // model calls. History still restores the selected id, but no catalog // adapter exists to provide its presentation name. - name: '选择模型,当前 deepseek-v4-flash', + name: 'Select model, current deepseek-v4-flash', }).waitFor({ timeout: 10_000 }) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index 763787f81f..12b9588a97 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -67,7 +67,7 @@ import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-scrollbar', import.meta.url)) @@ -275,7 +275,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum browser = await chromium.launch() // Shorter than the other scenarios' 1000px so SEED_COUNT rows overflow // the list with room to spare. - page = await browser.newPage({ viewport: { width: 1680, height: 800 } }) + page = await newEnglishPage(browser, 800) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 925ff924ca..cb252c724b 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -17,7 +17,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-invocation-policy', import.meta.url)) const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') @@ -80,7 +80,7 @@ describe('web e2e: skill invocation policy through the real host', () => { scaffold = await launchWebScaffold({}) await seedSkills(scaffold.workspaceCwd) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index aceb002d05..a3cd7f1b00 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -24,7 +24,7 @@ import { pathToFileURL } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { REPO_ROOT, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts' +import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts' function waitForReadyLine(child: ChildProcess): Promise { return new Promise((resolveReady, reject) => { @@ -376,7 +376,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke ) baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1') browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) page.on('pageerror', e => pageErrors.push(String(e))) await page.goto(baseUrl, { waitUntil: 'load' }) }, 120_000) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index dc1bc657ad..641d692714 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -23,7 +23,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') @@ -67,7 +67,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 5521d984da..aded1aa406 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -2,13 +2,28 @@ import { existsSync, mkdirSync } from 'node:fs' import { createServer } from 'node:net' import { fileURLToPath } from 'node:url' -import type { Page } from 'playwright' +import type { Browser, Page } from 'playwright' /** The built page under test; `pnpm run test:web` rebuilds it before running. */ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url)) export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** + * Open the standard browser-test page with English selected before client + * boot. This keeps role locators and goldens deterministic across localized + * component migrations; the settings locale scenario deliberately bypasses + * this helper to cover the product's default Chinese state. + * @param browser - Playwright browser owning the page. + * @param height - Viewport height; width is fixed to the lane baseline. + * @returns the initialized page. + */ +export async function newEnglishPage(browser: Browser, height = 1000): Promise { + const page = await browser.newPage({ viewport: { width: 1680, height } }) + await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') }) + return page +} + /** Fail loud on a stale checkout instead of testing yesterday's bundle. */ export function requireDist(): void { if (!existsSync(DIST_INDEX)) { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index ff9d458280..0e58f5188c 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -16,7 +16,7 @@ import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', import.meta.url)) // The seed is another scenario's committed fixture, reused read-only: this @@ -42,12 +42,12 @@ describe('web e2e: workspace management (create / rename / flat view / hover car const agentsBefore = scaffold.ctx.agents.list().length await page.getByRole('button', { name: 'Create workspace' }).click() await page.getByRole('menuitem', { name: 'Open local folder…' }).click() - const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '编辑路径' }).click() - await dialog.getByLabel('编辑路径').fill(path) - await dialog.getByLabel('编辑路径').press('Enter') - await dialog.getByRole('button', { name: '打开' }).click() + await dialog.getByRole('button', { name: 'Edit path' }).click() + await dialog.getByLabel('Edit path').fill(path) + await dialog.getByLabel('Edit path').press('Enter') + await dialog.getByRole('button', { name: 'Open' }).click() await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(path), @@ -72,7 +72,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -363,15 +363,15 @@ describe('web e2e: workspace management (create / rename / flat view / hover car try { await page.getByRole('button', { name: 'Create workspace' }).click() await page.getByRole('menuitem', { name: 'Open local folder…' }).click() - const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '编辑路径' }).click() - await dialog.getByLabel('编辑路径').fill(staged) - await dialog.getByLabel('编辑路径').press('Enter') + await dialog.getByRole('button', { name: 'Edit path' }).click() + await dialog.getByLabel('Edit path').fill(staged) + await dialog.getByLabel('Edit path').press('Enter') await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE) - await dialog.getByRole('button', { name: '取消' }).click() + await dialog.getByRole('button', { name: 'Cancel' }).click() await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) } finally { if (realHome === undefined) delete process.env.HOME @@ -406,7 +406,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car // card; no aria role — text anchors are the stable selector). await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1) // Leaving the anchor closes it with no delay. - await page.getByRole('button', { name: '设置' }).hover() + await page.getByRole('button', { name: 'Settings' }).hover() await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) From 2fa26cd1402dd19cc8a7f91bd792479411b2e27d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:39:40 +0800 Subject: [PATCH 39/46] test(web): refresh localized expected outputs --- .../snapshots/code-mode-round/ui.expected.md | 4 ++-- .../snapshots/cordis-tool-round/ui.expected.md | 4 ++-- .../snapshots/fresh-round-trip/ui.expected.md | 4 ++-- .../snapshots/lifecycle-chrome/hero.expected.md | 6 +++--- .../lifecycle-chrome/reloaded.expected.md | 4 ++-- .../live-interactions/cancel.expected.md | 4 ++-- .../live-interactions/error-auth.expected.md | 4 ++-- .../live-interactions/retry.expected.md | 4 ++-- .../snapshots/message-actions/ui.expected.md | 4 ++-- .../question-composer/answered.expected.md | 4 ++-- .../snapshots/question-composer/ui.expected.md | 14 +++++++------- .../snapshots/seeded-history/ui.expected.md | 4 ++-- .../skill-invocation-policy/menu.expected.md | 2 +- .../snapshots/steering/mid-steer.expected.md | 14 +++++++------- .../tests/snapshots/steering/settled.expected.md | 4 ++-- .../directory-browser.expected.md | 16 ++++++++-------- 16 files changed, 48 insertions(+), 48 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index ff53c0c23a..2851460265 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -37,11 +37,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 4af132b7a2..845d66c71f 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -61,11 +61,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index b25ff5487c..551141018e 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -34,11 +34,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index e357ebcdd8..0fff304821 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -17,9 +17,9 @@ - img - text: workspace 1 session - treeitem "New Session now" [selected] -- button "设置": +- button "Settings": - img - - text: 设置 + - text: Settings - text: Let's start building - button "Choose workspace": - img @@ -29,7 +29,7 @@ - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 8058490303..1065c5de3a 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -21,11 +21,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4b584df800..9c9f898e26 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -18,11 +18,11 @@ - button "在新对话中分支": - img - text: {{clock}} 0 tokens · 1 turns · 1 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 30233ef5cb..274b2b3132 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -11,11 +11,11 @@ - img - button "编辑": - img -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index c3160c1b51..1bc7c6c3c0 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -21,11 +21,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 99% · 7,869 tokens · 1 turns · 1 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 748f0e1109..33b2402d50 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -37,11 +37,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 deepseek-v4-flash": +- button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 443636d80d..e6f6d44853 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -34,11 +34,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 95% · 8,769 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md index 934ec244d5..a1c9dfac83 100644 --- a/apps/web/tests/snapshots/question-composer/ui.expected.md +++ b/apps/web/tests/snapshots/question-composer/ui.expected.md @@ -2,11 +2,11 @@ - text: Pick one - heading "Which color do you prefer?" [level=2] - text: 1 / 1 - - button "上一题" [disabled]: + - button "Previous question" [disabled]: - img - - button "下一题" [disabled]: + - button "Next question" [disabled]: - img - - button "放弃整组问题": + - button "Dismiss all questions": - img - radiogroup: - radio "Blue": @@ -15,9 +15,9 @@ - radio "Green": - text: 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - img - - button "其他,请填写自定义答案": + - button "Other — enter a custom answer": - img - - text: 其他,请填写自定义答案 + - text: Other — enter a custom answer - status - - button "跳过本题" - - button "提交" [disabled] + - button "Skip this question" + - button "Submit" [disabled] diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index c54c6ac3f5..4da2e8c0c9 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -36,11 +36,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 deepseek-v4-flash": +- button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md index ba62c84f91..11acc39ad0 100644 --- a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md +++ b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md @@ -1,3 +1,3 @@ - listbox "Trigger suggestions": - - text: 技能 + - text: Skills - option "policy-shared Available to both model and user invocation" [selected] diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index eb5d00dea4..7a92c09b4a 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -28,11 +28,11 @@ - text: Checkpoint - heading "Ready to continue?" [level=2] - text: 1 / 1 - - button "上一题" [disabled]: + - button "Previous question" [disabled]: - img - - button "下一题" [disabled]: + - button "Next question" [disabled]: - img - - button "放弃整组问题": + - button "Dismiss all questions": - img - radiogroup: - radio "Yes": @@ -41,9 +41,9 @@ - radio "No": - text: 2 No - img - - button "其他,请填写自定义答案": + - button "Other — enter a custom answer": - img - - text: 其他,请填写自定义答案 + - text: Other — enter a custom answer - status - - button "跳过本题" - - button "提交" [disabled] + - button "Skip this question" + - button "Submit" [disabled] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 08a230af9a..11d103cb6e 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -34,11 +34,11 @@ - button "在新对话中分支": - img - text: {{clock}} cache hit 98% · 15,967 tokens · 1 turns · 2 steps -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md index 7fbcd35d75..4bbd790afa 100644 --- a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -1,10 +1,10 @@ -- dialog "选择工作区目录": - - heading "选择工作区目录" [level=2] +- dialog "Select Workspace Directory": + - heading "Select Workspace Directory" [level=2] - navigation: - - button "主目录" + - button "Home" - img - button "browse-golden" - - button "编辑路径" + - button "Edit path" - list: - listitem: - button "alpha": @@ -16,8 +16,8 @@ - img - text: beta - img - - button "新建文件夹": + - button "New folder": - img - - text: 新建文件夹 - - button "取消" - - button "打开" + - text: New folder + - button "Cancel" + - button "Open" From e80d7bce170b0a5c2c7344c5ea98fe2be5ff592e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:50:12 +0800 Subject: [PATCH 40/46] test(web): refresh message action expected outputs --- .../snapshots/code-mode-round/ui.expected.md | 5 ----- .../snapshots/cordis-tool-round/ui.expected.md | 15 --------------- .../snapshots/fresh-round-trip/ui.expected.md | 5 ----- .../snapshots/message-actions/ui.expected.md | 5 ----- .../question-composer/answered.expected.md | 5 ----- .../tests/snapshots/seeded-history/ui.expected.md | 5 ----- .../snapshots/steering/mid-steer.expected.md | 5 ----- .../tests/snapshots/steering/settled.expected.md | 5 ----- 8 files changed, 50 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 2851460265..43ca98bacd 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -15,11 +15,6 @@ - img - img - text: "Think The user wants me to write a single `run_code` program that:" -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 845d66c71f..9a81500afa 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -15,11 +15,6 @@ - img - img - text: "Think The user wants me to:" -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img @@ -28,11 +23,6 @@ - img - img - text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code." -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button [expanded]: - img - text: Mount temporary Plugin typescript @@ -42,11 +32,6 @@ - img - img - text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id." -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 551141018e..78c1a6563c 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -15,11 +15,6 @@ - img - img - text: Think The user wants me to run a simple bash command and reply with "DONE". -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - img - text: Bash Echo the test string 已完成 workspace echo WEB_E2E_OK - button "复制" diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 33b2402d50..a5f1f83c72 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -16,11 +16,6 @@ - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - img - text: Read - button "a.txt" diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index e6f6d44853..c1ec5b7262 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -15,11 +15,6 @@ - img - img - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that. -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 4da2e8c0c9..247f6112ca 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -15,11 +15,6 @@ - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - img - text: Read - button "a.txt" diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 7a92c09b4a..59328ee1e7 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -15,11 +15,6 @@ - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 11d103cb6e..3d1788582e 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -15,11 +15,6 @@ - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. -- button "复制": - - img -- button "在新对话中分支": - - img -- text: {{clock}} - button: - img - img From 876d1947fe671e750651c79874bb41c780bdab08 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:56:54 +0800 Subject: [PATCH 41/46] test(web): pin queue replay locale --- apps/web/tests/queue-actions.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index be5e73f9a6..1df3ffc247 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -16,7 +16,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.meta.url)) const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) @@ -63,7 +63,7 @@ describe('web e2e: queue row actions', () => { scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) const tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) From 23fb779f7fac6e0abc44a08037e863a8cce49eba Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:57:00 +0800 Subject: [PATCH 42/46] test(web): refresh queue expected outputs --- apps/web/tests/snapshots/queue-actions/editing.expected.md | 4 ++-- apps/web/tests/snapshots/queue-actions/ui.expected.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 6a17d57df5..311c961450 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -25,11 +25,11 @@ - img - button "取消编辑": - img -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Stop generating" diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 05d3e3b347..f56d4e1970 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -19,11 +19,11 @@ - img - button "删除排队消息": - img -- textbox "给智能体发消息" +- textbox "Message the agent" - button "Add attachment": - img - 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "选择模型,当前 DeepSeek-V4-Flash": +- button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img - button "Stop generating" From 9035b1b47470ed0a926cf0d5f579778cc25d86e0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:01:31 +0800 Subject: [PATCH 43/46] docs(testing): record browser gate timing --- .../testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml | 4 ++-- .../testing/2026-07-30-web-browser-snapshot-ci-gate.md | 2 ++ .../testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml index dec7f26e7f..d16d412559 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml @@ -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 .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md -2026-07-30-web-browser-snapshot-ci-gate.md: b4e78e72d6eadacb18fb0e22d9c6d28a01211717 -2026-07-30-web-browser-snapshot-ci-gate.zh.md: d2383ca9c7a074bffb2f34f736541baaa9e04df9 +2026-07-30-web-browser-snapshot-ci-gate.md: 3f87bb0f3d936bcee7ba7c3d84ae808c6ede1a97 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: af563f0e2a1c20f7b53d371e97e41b7ffa52a1d1 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md index b4e78e72d6..3f87bb0f3d 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -18,6 +18,8 @@ Local `pnpm run test:web` continues to build first and then run the full browser For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The hosted and self-hosted default-branch Linux serial aggregates also include the comparison, while the macOS and Windows serial jobs remain browser-free. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. +An observed self-hosted consumer run measured `web-snapshot` at 112.15 seconds and the full consumer aggregate at 114.97 seconds. The gate scheduler starts it as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule. + ## Alternatives considered **Continue requiring only local runs.** Rejected: execution depends on developer memory, which is precisely why stale goldens drift across PRs, and cannot guarantee that the PR introducing a behavior change carries its own expected-output diff. diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md index d2383ca9c7..af563f0e2a 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -18,6 +18,8 @@ Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览 对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不供给 Chromium。托管和自托管的默认分支 Linux 串行聚合作业也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。 +一次自托管消费方运行中,`web-snapshot` 实测耗时 112.15 秒,完整消费方聚合实测耗时 114.97 秒。gate 调度器会在 `built-package-invariants` 成功后立即启动它,并发运行彼此独立的 gate,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。 + ## 曾考虑的替代方案 **继续只要求本地运行。** 已否决:执行依赖开发者记忆,正是旧 golden 跨 PR 漂移的原因,不能保证产生行为变化的 PR 自己携带 expected diff。 From 291fe1e823fadc91b23d0c8901b3fa2aaa322be5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:15:57 +0800 Subject: [PATCH 44/46] test(web): refresh composer stats expected outputs --- apps/web/tests/snapshots/code-mode-round/ui.expected.md | 3 ++- apps/web/tests/snapshots/cordis-tool-round/ui.expected.md | 3 ++- apps/web/tests/snapshots/fresh-round-trip/ui.expected.md | 3 ++- apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md | 3 ++- apps/web/tests/snapshots/live-interactions/cancel.expected.md | 3 ++- apps/web/tests/snapshots/live-interactions/retry.expected.md | 3 ++- apps/web/tests/snapshots/message-actions/ui.expected.md | 3 ++- .../web/tests/snapshots/question-composer/answered.expected.md | 3 ++- apps/web/tests/snapshots/seeded-history/ui.expected.md | 3 ++- apps/web/tests/snapshots/steering/settled.expected.md | 3 ++- 10 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 43ca98bacd..391d07fc2d 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -31,7 +31,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -40,3 +40,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 52% Input 17.2K tok · Output 252 tok diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 9a81500afa..faf22422a5 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -45,7 +45,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -54,3 +54,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 4 steps Tool call {{duration}} Cache hit 77% Input 66.5K tok · Output 312 tok diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 78c1a6563c..d908af87af 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -28,7 +28,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -37,3 +37,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 99% Input 15.7K tok · Output 111 tok diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 1065c5de3a..e4a14a77a8 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -20,7 +20,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -29,3 +29,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 1 steps Cache hit 99% Input 7.8K tok · Output 21 tok diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 9c9f898e26..ac9a1ad53c 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -17,7 +17,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} 0 tokens · 1 turns · 1 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -26,3 +26,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 1bc7c6c3c0..48afc76970 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -20,7 +20,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 99% · 7,869 tokens · 1 turns · 1 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -29,3 +29,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 1 steps Cache hit 99% Input 7.8K tok · Output 79 tok diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index a5f1f83c72..1d14d00143 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -31,7 +31,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -40,3 +40,4 @@ - text: deepseek-v4-flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index c1ec5b7262..5ba842114e 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -28,7 +28,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 95% · 8,769 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -37,3 +37,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 95% Input 8.6K tok · Output 180 tok diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 247f6112ca..428fa5b526 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -30,7 +30,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -39,3 +39,4 @@ - text: deepseek-v4-flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 3d1788582e..e0f6df36fb 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -28,7 +28,7 @@ - img - button "在新对话中分支": - img -- text: {{clock}} cache hit 98% · 15,967 tokens · 1 turns · 2 steps +- text: {{clock}} - textbox "Message the agent" - button "Add attachment": - img @@ -37,3 +37,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] +- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 156 tok From c0b8fea4c834657adf4294e372c16aecd360ff66 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 30 Jul 2026 14:45:07 +0800 Subject: [PATCH 45/46] fix(ui): contain trajectory scrolling and overlays --- apps/web/tests/live-interactions.e2e.ts | 23 +++++++++++++++++++ .../src/client/TrajectoryTable.module.css | 5 ++++ .../src/client/TrajectoryTimeline.module.css | 3 +++ .../ui-trajectory/src/client/views.module.css | 16 +++++++++---- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 692210b352..fb1f825106 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -175,6 +175,29 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { expect(tripwire.warnings).toEqual([]) }, 120_000) + it.skipIf(MODE === 'record')('keeps a terminal request marker inside the trajectory table', async () => { + await launch(() => ({ + patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'invalid api key', code: 'AUTH' } }], + })) + const { settled } = await sendPrompt() + await settled + await page.getByRole('tab', { name: 'Trajectory' }).click() + const tailRequest = page.locator('tr[data-request-only="true"]').last() + await tailRequest.waitFor({ timeout: 10_000 }) + const requestMarker = tailRequest.getByRole('button', { name: /Request #/ }) + + const markerWithinTable = await requestMarker.evaluate((element) => { + const marker = element.getBoundingClientRect() + const table = element.closest('table')?.getBoundingClientRect() + if (table === undefined) throw new Error('request marker has no table') + return marker.bottom <= table.bottom + }) + + expect(markerWithinTable).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 120_000) + it.skipIf(MODE === 'record')('recovers a transient SERVER failure through llm-retry and completes', async () => { const derived = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8'))) expect(derived).toHaveLength(1) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 1b30318f68..3e0e96b838 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -103,6 +103,11 @@ border-bottom: 0; } +.table tbody tr[data-request-only='true']:last-child td { + /* Retain the lower half of the 16px boundary marker at the table's end. */ + height: 9px; +} + .table tbody tr[data-request-only='true'] .turnRail { top: -15px; bottom: 0; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index a2cac22a71..734b2f3326 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -1,4 +1,7 @@ .root { + position: relative; + z-index: 0; + isolation: isolate; flex: none; border-bottom: 1px solid var(--dsw-alias-border-l2); user-select: none; diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 14b4ce6ac2..3dd80c077a 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -13,11 +13,17 @@ background: var(--dsw-alias-bg-layer-1); } -/* Under the active conversation column (`[data-conversation-scroll]`) the - * parent owns overflow so the sticky composer stays in the same scrollport. */ -:global([data-conversation-scroll]) .root { - overflow: visible; - height: auto; +/* Trajectory keeps the ledger and details panel inside the remaining + * conversation height. Only the ledger pane scrolls; the composer remains + * the fixed flex sibling below this view. */ +:global([data-conversation-scroll]):has(.root) { + overflow: hidden; +} + +:global([data-conversation-scroll]):has(.root) > :first-child { + flex: 1 1 0; + min-height: 0; + overflow: hidden; } .ledger { From b21f13baa14673e7587dfe237e2f2a854168ff56 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:21:42 +0800 Subject: [PATCH 46/46] test: update tool agent fixtures for inbox API --- packages/fs/tool-str-replace-editor/tests/tools.spec.ts | 1 + .../pty/tool-bash-persistent/tests/loader-composition.spec.ts | 1 + packages/pty/tool-bash-persistent/tests/tools.spec.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 9a948ed164..0a3b65ba5e 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -39,6 +39,7 @@ function agent(ctx: Context, cwd: string): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index b8586161f2..9e18402477 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -49,6 +49,7 @@ function agent(ctx: Context, cwd: string): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 4b5401a256..9949879292 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -45,6 +45,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), }