mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge commit '881f7fe696c64e7775572680a38af07be6e9d158' into codex/subprocess-win32-process-primitives
This commit is contained in:
+2
-2
@@ -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-07-tool-call-timeout-policy.md
|
||||
2026-07-07-tool-call-timeout-policy.md: ce414e541f8e374dd48e46d68cb00121e0004247
|
||||
2026-07-07-tool-call-timeout-policy.zh.md: 6fe3c979a3c4e7b7a6ed803a47af45ad32d52cce
|
||||
2026-07-07-tool-call-timeout-policy.md: 92618cc8c761b38d7e516c9d00eb3de1c37831a8
|
||||
2026-07-07-tool-call-timeout-policy.zh.md: cc633ceaa3840331826f6475e603e78a98f0afd2
|
||||
|
||||
@@ -77,7 +77,7 @@ No new session event is needed for reconstructability: `TOOL_TIMEOUT` is the fin
|
||||
|
||||
### Existing tool adaptation
|
||||
|
||||
`web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` dropped its `timeout_ms` parameter to match the reference-agent shape, and `web_search` stays query-only. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`.
|
||||
`web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` has no `timeout_ms` parameter, while `web_search` accepts a required `queries` array without a timeout argument. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`.
|
||||
|
||||
`dsh-web-fetch-http` keeps one configured provider-level `timeoutMs` as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls.
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
|
||||
|
||||
### 现有工具适配
|
||||
|
||||
`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 移除了 `timeout_ms` 参数以匹配参考 agent(智能体)的形状,`web_search` 保持仅查询。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。
|
||||
`web_fetch` 和 `web_search` 已迁移。`dsh-tool-web` 保留对其面向模型 schema 的所有权,这些 schema 不暴露超时旋钮:`web_fetch` 没有 `timeout_ms` 参数,`web_search` 接受必填的 `queries` 数组,但不接受超时参数。工具体不导入 `@deepseek-ai/dsh-timeout`;它们将 `exec.signal` 转发给 `ctx.web`。
|
||||
|
||||
`dsh-web-fetch-http` 保留一个在提供方层面配置的 `timeoutMs`,作为较大的资源兜底值,服务于直接调用 `ctx.web.fetch()` 的调用方和配置错误的部署;它不拥有面向模型的超时。当 `TOOL_TIMEOUT` 信号先到达 fetch 提供方时,提供方作用域的分类将其视为上游 `WEB_ABORTED`,而外层 `tools/execute` 包装器将最终工具结果替换为 `TOOL_TIMEOUT`。一个已发布的 web 工具部署将提供方兜底配置为高于 `timeout-policy` 预算,使工具调用策略在模型调用中通常胜出。
|
||||
|
||||
|
||||
+2
-2
@@ -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: fa2f86893b730aa1ba020bd568d268ec8d9d6239
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 509bec18edb9923dd4d60d4ecf30d4fbcd9cc6d5
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 54030fa4b0742fbc282bc327b0ca22747e6a20bd
|
||||
|
||||
+7
-5
@@ -36,19 +36,21 @@ Config discovery has two channels and fails loudly when both are missing: the `D
|
||||
|
||||
Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails.
|
||||
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
|
||||
The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call.
|
||||
|
||||
### 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/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-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-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and 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 gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry.
|
||||
[`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/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-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-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. 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 gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry.
|
||||
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. 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 both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. 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 `python-v<repository-version>` 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 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-sdk-jsonrpc-demo/lib/packaged-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 with its required `-rg` sidecar and optional macOS 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-sdk-jsonrpc-demo/lib/packaged-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 `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v<repository-version>` 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. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. 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 the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms.
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v<repository-version>` 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. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched `-rg` sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, 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-sdk-jsonrpc-server` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`.
|
||||
|
||||
@@ -62,7 +64,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, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. 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, agent, workflow-run, and session IDs across 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, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. 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, agent, workflow-run, and session IDs across 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.
|
||||
|
||||
|
||||
+7
-5
@@ -36,19 +36,21 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
|
||||
|
||||
exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。
|
||||
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
|
||||
部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。
|
||||
|
||||
### 构建流水线与产物
|
||||
|
||||
[`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/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。
|
||||
[`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/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。
|
||||
|
||||
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 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 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
|
||||
Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v<repository-version>` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v<repository-version>` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。
|
||||
|
||||
exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-sdk-jsonrpc-server` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。
|
||||
|
||||
@@ -62,7 +64,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread`
|
||||
|
||||
## 测试
|
||||
|
||||
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。
|
||||
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。
|
||||
|
||||
|
||||
手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。
|
||||
|
||||
@@ -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/bug-fix/2026-08-06-plan-narrow-viewport-regression.md
|
||||
2026-08-06-plan-narrow-viewport-regression.md: 945d014e0c51cbaf4080e72f50ee60763d851698
|
||||
2026-08-06-plan-narrow-viewport-regression.zh.md: 37060129364c65b02cc1729331fc7334797863db
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: narrow-viewport plan chip click-area regression test
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-06-plan-narrow-viewport-regression.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The external report dsh-external/issues#107 (clustered internally as deepseek-harness#1406) measured that at viewports between 760px and 850px the plan control and the model selector overlapped, with the model selector covering the plan control's click area so plan mode could not be left by mouse at 800×720. Its acceptance list asked for a browser regression test asserting that the plan center hit-tests to the plan button.
|
||||
|
||||
The browser regression test reproduced the report on current master: at 800×720 the plan chip and the model trigger overlapped by 36.9px and the chip's center hit-tested to the trigger's label. The composer control row is `display: flex; justify-content: space-between` with `.trailing { flex: none }`: when the combined control width exceeds the card, the shrinking `.tools` group keeps its flow children inside its `min-width: 0` box, so the chip — the last flow child before the overflow — is painted over the trailing group. The plan-control form changed since the report (select → chip, `c20b988166`/`fe91919346`) and the row gained adaptive behavior (`c8c75ec891`, [web-composer-shared-width-axis](../feature/2026-08-04-web-composer-shared-width-axis.md)), but the row had no wrap, so the overlap survived both.
|
||||
|
||||
## Decision
|
||||
|
||||
The row wraps instead of shrinking its left group into the right group's area: `.row { flex-wrap: wrap }` plus `margin-left: auto` on `.trailing`, which re-anchors the trailing group (model + send) to the right edge of its wrapped line while `space-between` already pins it right on a single line. Wrapping is the acceptance's "wrap, fold, or re-arrange controls when space runs out" option, keeps every control at full width (no label folding that would hide the model name or the Plan wordmark), and holds at every viewport width by construction instead of at a calibrated container-query threshold.
|
||||
|
||||
Add `apps/web/tests/plan-control-row.e2e.ts`: enter plan mode with the real `/plan` command (no argument — the command handler commits plan/mode active without a model round, the lifecycle-chrome precedent), so the test needs no model call in any mode and no API key in replay/refresh; a providers-only fixture mounts the model catalog without a script to consume. The file joins the host-plane e2e pairing like every sibling: excluded from the client graph in `apps/web/tsconfig.json` (it imports host-plane types) AND included in the host aggregate in `tsconfig.host.json`, so exactly one TypeScript program owns it — the pairing that also gives the lint type service its program.
|
||||
|
||||
The geometry golden records stable facts — viewport membership on both axes and disjoint click areas — never absolute coordinates, whose pixel values depend on installed fonts and differ between macOS and Linux. The behavior assertions implement the acceptance directly: the click areas are disjoint, the click at the chip's center (Playwright's actionability check) leaves plan mode through the real command channel (`/plan off` via `commands.execute`), and the last `plan/mode` event in the session log flips inactive.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Seed a cold session (composer-tab-geometry pattern).** Rejected: the exit path executes `/plan off` through `commands.execute`, which needs the live agent a cold seeded session does not have; `connectFreshWorkspace` keeps one, matching the product's user path.
|
||||
|
||||
**Pin absolute bounding boxes in the golden.** Rejected: chip and trigger widths depend on the installed fonts, so absolute coordinates would churn across platforms without a behavior change.
|
||||
|
||||
**Reuse the plan-review fixture shape (exit_plan_mode review takeover).** Rejected: the takeover replaces the composer's control row, which is the surface under test.
|
||||
|
||||
**Container-query label folding for the chip and/or the model trigger.** Rejected for the fix: two packages (ui-plan, ui-model) would need calibrated thresholds and the chip's own icon-only fold still leaves ~7px of overlap at the reported viewport unless the trigger folds too. Wrapping is one rule in one package and holds at every width.
|
||||
|
||||
## Consequences
|
||||
|
||||
Any future change to the control row layout — fonts, gaps, media or container queries — that re-introduces overlap or moves the chip out of the viewport on either axis fails this test. The test needs no API key in replay/refresh modes: plan mode toggles through the command handler without a model round, and a providers-only replay fixture (no recorded script, consumption check skipped) mounts the model directory so the trigger renders its real long label — the width that made the reported overlap measurable; the test asserts that label before measuring. The golden is compared in replay and record modes and rewritten in refresh mode.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 窄视口下 Plan chip 点击区域回归测试
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-06-plan-narrow-viewport-regression.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
外部报告 dsh-external/issues#107(内部聚类为 deepseek-harness#1406)测得视口宽度在 760px 到 850px 之间时 Plan 控件与模型选择器发生重叠,模型选择器覆盖 Plan 控件的点击区域,导致在 800×720 下无法用鼠标退出 Plan 模式。其验收清单要求增加浏览器回归测试,断言 Plan 中心命中 Plan 按钮。
|
||||
|
||||
浏览器回归测试在当前 master 上复现了报告:800×720 下 Plan chip 与模型 trigger 重叠 36.9px,chip 中心命中 trigger 的 label。composer 控制行是 `display: flex; justify-content: space-between` 且 `.trailing { flex: none }`:当控件总宽超过卡片时,可收缩的 `.tools` 组把流内子项留在 `min-width: 0` 的盒内,于是 chip——溢出前最后一个流内子项——被绘制到 trailing 组上方。报告以来 Plan 控件形态已变(select → chip,`c20b988166`/`fe91919346`),控制行也获得过自适应能力(`c8c75ec891`,[web-composer-shared-width-axis](../feature/2026-08-04-web-composer-shared-width-axis.md)),但该行没有换行,重叠在两次重构后依然存在。
|
||||
|
||||
## 决策
|
||||
|
||||
控制行换行而不是把左侧组收缩进右侧组的区域:`.row { flex-wrap: wrap }` 加上 `.trailing` 的 `margin-left: auto`——后者把 trailing 组(模型选择 + 发送)重新锚定到换行后的右缘,单行时 `space-between` 已把它钉在右侧。换行是验收中"空间不足时允许换行、折叠或重新排列控件"的选项,保持每个控件全宽(不做会隐藏模型名或 Plan 字样的 label 折叠),并且按构造在所有视口宽度下成立,而非依赖标定的容器查询阈值。
|
||||
|
||||
新增 `apps/web/tests/plan-control-row.e2e.ts`:通过真实 `/plan` 命令(无参数——命令 handler 不经模型回合即提交 plan/mode active,lifecycle-chrome 先例)进入 Plan 模式,因此测试在任何模式下都无需模型调用,仅在 replay/refresh 下无需 API key;providers-only fixture 挂载模型目录而无脚本可消费。该文件与所有同类 host 平面 e2e 一样采用成对登记:在 `apps/web/tsconfig.json` 的 exclude 列表(它导入 host 平面类型,client 图绝不编译它),同时在 `tsconfig.host.json` 的 host 聚合 include 中——恰好一个 TypeScript 程序拥有它,这也是 lint 类型服务获得程序的配对方式。
|
||||
|
||||
几何 golden 记录稳定事实——两个轴上的视口内位置与点击区域不相交——绝不记录绝对坐标,其像素值依赖安装字体且在 macOS 与 Linux 间不同。行为断言直接实现验收:点击区域不相交、点击 chip 中心(Playwright 的可操作性检查)经真实命令通道(`commands.execute` 执行 `/plan off`)退出 Plan 模式,且会话日志中最后一条 `plan/mode` 事件翻转为 inactive。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**冷会话 seed(composer-tab-geometry 模式)。** 否决:退出路径经 `commands.execute` 执行 `/plan off`,需要 live agent,而冷 seed 会话没有;`connectFreshWorkspace` 保留一个,与产品的用户路径一致。
|
||||
|
||||
**golden 固定绝对 bounding box。** 否决:chip 与 trigger 宽度依赖安装字体,绝对坐标会在平台间漂移而不反映行为变化。
|
||||
|
||||
**复用 plan-review fixture 形态(exit_plan_mode review takeover)。** 否决:takeover 会替换 composer 控制行,而被测表面正是控制行。
|
||||
|
||||
**chip 与/或模型 trigger 的容器查询 label 折叠。** 否决(作为修复):两个包(ui-plan、ui-model)需要各自标定阈值,且 chip 单独折叠为 icon-only 在报告视口下仍剩约 7px 重叠,除非 trigger 也折叠。换行是一个包中的一条规则,且在所有宽度下成立。
|
||||
|
||||
## 后果
|
||||
|
||||
任何改变控制行布局的后续改动——字体、间距、媒体查询或容器查询——一旦重新引入重叠或把 chip 沿任一轴移出视口,本测试即失败。测试在 replay/refresh 模式下无需 API key:Plan 模式经命令 handler 切换,不经模型回合;providers-only replay fixture(无录制脚本,跳过消费检查)挂载模型目录,使触发器渲染真实的长标签——正是使报告重叠可测量的宽度;测试在测量前断言该标签。golden 在 replay 与 record 模式下比较,在 refresh 模式下重写。
|
||||
@@ -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/bug-fix/2026-08-13-feedback-note-editor-popover.md
|
||||
2026-08-13-feedback-note-editor-popover.md: 33b7cd84b97ceac7fef1d96f1dee279fbb215800
|
||||
2026-08-13-feedback-note-editor-popover.zh.md: b130ab1e9c66372cf2a52bc5e12f65027c64cf51
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: The feedback note editor floats above the transcript in a popover
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-13-feedback-note-editor-popover.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web surface for message feedback ([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262)) contributes its controls to `conversation.chat.assistant-actions`, which renders inside the finalized assistant message's shared IconActions row. That row was one fixed-height `flex` line with `flex-wrap` at its initial `nowrap` and `height: 28px`, sized for 28px icons and a clock. The note editor mounted into it as an inline group holding a `width: 260px` textarea plus Save and Cancel.
|
||||
|
||||
A 260px input and two buttons do not fit that line at any window size. Measured against the shipped bundle, the row's scrollable overflow with the editor open was 168px at a 1680px viewport and 444px at 600px — the defect was never a narrow-window edge case, it was present at full-screen desktop. Flex overflow spills past the end of the line, so the items after the editor in flex order were the ones pushed out of the conversation column: the branch action left the column at 600px, and the clock and its run/TTFT/throughput readings left it at 900px. Those controls stay hit-testable while invisible, so no behavioral assertion noticed; the shipped e2e covered rate, note, reload, and retract, and the 24 UI snapshots are width-independent DOM.
|
||||
|
||||
The same stylesheet also named four `--dsw-alias-*` tokens that the theme does not define: `border-secondary`, `bg-primary`, `interactive-bg-primary`, and `label-inverse`. An undefined custom property makes its whole declaration invalid at computed-value time, so the textarea shipped with no border and no surface, and Save with neither fill nor a readable label — the editor read as loose text floating in the transcript rather than as an input.
|
||||
|
||||
## Decision
|
||||
|
||||
The note editor does not enter the row's flex layout at all. It is a popover: a fixed-position panel, portaled to `document.body`, whose coordinates come from the note trigger's rect. The row keeps its single line of icons and the note trigger, so nothing has to shrink, wrap, or reflow around the editor, and no `order` or wrapping is needed anywhere. Portaling out of the conversation column also escapes its `overflow` clip, so the panel cannot be cropped at the scroll edge and it moves with the message it annotates when the transcript scrolls. This reuses the same portal mechanism `ui-primitives/Menu` uses for anchored menus (`ui-subagent`'s catalog popover is built on it): the panel is `position: fixed`, placed from the anchor rect on open, clamped inside the viewport, and re-placed on scroll (capture phase) and resize. That anchoring is shared rather than copied: `ui-primitives/useAnchoredPosition` owns measure-offset-clamp-and-track, and the duplication gate is what forced the extraction — an inline copy of the clamp and its listener pair reported a 10-line clone against `Menu`. `Menu` keeps its own effect because its placement also resolves `side`/`align` variants and an optional caller-supplied anchor rect, which this surface does not need; the hook covers the plain below-the-anchor case both would otherwise spell out.
|
||||
|
||||
**The action strip.** The like/dislike buttons and the note trigger stay in the row, unchanged. The trigger is a plain button (`aria-haspopup="dialog"`, `aria-expanded` while open) that shows "Add a note" before a note exists and the note text afterward.
|
||||
|
||||
**The popover.** While open, the panel contains the textarea plus Save and Cancel, and any note-save failure, as `role="dialog"` with a title distinct from the textarea's own label so both are addressable by name. It opens beneath the trigger (4px gap), clamps to 12px from the viewport edges, auto-focuses the textarea, and closes on Escape or an outside pointer-down. Closing returns focus to the trigger only when the panel was really open, never on the initial mount (a freshly rendered rated message must not pull focus into its action row). A rating action during an open editor closes the panel. The four undefined tokens are replaced with the ones the theme actually defines, matching the primitives' precedent: `border-l2` and `bg-layer-1` for the input, `button-primary-fill` with `label-primary-foreground` plus a `button-primary-hover` state for Save; the panel surface reuses the Menu card recipe (`--dsw-specific-menu`, `--dsw-shadow-lv3`, inverted hairline `--dsw-alias-border-inverted`, `border-radius: 12px`).
|
||||
|
||||
**Failure surfaces split by where the human is looking.** A rating or list-load failure shows beside the buttons in the row, legible whether or not the popover is open. A note-save failure shows inside the popover, next to Save/Cancel, and the panel stays open so the draft survives to be corrected.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Inline expansion on the row, the editor claiming its own line via a full-width flex basis with the row allowed to wrap** — the approach first shipped on this branch and rejected here. It fixes the geometry (the row reports zero overflow from 1680px down to 600px) but at a visible cost: the branch action and the end clock wrap below the editor while it is open, the row occupies three lines, and the interaction competes for the same horizontal strip the row already fills. That cost is what [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) reported from real use — the row reads as misaligned once the editor expands — and it asked for the popover the chat surface already uses. A popover removes the editor from the row entirely, so the strip and the keyboard tab order are untouched whether the editor is open or not.
|
||||
|
||||
**An absolutely-positioned popover not portaled out of the column** — rejected: the conversation column is an `overflow-y: auto` scroller, so a panel laid out inside it is clipped at the scroll edge and does not track the message as the column scrolls. Portaling to `document.body` with fixed placement from the trigger rect is what makes the floating panel viable, exactly as `Menu`'s portal mode and the subagent catalog popover already do.
|
||||
|
||||
**A new `belowActions` seam on `MessageIconActions`, rendering the editor as a sibling under the row** — rejected: the slot contract documents `assistant-actions` as rendering *inside* the message's IconActions row, and one entry cannot supply two render sites without widening the host contract for a presentation detail that a portaled popover already expresses without touching the host.
|
||||
|
||||
## Consequences
|
||||
|
||||
With the editor open the actions row stays a single 28px line with zero overflow and nothing outside the column at every viewport from 1680px down to 600px — because the editor is not in the row to begin with. The panel floats above the transcript inside the viewport and stays anchored to its trigger, escaping the column's overflow clip. The editor is legible as an input in both themes.
|
||||
|
||||
`apps/web/tests/message-feedback-layout.e2e.ts` sweeps six viewports with the editor open and pins, per stop, that the row reports one line and zero overflow, that the panel is outside the conversation column (proof it escapes the clip), that it lies within the viewport (proof the clamp holds), and that it sits by its trigger. A committed golden records the relations; reverting to inline (or dropping the portal) fails the geometry assertions. `packages/client/ui-message-feedback/tests/styles.client.spec.ts` checks the tokens against the theme's committed source, that the panel is `position: fixed`, and that it carries no flex sizing (so it cannot rejoin the row), plus the brace balance, following the `ui-settings-models` styles-spec precedent. The unit spec covers rate, note, reload, retract, plus the popover's portal-to-body, Escape/outside-click dismissal, and keep-open-on-inside-click.
|
||||
|
||||
The `ui-message-feedback` package adds `@types/react-dom` so the `createPortal` usage typechecks, mirroring `ui-primitives`.
|
||||
|
||||
Known limitations are accepted rather than fixed here. A rating click while the panel is open closes it, and the close path returns focus to the note trigger rather than leaving it on the rating button the human just pressed; the same happens when an outside click lands on another focusable control, which the browser focuses before the close returns focus to the trigger. A pointer user does not notice either; a keyboard user feels the focus move. The clamp assumes the panel fits: a panel taller than the viewport makes the upper bound `innerHeight - height - margin` smaller than `margin`, so `top` goes negative and the panel's head is cut off rather than its foot. The panel's three-row textarea carries `resize: vertical`, so a human can drag past that size; `.notePanel` therefore bounds its height at `calc(100vh - 24px)` and scrolls its own content, the counterpart of the existing `max-width` and the same 12px margin the clamp uses. If the rating disappears while the editor is open, the panel unmounts on the `rating !== undefined` guard but `noteOpen` stays true, so the document-level Escape and pointer-down listeners remain attached; should the item reappear through a later resync, the panel returns with the previous draft and without refocusing the textarea. The window is one click or Escape wide, and the save failure it could hide already falls back to the row, so it is left as it is. A failure that lands after the panel was closed and reopened is not written into the new session's panel: its draft was reseeded from the stored note, so an old attempt's error would mislabel it, and the uncommitted content is already gone — the failure is dropped rather than shown. And while the placement replays on scroll, window resize, and the panel's own size changes, jsdom has no layout, so the real geometry is proven by the browser scenario while the unit spec covers the wiring through a `ResizeObserver` stub.
|
||||
|
||||
A residual narrow-viewport clock overflow remains below 520px from the clock string alone, unrelated to the feedback surface. The repo has no gate for undefined design tokens, and a scan during this work found more in `ui-agent-preset`, `ui-conversation`, `ui-jobs`, `ui-settings-plugins`, and `ui-tool`; they are untouched here and want their own change.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note:反馈备注编辑器以浮层悬浮在对话记录上方
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-13-feedback-note-editor-popover.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
消息反馈的 Web 界面([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262))把控件贡献给 `conversation.chat.assistant-actions`,该槽位渲染在已定稿助手消息共享的 IconActions 行内。那一行是单条固定高度的 `flex` 线,`flex-wrap` 保持初始值 `nowrap` 且 `height: 28px`,按 28px 图标加一个时钟来定尺寸。备注编辑器作为一个内联组挂进去,内含 `width: 260px` 的 textarea 加 Save 与 Cancel。
|
||||
|
||||
一个 260px 输入框加两个按钮在任何窗口尺寸下都装不进那条线。对着已构建产物实测,编辑器打开时该行的可滚动溢出在 1680px 视口下是 168px,在 600px 下是 444px——这个缺陷从来不是窄窗口的边缘情况,在全屏桌面下就已存在。flex 溢出会溢出到线的末端之外,因此按 flex 顺序排在编辑器之后的项被挤出会话列:branch 操作在 600px 时离开列,时钟及其运行时长/TTFT/吞吐读数在 900px 时离开列。这些控件在不可见的同时仍可命中测试,所以没有任何行为断言发现它;已交付的 e2e 覆盖评分、备注、reload 与撤回,而 24 个 UI 快照是与宽度无关的 DOM。
|
||||
|
||||
同一张样式表还引用了四个主题并未定义的 `--dsw-alias-*` token:`border-secondary`、`bg-primary`、`interactive-bg-primary` 与 `label-inverse`。未定义的自定义属性会让其所在的整条声明在 computed-value 阶段失效,因此 textarea 交付时既无边框也无底色,Save 既无填充也无可读标签——编辑器读起来像是浮在对话记录里的散落文本,而不是一个输入框。
|
||||
|
||||
## Decision
|
||||
|
||||
备注编辑器完全不进入行的 flex 布局。它是一个浮层:一张固定定位的面板,portal 到 `document.body`,其坐标来自备注触发按钮的矩形。行保持其单行图标与备注触发按钮,因此没有任何东西需要围绕编辑器收缩、换行或回流,任何地方都不需要 `order` 或换行。portal 出会话列也逃出了列的 `overflow` 裁剪,因此面板不会被滚动边缘裁掉,并且当对话记录滚动时会随它所批注的消息一起移动。这里复用 `ui-primitives/Menu` 为锚定菜单所用的同一套 portal 机制(`ui-subagent` 的 catalog popover 就构建在它之上):面板 `position: fixed`,打开时从 anchor rect 定位,钳制在视口内,并在滚动(捕获阶段)与缩放时重新定位。这套锚定逻辑是共享而非复制的:`ui-primitives/useAnchoredPosition` 持有「测量—偏移—钳制—跟随」这一件事,而促成这次抽取的正是重复代码门禁——内联的钳制与那对监听器被报为与 `Menu` 的 10 行克隆。`Menu` 保留自己的 effect,因为它的定位还要解析 `side`/`align` 变体与可选的调用方 anchor rect,而本界面不需要这些;该 hook 覆盖的是两边本来都要各写一遍的「锚点正下方」这一简单情形。
|
||||
|
||||
**操作条。** 点赞/点踩按钮与备注触发按钮保持原样留在行内。触发按钮是普通 `button`(`aria-haspopup="dialog"`,打开时 `aria-expanded`),在没有备注时显示「补充说明」,已有备注时显示备注文本。
|
||||
|
||||
**浮层。** 打开时,面板内含 textarea、Save 与 Cancel,以及任何备注保存失败提示,作为 `role="dialog"`,其标题与 textarea 自身的标签不同,以便两者都能按名称寻址。它在触发按钮下方打开(4px 间距),钳制到距视口边缘 12px,自动聚焦 textarea,并在 Escape 或外部 pointer-down 时关闭。关闭时仅当面板确实曾经打开才把焦点还给触发按钮,绝不会在初始挂载时(新渲染出的一条已评分消息不得把焦点拉进其操作条)。编辑器打开时进行评分操作会关闭面板。四个未定义 token 换成主题确实定义的那些,与 primitives 的既有做法一致:输入框用 `border-l2` 与 `bg-layer-1`,Save 用 `button-primary-fill` 配 `label-primary-foreground` 并加 `button-primary-hover` 状态;面板表面复用 Menu 卡片的配方(`--dsw-specific-menu`、`--dsw-shadow-lv3`、反色发丝线 `--dsw-alias-border-inverted`、`border-radius: 12px`)。
|
||||
|
||||
**失败提示按人的视线所落之处拆分。** 评分或列表加载失败显示在按钮旁的图标行里,无论浮层是否打开都清晰可读。备注保存失败显示在浮层内、Save/Cancel 旁,且面板保持打开,以便草稿留存待修正。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**行内展开:编辑器通过整行 flex basis 独占一行,并让行允许换行** — 这是本分支最初交付、在此否决的做法。它修好了几何(行在 1680px 到 600px 报告零溢出),但有可见代价:branch 与末尾时钟在编辑器打开时换行到编辑器下方,行占三行,交互与行本就占满的横向条带争空间。这一代价正是 [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) 在真实使用中反馈的问题——编辑器展开后这一行读起来是错位的——并提出改用 chat 界面已有的弹窗。浮层把编辑器完全移出行,因此无论编辑器是否打开,操作条与键盘 Tab 顺序都不受影响。
|
||||
|
||||
**不 portal 出列的绝对定位浮层** — 否决:会话列是 `overflow-y: auto` 的滚动容器,因此在列内布局的面板会被滚动边缘裁掉,且不随列滚动而跟住消息。portal 到 `document.body` 并从触发按钮矩形做固定定位,才让浮动面板可行,正如 `Menu` 的 portal 模式与 subagent catalog popover 已然做到的那样。
|
||||
|
||||
**在 `MessageIconActions` 上新增 `belowActions` 接缝,把编辑器作为该行的兄弟节点渲染在下方** — 否决:slot 契约明确记载 `assistant-actions` 渲染在消息 IconActions 行**内部**,且单个条目无法在不为一个展示细节拓宽 Host 契约的前提下提供两个渲染点,而 portal 出的浮层无需触碰 Host 就表达了该细节。
|
||||
|
||||
## Consequences
|
||||
|
||||
编辑器打开时,操作行保持单条 28px 线,在 1680px 到 600px 的每一档视口都零溢出、零项落在列外——因为编辑器本就不在行里。面板悬浮于对话记录之上、位于视口内,并保持锚定其触发按钮,逃出列溢出裁剪。编辑器在两种主题下都能被辨认为输入框。
|
||||
|
||||
`apps/web/tests/message-feedback-layout.e2e.ts` 在编辑器打开时扫描六个视口,并在每一档钉住:行报告单行零溢出、面板位于会话列之外(证明它逃出裁剪)、面板落在视口内(证明钳制有效)、面板紧贴其触发按钮。已提交的 golden 记录这些关系;回退到行内(或去掉 portal)会让几何断言失败。`packages/client/ui-message-feedback/tests/styles.client.spec.ts` 校验 token 与主题已提交的源一致、面板为 `position: fixed`、且不带任何 flex sizing(因此不会重新加入行),并校验大括号平衡,沿用 `ui-settings-models` styles spec 的先例。单元 spec 覆盖评分、备注、reload、撤回,外加浮层的 portal 到 body、Escape/外部点击关闭、以及浮层内部点击保持打开。
|
||||
|
||||
`ui-message-feedback` 包新增 `@types/react-dom`,使 `createPortal` 用法能通过类型检查,与 `ui-primitives` 一致。
|
||||
|
||||
有若干已知限制在此接受而非修复。面板打开时点击评分会关闭它,而关闭路径把焦点归还给备注触发按钮,而不是留在用户刚按下的评分按钮上;外部点击落在另一个可聚焦控件上时同理——浏览器先把焦点给该控件,随后关闭路径又把它拉回触发按钮。指针用户对两者都无感,键盘用户会察觉焦点移动。钳制假定面板放得下:面板高于视口时,上界 `innerHeight - height - margin` 会小于 `margin`,于是 `top` 变为负值、被裁掉的是面板顶部而非底部。面板里的三行 textarea 带 `resize: vertical`,用户可以拖过这个尺寸,因此 `.notePanel` 把自身高度限制在 `calc(100vh - 24px)` 并自行滚动内容——这是既有 `max-width` 的对应项,用的是与钳制相同的 12px 边距。编辑器打开时若评分消失,面板会因 `rating !== undefined` 守卫卸载,但 `noteOpen` 仍为 true,因此 document 级的 Escape 与 pointer-down 监听继续挂着;若该 item 之后经 resync 重新出现,浮层会带着上一次的草稿回来且不重新聚焦 textarea。该窗口只有一次点击或一次 Escape 那么宽,而它可能遮住的保存失败已经有行内回退,因此保持现状。若失败在面板关闭并重开后才到达,不会写入新会话的面板:其草稿已按已存备注重新播种,旧尝试的错误会误标新草稿,而未提交的内容本就不存在——该失败被丢弃而不展示。以及,定位虽然会在滚动、窗口缩放与面板自身尺寸变化时重放,但 jsdom 没有布局,因此真实几何由浏览器场景证明,单测则通过 `ResizeObserver` stub 覆盖其接线。
|
||||
|
||||
520px 以下仍残留仅来自时钟字符串的窄视口溢出,与本界面无关。仓库没有针对未定义设计 token 的门禁;本次工作中的一次扫描在 `ui-agent-preset`、`ui-conversation`、`ui-jobs`、`ui-settings-plugins` 与 `ui-tool` 中又发现更多,本次未触碰,需要单独的改动处理。
|
||||
@@ -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-08-03-web-search-source-scroll.md
|
||||
2026-08-03-web-search-source-scroll.md: 3402f519e1974b99e1f5a87dcd53b4d94a1a8374
|
||||
2026-08-03-web-search-source-scroll.zh.md: 8ac1158d054f739bb1f76c87570ac1e75b77e05d
|
||||
2026-08-03-web-search-source-scroll.md: 6fe532e2a2989e834b926cf48d531ae60a32f58b
|
||||
2026-08-03-web-search-source-scroll.zh.md: bc1abb5215c618809e79f56d1f9bd6c1ee9dbf15
|
||||
|
||||
@@ -8,13 +8,13 @@ English | [中文](2026-08-03-web-search-source-scroll.zh.md)
|
||||
|
||||
The `web_search` result card (`WebBlock`, `packages/client/ui-primitives/src/WebBlock.tsx`) rendered its source list with a head/tail collapse: past a `maxSources` count (16 in the details panel, 8 in the chat row via `CHAT_WEB_MAX_SOURCES`) it drew the first `ceil(max/2)` sources, an `… 其余 N 条来源` expand button, then the last `max - ceil(max/2)`, mirroring `TerminalBlock`'s output cap. A user reading the card saw `来源列表已截断` and assumed the frontend had dropped sources it was holding.
|
||||
|
||||
It had not. The seam (`capSources`, `packages/web/web/src/index.ts`) cuts the provider's sources to the tool's `searchMaxResults` bound (default 8) and sets `truncated`, and that one capped list feeds both the model-facing render text and the card's `presentationMeta`. The card never holds more sources than that one cut produced. So the collapse was hiding sources the user was entitled to see in full — and, with the default bound at 8 and the panel cap at 16, it almost never even triggered, leaving only the `truncated` note with no way to reveal anything.
|
||||
It had not. The seam (`capSources`, `packages/web/web/src/index.ts`) cuts each provider result to the tool's `searchMaxResults` bound (default 8); a multi-query call then deduplicates, interleaves, and caps the combined sources at the same bound. The final capped list feeds both the model-facing render text and the card's `presentationMeta`, so the card never holds more sources than the tool returned. The collapse was hiding sources the user was entitled to see in full — and, with the default bound at 8 and the panel cap at 16, it almost never even triggered, leaving only the `truncated` note with no way to reveal anything.
|
||||
|
||||
## Decision
|
||||
|
||||
`WebBlock`'s search arm renders every source it receives in one `<ol className={css.sources}>`, with no head/tail slicing, no expand button, and no `maxSources` prop. `.sources` (`WebBlock.module.css`) gets a fixed `max-height` and `overflow-y: auto`, so a list longer than the card height scrolls in place rather than growing the card or hiding rows. The height is a design constant of the card geometry, so it lives in CSS, not a plugin config field.
|
||||
|
||||
The model side is unchanged: the seam still caps sources at `searchMaxResults`, the model-facing render text is untouched, and the `truncated` flag and its `来源列表已截断` indicator stay. The card draws the list the seam produced, in full and scrollable, instead of collapsing its middle.
|
||||
The model side remains capped at `searchMaxResults`: the seam caps each provider result, the multi-query consumer caps a combined list, and the `truncated` flag and its `来源列表已截断` indicator stay. The card draws the final tool source list in full and scrollable, instead of collapsing its middle.
|
||||
|
||||
That list is the one the model reads as long as nothing downstream of the tool rewrites the result content alone. A deployment mounting `dsh-spill-policy` breaks that correspondence for an oversized result: `tools/post-execute` replaces the model-facing `content` with a preview plus a spill locator and leaves `presentationMeta` whole, so the card still draws every source while the model reads a bounded excerpt. The card's contract is therefore the view it receives, not the model's context.
|
||||
|
||||
@@ -36,11 +36,11 @@ Every source the tool returned is always in the DOM, so no source the view carri
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/web-block.client.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `<li>` with no `[aria-expanded]` and no `<button>`, every `<ol>` child is a source `<li>`, and `<li value>` numbers 1..N contiguously. `packages/client/ui-tool/tests/web-card.client.spec.tsx` drops the `CHAT_WEB_MAX_SOURCES` cap assertion; the WebRow expansion test still asserts the card shows every source field. The `packages/web/tool-web` tests are unchanged — the model side did not move.
|
||||
`packages/client/ui-primitives/tests/web-block.client.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `<li>` with no `[aria-expanded]` and no `<button>`, every `<ol>` child is a source `<li>`, and `<li value>` numbers 1..N contiguously. `packages/client/ui-tool/tests/web-card.client.spec.tsx` drops the `CHAT_WEB_MAX_SOURCES` cap assertion; the WebRow expansion test still asserts the card shows every source field. `packages/web/tool-web` independently pins the single- and multi-query model-side caps.
|
||||
|
||||
jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 12 provider results, each with a title, a citation snippet, and a date. That first pins the seam's cap end to end in a real composition — the shipped `searchMaxResults` keeps 8, the model-visible render text carries the 8 kept titles and none of the 4 dropped URLs plus `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's `<ol>`: 8 `<li>`, no `<button>` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over `scrollHeight` 574 against `clientHeight` 320. A further case measures a `999. ` marker in the list's own inherited font and requires the computed `padding-left` to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Neither the recorded stream nor the aria golden moved: replay is a positional cursor over the fixture's `assistant/chunk` entries and the search double is a separate local endpoint the provider reaches by `fetch`, while the card is collapsed at capture time so its `<ol>` is out of the DOM and the summary row carries no source count.
|
||||
jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 6 results for each of two queries, each with a title, a citation snippet, and a date. The real composition observes both provider requests and pins the tool's round-robin combined cap — the shipped `searchMaxResults` keeps 8 sources representing both queries, the model-visible render text omits the 4 dropped URLs and includes `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's `<ol>`: 8 `<li>`, no `<button>` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over a taller scroll body. A further case measures a `999. ` marker in the list's own inherited font and requires the computed `padding-left` to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Replay is a positional cursor over the fixture's `assistant/chunk` entries and the search double is a separate local endpoint the provider reaches by `fetch`.
|
||||
|
||||
## Related
|
||||
|
||||
- [Web result card](2026-07-30-web-result-card.md) — the `card: 'web'` render-intent arm and `presentationMeta` route this card consumes; the source of the capped-once list.
|
||||
- [Web result card](2026-07-30-web-result-card.md) — the `card: 'web'` render-intent arm and `presentationMeta` route this card consumes; the source of the final capped list.
|
||||
- [Web result card frontend](2026-07-30-web-result-card-frontend.md) — owns `WebBlock`, the single `web-card-model` derivation, and the render sites that draw the card; this note replaces the source-list collapse it specified, and its other decisions (one component for both kinds, the http(s) link allowlist, the single derivation, the resident posture) stand.
|
||||
|
||||
@@ -8,13 +8,13 @@ Status: implemented
|
||||
|
||||
`web_search` 结果卡片(`WebBlock`,`packages/client/ui-primitives/src/WebBlock.tsx`)此前用首尾折叠渲染它的来源列表:超过 `maxSources` 数量(详情面板为 16,聊天行经由 `CHAT_WEB_MAX_SOURCES` 为 8)时,它画出前 `ceil(max/2)` 条来源、一个 `… 其余 N 条来源` 展开按钮,再画出末尾 `max - ceil(max/2)` 条,仿照 `TerminalBlock` 的输出上限机制。用户阅读该卡片时看到 `来源列表已截断`,会以为前端丢弃了它正持有的来源。
|
||||
|
||||
其实并没有。seam(`capSources`,`packages/web/web/src/index.ts`)把 provider 的来源裁剪到工具的 `searchMaxResults` 上限(默认 8)并置位 `truncated`,而这一份被裁剪过一次的列表同时喂给面向模型的 render 文本与卡片的 `presentationMeta`。卡片持有的来源绝不会多于这一次裁剪的产物。因此这个折叠隐藏的正是用户本有权完整查看的来源——并且在默认上限为 8、面板上限为 16 时,它几乎从不触发,只留下 `truncated` 提示,却无从展开任何内容。
|
||||
其实并没有。seam(`capSources`,`packages/web/web/src/index.ts`)把每个提供方结果裁剪到工具的 `searchMaxResults` 上限(默认 8);多查询调用随后对组合来源去重、交错并限制在同一个上限内。最终的有界列表同时喂给面向模型的 render 文本与卡片的 `presentationMeta`,因此卡片持有的来源绝不会多于工具返回的来源。这个折叠隐藏的正是用户本有权完整查看的来源——并且在默认上限为 8、面板上限为 16 时,它几乎从不触发,只留下 `truncated` 提示,却无从展开任何内容。
|
||||
|
||||
## 决策
|
||||
|
||||
`WebBlock` 的 search 分支把它收到的每一条来源都渲染进单个 `<ol className={css.sources}>`,不做首尾切片、不设展开按钮、也不带 `maxSources` prop。`.sources`(`WebBlock.module.css`)获得一个固定的 `max-height` 与 `overflow-y: auto`,因此长于卡片高度的列表在原地滚动,而非撑大卡片或隐藏行。该高度是卡片几何形状的一个设计常量,因此放在 CSS 里,而非插件配置字段。
|
||||
|
||||
模型侧不变:seam 仍在 `searchMaxResults` 处封顶来源,面向模型的 render 文本未动,`truncated` 标志及其 `来源列表已截断` 指示保留。卡片完整且可滚动地画出 seam 产出的这份列表,而非折叠其中段。
|
||||
模型侧仍受 `searchMaxResults` 限制:seam 限制每个提供方结果,多查询消费方限制组合列表,`truncated` 标志及其 `来源列表已截断` 指示保留。卡片完整且可滚动地画出最终工具来源列表,而非折叠其中段。
|
||||
|
||||
只要工具下游没有单独改写结果 content,这份列表就是模型读到的那份。挂载了 `dsh-spill-policy` 的部署会对超限结果打破这一对应:`tools/post-execute` 把面向模型的 `content` 替换为预览加 spill 定位符,而 `presentationMeta` 原样保留,因此卡片仍画出全部来源,模型读到的却是一段有界摘录。所以卡片的约定是它收到的 view,不是模型的上下文。
|
||||
|
||||
@@ -36,11 +36,11 @@ Status: implemented
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/client/ui-primitives/tests/web-block.client.spec.tsx` 删去折叠相关用例(首尾切片、点击展开、折叠尾部编号、展开器不计入编号、仅首部、默认上限),并新增:一张含 30 条来源的卡片渲染出全部 30 个 `<li>`,无 `[aria-expanded]`、无 `<button>`,每个 `<ol>` 子元素都是一条来源 `<li>`,且 `<li value>` 从 1 到 N 连续编号。`packages/client/ui-tool/tests/web-card.client.spec.tsx` 删去 `CHAT_WEB_MAX_SOURCES` 上限断言;WebRow 展开测试仍断言卡片展示每一个来源字段。`packages/web/tool-web` 的测试不变——模型侧没有改动。
|
||||
`packages/client/ui-primitives/tests/web-block.client.spec.tsx` 删去折叠相关用例(首尾切片、点击展开、折叠尾部编号、展开器不计入编号、仅首部、默认上限),并新增:一张含 30 条来源的卡片渲染出全部 30 个 `<li>`,无 `[aria-expanded]`、无 `<button>`,每个 `<ol>` 子元素都是一条来源 `<li>`,且 `<li value>` 从 1 到 N 连续编号。`packages/client/ui-tool/tests/web-card.client.spec.tsx` 删去 `CHAT_WEB_MAX_SOURCES` 上限断言;WebRow 展开测试仍断言卡片展示每一个来源字段。`packages/web/tool-web` 独立固定单查询与多查询的模型侧上限。
|
||||
|
||||
jsdom 不解析 CSS Modules 布局,对任何元素都报 `scrollHeight === clientHeight`,因此它根本无从见证这次滚动。几何改由组装态浏览器钉住,位于 `apps/web/tests/web-search-round.e2e.ts`:其确定性 search double 返回 12 条提供方结果,每条带标题、引用摘录与日期。这首先在真实组合里端到端钉住 seam 的裁剪——出厂 `searchMaxResults` 保留 8 条,面向模型的 render 文本含这 8 条标题、不含被丢弃的 4 条 URL,并含 `(Showing the first 8 sources. Refine the query for more.)`,`meta.truncated` 为 true。随后位于 aria golden 之后的一个用例展开 `web_search` 行,对卡片的 `<ol>` 断言:8 个 `<li>`、卡片内任何位置都没有 `<button>`、`来源列表已截断` 指示可见,以及计算样式 `max-height: 320px` 与 `overflow-y: auto`,`scrollHeight` 为 574、`clientHeight` 为 320。再后一个用例在列表自身继承的字体下量出 `999. ` 序号的宽度,要求计算后的 `padding-left` 不小于该宽度,从而把滚动容器无从滚回的那段序号空间钉在最宽序号上,而非钉在某一份 fixture(测试前置数据)的来源条数上。录制的模型流与 aria golden 都未变动:回放是对 fixture 中 `assistant/chunk` 条目的位置游标,而 search double 是提供方经 `fetch` 抵达的另一个本地端点;捕获时卡片处于折叠状态,其 `<ol>` 不在 DOM 中,摘要行也不携带来源数量。
|
||||
jsdom 不解析 CSS Modules 布局,对任何元素都报 `scrollHeight === clientHeight`,因此它根本无从见证这次滚动。几何改由组装态浏览器钉住,位于 `apps/web/tests/web-search-round.e2e.ts`:其确定性 search double 为两个查询分别返回 6 条结果,每条带标题、引用摘录与日期。真实组合会观察两次提供方请求,并固定工具的轮询组合上限——出厂 `searchMaxResults` 保留代表两个查询的 8 条来源,面向模型的 render 文本不含被丢弃的 4 条 URL,并含 `(Showing the first 8 sources. Refine the query for more.)`,`meta.truncated` 为 true。随后位于 aria golden 之后的一个用例展开 `web_search` 行,对卡片的 `<ol>` 断言:8 个 `<li>`、卡片内任何位置都没有 `<button>`、`来源列表已截断` 指示可见,以及计算样式 `max-height: 320px` 与 `overflow-y: auto`,滚动主体高于容器。再后一个用例在列表自身继承的字体下量出 `999. ` 序号的宽度,要求计算后的 `padding-left` 不小于该宽度,从而把滚动容器无从滚回的那段序号空间钉在最宽序号上,而非钉在某一份 fixture(测试前置数据)的来源条数上。回放是对 fixture 中 `assistant/chunk` 条目的位置游标,而 search double 是提供方经 `fetch` 抵达的另一个本地端点。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Web result card](2026-07-30-web-result-card.md) —— 本卡片消费的 `card: 'web'` 渲染意图分支与 `presentationMeta` 路由;那份裁剪过一次的列表的来源。
|
||||
- [Web result card](2026-07-30-web-result-card.md) —— 本卡片消费的 `card: 'web'` 渲染意图分支与 `presentationMeta` 路由;最终有界列表的来源。
|
||||
- [Web result 卡片前端](2026-07-30-web-result-card-frontend.md) —— `WebBlock`、唯一的 `web-card-model` 派生,以及绘制该卡片的各渲染点由它拥有;本笔记替换掉它所规定的来源列表折叠,它的其余决策(一个组件绘制两种 kind、http(s) 链接 allowlist、单一派生、常驻姿态)依然成立。
|
||||
|
||||
+2
-2
@@ -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-08-04-claude-code-and-codex-subagent-backends.md
|
||||
2026-08-04-claude-code-and-codex-subagent-backends.md: a8f500c7fb934b8634456e1618498909f682f0e2
|
||||
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: ca35c38617b1ca38757959735b11618559f6f804
|
||||
2026-08-04-claude-code-and-codex-subagent-backends.md: 9b47fcf49d47d2c3561245fa1e16ff8c5da0a35c
|
||||
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: fcb1aac71be2da9d907ad67867c763e3051baec5
|
||||
|
||||
+8
-8
@@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance
|
||||
|
||||
## Decision
|
||||
|
||||
The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration.
|
||||
The harness publishes two sibling one-shot provider packages whose default registry names are `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [named-instance decision](2026-08-18-product-subagent-named-instances.md) owns Profile-selected provider identity and static tool binding, the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their independent optional Bundles and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and safe permission decisions, and the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns version-pinned product categories, lifecycle stages, and process outcomes exposed through the same diagnostic. Both packages accept multiple named instances. Loading either provider starts no product process, and each tool accepts only a standalone text task; product and instance selection remain deployment configuration.
|
||||
|
||||
Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation.
|
||||
|
||||
@@ -38,11 +38,11 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro
|
||||
|
||||
Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session.
|
||||
|
||||
`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; a permission-related error may additionally carry the shared safe diagnostic. This version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted` without permission detail.
|
||||
`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns Codex error-info categories, HTTP status, lifecycle stages, process outcomes, and stop-reason preservation. Local cancellation remains `aborted` without a failure diagnostic.
|
||||
|
||||
For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply.
|
||||
|
||||
An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Result failure and teardown failure stay independently observable.
|
||||
An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal.
|
||||
|
||||
Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively.
|
||||
|
||||
@@ -52,9 +52,9 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp
|
||||
|
||||
The public configuration contains a non-empty `providerName`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each named instance retains those resolved values for its own runs. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own.
|
||||
|
||||
The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. When a permission denial or unattended callback contributes to that failure, the result may additionally carry the bounded, non-assistant diagnostic owned by the non-interactive permissions decision. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without permission detail.
|
||||
The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns every non-success category, stage, process outcome, and its ordering with a contributing permission decision. Local cancellation wins and becomes `aborted` without either diagnostic fact.
|
||||
|
||||
Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. Query-close failure, process failure, and teardown failure remain independently observable.
|
||||
Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic.
|
||||
|
||||
The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract directly: the runtime-only DeepSeek key becomes `ANTHROPIC_AUTH_TOKEN`, the fixed official base gains `/anthropic`, and the main and subagent model variables select the documented DeepSeek models. It starts the production provider and real SDK/CLI, requires one random nonce as the complete answer, persists no credential in settings, and waits for every managed handle to exit.
|
||||
|
||||
@@ -62,11 +62,11 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract
|
||||
|
||||
Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Codex Loader fixture exposes two named Codex instances and tools; the Claude Code Loader fixture exposes the default Codex tool plus two named Claude Code instances and tools. Both fixtures include generic Job controls and start neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret.
|
||||
|
||||
The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, wrapper/native whole-tree exit, and missing-payload failure without host fallback.
|
||||
The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence.
|
||||
|
||||
The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit.
|
||||
|
||||
The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and the identities and versions of all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, safe permission diagnostics, process failure, local cancellation, and whole-tree exit. Unit coverage proves that production never resolves host `PATH`, omits the executable override, forwards the SDK-selected Windows `claude.exe` without a batch shim, and surfaces the SDK's missing-payload error without host fallback. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions. Loader coverage resolves both products through their optional Bundle patches while starting neither product.
|
||||
The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and whole-tree exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product.
|
||||
|
||||
The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test.
|
||||
|
||||
@@ -90,6 +90,6 @@ The project owner's distribution authorization is scoped to the official `@anthr
|
||||
|
||||
Users delegate through Profile-configured one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); named instance identity and tool binding are owned by the [named-instance decision](2026-08-18-product-subagent-named-instances.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence.
|
||||
|
||||
Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Both products use Bundle-pinned platform CLIs plus native account and workspace settings and the selected Provider permission mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout.
|
||||
Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic containing provider-owned permission facts or version-pinned structured failure facts. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Both products use Bundle-pinned platform CLIs plus native account and workspace settings and the selected Provider permission mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout.
|
||||
|
||||
Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe.
|
||||
|
||||
+8
-8
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责各自独立的可选 Bundle 与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。
|
||||
harness 交付两个同级的一次性提供方包,其默认注册名称分别为 `codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责 Profile 选择的提供方身份与静态工具绑定,[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责各自独立的可选 Bundle 与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 模式选择与安全权限决定,[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)则负责通过同一诊断公开锁定产品版本的类别、生命周期阶段与进程结果。两个包都接受多个命名实例。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品与实例选择仍属于部署配置。
|
||||
|
||||
这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。
|
||||
|
||||
@@ -38,11 +38,11 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro
|
||||
|
||||
发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。
|
||||
|
||||
`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;权限相关错误可以额外携带共享安全诊断。本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`,且不附带权限说明。
|
||||
`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责 Codex error-info 类别、HTTP status、生命周期阶段、进程结果与终止原因保持。本地取消仍是 `aborted` 且不附带失败诊断。
|
||||
|
||||
对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。
|
||||
|
||||
若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。结果失败与清理失败仍可彼此独立地观察。
|
||||
若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。
|
||||
|
||||
Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。
|
||||
|
||||
@@ -52,9 +52,9 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端
|
||||
|
||||
公开配置包含非空的 `providerName`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。
|
||||
|
||||
只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。当权限拒绝或无人值守回调参与了该失败时,结果还可以携带由非交互权限决策负责的有界、非 assistant 诊断。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带权限说明。
|
||||
只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责所有非成功类别、阶段、进程结果,以及它们与参与失败的权限决定之间的顺序。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实。
|
||||
|
||||
启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。Query 关闭失败、进程失败和清理失败仍可彼此独立地观察。
|
||||
启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。
|
||||
|
||||
带密钥 Claude Code e2e 直接使用官方 DeepSeek Claude Code 约定:仅在运行时提供的 DeepSeek 密钥会映射为 `ANTHROPIC_AUTH_TOKEN`,固定的官方基础 URL 会追加 `/anthropic`,主模型与 subagent 模型变量会选择文档所示的 DeepSeek 模型。该测试会启动生产提供方与真实 SDK 和 CLI,要求一个随机数作为完整答案,不会把任何凭据持久化到设置中,并等待所有受管句柄退出。
|
||||
|
||||
@@ -62,11 +62,11 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端
|
||||
|
||||
每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Codex Loader fixture 会公开两个命名 Codex 实例与工具;Claude Code Loader fixture 会公开默认 Codex 工具以及两个命名 Claude Code 实例与工具。两个 fixture 都包含通用 Job 控制工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。
|
||||
|
||||
Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消、wrapper/原生整棵进程树退出,以及载荷缺失时不回退宿主命令的失败。
|
||||
Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责 schema、失败、进程结果与最终呈现证据。
|
||||
|
||||
带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。
|
||||
|
||||
Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八个 SDK 平台包的身份与版本。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、安全权限诊断、进程失败、本地取消和整棵进程树退出。单元覆盖会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖、直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim,并且在载荷缺失时原样暴露 SDK 错误且不回退宿主 CLI。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。
|
||||
Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及整棵进程树退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。
|
||||
|
||||
带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。
|
||||
|
||||
@@ -90,6 +90,6 @@ Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八
|
||||
|
||||
用户通过由 Profile 配置、并由官方产品集成支持的一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;命名实例身份与工具绑定由[命名实例决策](2026-08-18-product-subagent-named-instances.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。
|
||||
|
||||
每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。两个产品都使用 Bundle 锁定的平台 CLI,并保留原生账户与工作区设置以及所选提供方权限模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
|
||||
每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断,其中包含由提供方拥有的权限事实,或锁定版本产品提供的结构化失败事实。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。两个产品都使用 Bundle 锁定的平台 CLI,并保留原生账户与工作区设置以及所选提供方权限模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。
|
||||
|
||||
兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。
|
||||
|
||||
+2
-2
@@ -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-08-15-product-subagent-noninteractive-permissions.md
|
||||
2026-08-15-product-subagent-noninteractive-permissions.md: df1f0d9939e951f16070729615a3779f1f7c2ddc
|
||||
2026-08-15-product-subagent-noninteractive-permissions.zh.md: 982b4409e08a506dec828db15c8c4aa5fcc36883
|
||||
2026-08-15-product-subagent-noninteractive-permissions.md: 8788fba3492e08090dd038fc3e7377f6bd1e29cd
|
||||
2026-08-15-product-subagent-noninteractive-permissions.zh.md: 6f254930151abce23f04de4f354bf57ad81bba61
|
||||
|
||||
+4
-4
@@ -44,9 +44,9 @@ The Provider overrides only those thread fields. `CODEX_HOME`, project configura
|
||||
|
||||
### Failure diagnostic
|
||||
|
||||
`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character.
|
||||
`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns non-permission product categories, lifecycle stages, and process outcomes carried by the same field.
|
||||
|
||||
Each product records only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`.
|
||||
Each product's permission fact contains only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. Both Providers place their structured failure line before the latest contributing permission fact. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. The Provider never adds either diagnostic fact to assistant output, structured output, or `subagent/end.lastAssistantMessage`.
|
||||
|
||||
The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior.
|
||||
|
||||
@@ -63,7 +63,7 @@ The foreground consumer presents the stop-reason headline, then the optional dia
|
||||
|
||||
## Verification
|
||||
|
||||
Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared diagnostic presentation while the model-facing product tool schemas contain no permission parameter.
|
||||
Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -83,6 +83,6 @@ Package tests pin every allowed and rejected Config value, the exact SDK and app
|
||||
|
||||
Profiles can select each product's native restricted, automatic, planning/edit-accepting where supported, or bypass behavior before the Provider starts, while both safe defaults never ask a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences.
|
||||
|
||||
Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. That diagnostic can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound it before result settlement.
|
||||
Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. The same field can also carry the separately owned structured failure facts. It can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound the complete text before result settlement.
|
||||
|
||||
The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Other Providers remain valid without producing a diagnostic or exposing a permission-mode Config.
|
||||
|
||||
+4
-4
@@ -44,9 +44,9 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交
|
||||
|
||||
### 失败诊断
|
||||
|
||||
`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。
|
||||
`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责由同一字段承载的非权限产品类别、生命周期阶段与进程结果。
|
||||
|
||||
每个产品都只记录有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。
|
||||
每个产品的权限事实都只包含有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。两个提供方都会把结构化失败行放在最新参与失败的权限事实之前。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。提供方绝不会把任一诊断事实写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。
|
||||
|
||||
前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。
|
||||
|
||||
@@ -63,7 +63,7 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交
|
||||
|
||||
## Verification
|
||||
|
||||
包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录共享诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。
|
||||
包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -83,6 +83,6 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交
|
||||
|
||||
Profile 可以在提供方启动前选择各产品原生的受限、自动、在产品支持时仅规划/编辑放行,或 bypass 行为,而两个安全默认值都绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。
|
||||
|
||||
权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。该诊断可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前完成脱敏和限长。
|
||||
权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。同一字段还可以承载由另一项决策负责的结构化失败事实。它可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前对完整文本完成脱敏和限长。
|
||||
|
||||
本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。
|
||||
|
||||
+6
@@ -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-08-17-command-image-attachment-envelope.md
|
||||
2026-08-17-command-image-attachment-envelope.md: 328a3fffa1d8db3ac9be42983965ef7f9578dec9
|
||||
2026-08-17-command-image-attachment-envelope.zh.md: bb135d218f156aaa36e3f9f52ed36019b68b56c3
|
||||
@@ -0,0 +1,46 @@
|
||||
# Agent Note: Command image-attachment envelope
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-17-command-image-attachment-envelope.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web composer submits one envelope — draft text, attached images, and delivery mode — but the two submission planes consumed it asymmetrically. A plain message rode `defaultSink → conversation.sendSession`, which serialized the images into prompt content and cleared them on success. A claimed slash command rode `claim.submit(args, actx)`, a text-only transaction: `/goal rebuild the cathedral` with four reference photos executed the command, cleared the draft, and silently stranded the images in the composer rail. The model never saw them, and no surface said so. The defect was contract-level, not a missed call site: nothing in the claim, the adjudication, or the host executor modeled attachments, so any command could consume the text half of a submission and drop the rest.
|
||||
|
||||
Merging the two planes was not on the table — the [plugin command registration Agent Note](2026-07-19-plugin-command-registration.md) deliberately keeps human commands out of the model plane, and that separation is correct. The gap was that the envelope fractured at the plane fork.
|
||||
|
||||
## Decision
|
||||
|
||||
The submission envelope is modeled end to end, and every command route either consumes it whole or refuses it loudly.
|
||||
|
||||
**Declaration.** `CommandDefinition.input.images: boolean` (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen `CommandDescriptor` through `commands/list` to every client, onto the minted `CommandClaim` (`images: true`), and into the input machine's published claim snapshot.
|
||||
|
||||
**Generic identity, image-specific payload.** Browser drafts and durable references already use `DraftAttachmentId` and `AttachmentId`; the command RPC carries encoded bytes rather than an image identifier. The wire remains `EncodedImageAttachment[]`, and the declaration remains `input.images`, while images are the only non-text attachment with defined admission and model-block semantics.
|
||||
|
||||
**Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`.
|
||||
|
||||
**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan <message>` folds the images into its steered text message, while bare `/plan` steers an image-only user message because the images may contain the whole task. Producer control forms with no model input (`/goal pause`, `/plan off`) return a direct error and keep the composer's images in place. The plan projection treats `command/run` as a candidate and drops it on a paired `command/done` error, so a rejected image-carrying `/plan off` cannot leave a pending exit.
|
||||
|
||||
**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them.
|
||||
|
||||
## Testing
|
||||
|
||||
Registry executor enforcement, admission failure settlement, and frozen invocation attachments are covered in `packages/interaction/commands/tests/commands.spec.ts`; batch admission ordering and limits in `packages/attachment/attachment/tests/admission.spec.ts`; producer behavior in `packages/goal/command-goal/tests/command-goal.spec.ts` and `packages/plan/plan-mode/tests/plan-mode.spec.ts`; client refusal and consumption paths in the ui-commands, ui-conversation, and ui-input-trigger client suites; and the assembled-application flow in the apps/web keyless lanes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Block commands whenever images are attached (no acceptance path)** — rejected: predictable, but `/goal` with reference images is the motivating use case; the user's images would have no route to the model at all.
|
||||
- **Auto-send stranded images as a follow-up user message after any command** — rejected: surprising for host-state commands (`/model`, `/compact`), and it moves the message contract from the producer to the composer, against the command registry's "producer owns model-visible work" rule.
|
||||
- **Store attachment references in the goal domain and render them into round prompts** — rejected: requires durable goal schema changes and either duplicates image blocks into every round prompt or adds round-one-only prompt shape; the round-prompt invariant would need attachment state. One ordinary logged user message achieves the same model visibility.
|
||||
- **Consume images on any command success regardless of grammar** — rejected: `/goal pause` with images attached would silently discard them, recreating the original defect one layer deeper. Consumption is tied to the producer's explicit success, and grammar misfits return errors.
|
||||
- **Keep enforcement client-side only** — rejected: schema omission is not enforcement; direct RPC callers could bypass the composer. The executor settles the declaration itself.
|
||||
- **Generalize the command wire to a multimedia identifier** — rejected: the two identifiers are already attachment-generic, while the wire transports bytes and its image-specific fields state the admission rules the Host enforces. Files and videos lack shared admission and model-visible semantics, and an untagged multimedia identifier would not supply them. A second supported attachment kind is the reintroduction condition; the command envelope then widens to a tagged attachment union and commands declare the accepted kinds while retaining `AttachmentId`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No command route can consume a submission's text and strand its images: the contract forces whole-envelope consumption or a visible refusal, for current and future commands alike.
|
||||
- The commands package now depends on `dsh-attachment` and `dsh-llm`, and `commands/execute` carries a required `images` wire parameter — every caller states its envelope explicitly.
|
||||
- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), including an image-only message for bare `/plan`; all are billed like any image prompt.
|
||||
- Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary.
|
||||
- "A rejected batch publishes no durable object" covers exactly the pre-admission settlements (declaration, missing store, batch limit). A handler-level grammar rejection (`/goal pause` with images) and a post-admission cancellation settle AFTER the batch committed, leaving content-addressed objects without a referencing session event — harmless under sha256 dedup and the attachment store's deferred reference-aware GC, but not "no object was written".
|
||||
@@ -0,0 +1,46 @@
|
||||
# Agent Note: Command image-attachment envelope
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-17-command-image-attachment-envelope.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Web composer 的一次提交是一个信封——草稿文本、已附加图片、投递模式——但两条提交平面对它的消费是不对称的。普通消息走 `defaultSink → conversation.sendSession`,图片被序列化进 prompt 内容并在成功后清除。被 claim 的斜杠命令走 `claim.submit(args, actx)`,一个纯文本事务:`/goal rebuild the cathedral` 带四张参考照片时,命令执行、草稿清空,图片却静默滞留在 composer 附件栏。模型从未看到它们,也没有任何界面提示。这个缺陷在契约层面而非某个漏掉的调用点:claim、裁决、宿主执行器都没有建模附件,因此任何命令都可能消费提交的文本一半而丢弃其余部分。
|
||||
|
||||
合并两个平面从未在考虑范围内——[插件命令注册 Agent Note](2026-07-19-plugin-command-registration.md)刻意让人类命令留在模型平面之外,这个分离是正确的。问题在于信封在平面分叉处被拆散了。
|
||||
|
||||
## Decision
|
||||
|
||||
提交信封被端到端建模,每条命令路径要么整体消费它,要么响亮拒绝。
|
||||
|
||||
**声明。**`CommandDefinition.input.images: boolean`(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 `CommandDescriptor` 经 `commands/list` 到达每个客户端,进入铸造出的 `CommandClaim`(`images: true`),再进入输入状态机发布的 claim 快照。
|
||||
|
||||
**通用标识,图片专用载荷。**浏览器草稿与持久化引用已经使用 `DraftAttachmentId` 和 `AttachmentId`;命令 RPC 传输的是编码字节,而非图片标识。图片仍是唯一已经定义准入规则和模型块语义的非文本附件,因此 wire 保持 `EncodedImageAttachment[]`,声明保持 `input.images`。
|
||||
|
||||
**执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。
|
||||
|
||||
**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan <message>` 把图片并入其 steer 的文本消息;不带参数的 `/plan` 则 steer 一条只含图片的用户消息,因为图片可能包含全部任务内容。不会发送模型输入的控制形式(`/goal pause`、`/plan off`)会直接返回错误,composer 的图片原地保留。plan 投影会把 `command/run` 视为候选选择,并在配对的 `command/done` 报错时丢弃它,因此被拒绝的带图 `/plan off` 不会留下待退出状态。
|
||||
|
||||
**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。
|
||||
|
||||
## Testing
|
||||
|
||||
注册表执行器强制、准入失败结算、冻结的调用附件由 `packages/interaction/commands/tests/commands.spec.ts` 覆盖;批量准入顺序与限额在 `packages/attachment/attachment/tests/admission.spec.ts`;生产方行为在 `packages/goal/command-goal/tests/command-goal.spec.ts` 与 `packages/plan/plan-mode/tests/plan-mode.spec.ts`;客户端拒绝与消费路径在 ui-commands、ui-conversation、ui-input-trigger 客户端套件;组装后应用流程在 apps/web 的 keyless 通道。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **附加图片时一律拦截命令(没有接受路径)**——被拒绝:可预测,但带参考图的 `/goal` 正是驱动这次修复的用例,用户的图片将完全没有通往模型的路径。
|
||||
- **任何命令后把滞留图片自动作为后续用户消息发送**——被拒绝:对宿主状态命令(`/model`、`/compact`)令人意外,且把消息契约从生产方挪到 composer,违反命令注册表「生产方负责模型可见工作」的规则。
|
||||
- **在 goal 领域存储附件引用并渲染进 Round 提示词**——被拒绝:需要持久化 goal schema 变更,且要么把图片块复制进每轮提示词,要么引入仅首轮的提示词形态;round 提示词不变量将需要附件状态。一条普通的已记录用户消息达到同样的模型可见性。
|
||||
- **只要命令成功就消费图片,不管语法**——被拒绝:`/goal pause` 带图会把图片静默丢弃,在更深一层重演原始缺陷。消费与生产方的显式成功绑定,语法不匹配返回错误。
|
||||
- **只在客户端强制**——被拒绝:schema 省略不是强制执行;直接 RPC 调用方可以绕过 composer。执行器自己结算声明。
|
||||
- **把命令 wire 泛化成多媒体标识**——被拒绝:两个标识已经是附件通用类型,wire 传输的是字节,其图片专用字段明确表达了 Host 强制执行的准入规则。文件和视频尚无共同的准入规则与模型可见语义,一个不带类型标记的多媒体标识也无法提供这些信息。出现第二种受支持附件时再引入泛化:命令信封扩展为带类型标记的附件联合类型,命令声明接受的类型,`AttachmentId` 保持不变。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 任何命令路径都不可能消费提交的文本而滞留图片:契约强制整信封消费或可见拒绝,对现有与未来命令一体适用。
|
||||
- commands 包新增对 `dsh-attachment` 与 `dsh-llm` 的依赖,`commands/execute` 携带必填的 `images` wire 参数——每个调用方都显式陈述其信封。
|
||||
- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),其中不带参数的 `/plan` 会产生只含图片的消息;所有这些输入的计费都与常规图片提示词相同。
|
||||
- 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。
|
||||
- 「被拒绝的批量不发布任何持久化对象」只覆盖准入前的三种结算(声明、存储缺失、批量超限)。handler 级语法拒绝(如 `/goal pause` 带图)与准入后取消发生在批量已提交之后,会留下没有会话事件引用的内容寻址对象——在 sha256 去重与附件存储延后的引用感知 GC 下无害,但并非「未写入任何对象」。
|
||||
@@ -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-08-17-web-search-multiple-queries.md
|
||||
2026-08-17-web-search-multiple-queries.md: f0a8bf3d69763231ee2c1f441ed52e2e0b7a6060
|
||||
2026-08-17-web-search-multiple-queries.zh.md: 5c6be5e20fdb7ea14fcc8ef3e1275f57a008152f
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: web_search accepts multiple queries in one call
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-17-web-search-multiple-queries.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The model-facing `web_search` tool accepted only one `query`. In deployments where an internal search backend was also exposed as MCP, models preferred the MCP search tool because it could take multiple keywords in one call, and they often followed a native `web_search` with a second MCP search when the first result felt insufficient.
|
||||
|
||||
## Decision
|
||||
|
||||
`web_search` accepts one required `queries` string array. A one-item array performs a single search. `searchMaxQueries` bounds the array and provider fan-out, defaults to four, and appears in the system-prompt guidance and tool descriptions. Validation rejects an oversized array before any provider call starts, then exact duplicate strings are removed while preserving their first position.
|
||||
|
||||
When `queries` has multiple distinct entries, `dsh-tool-web` runs them concurrently through `ctx.web.search`, labels provider answers with their originating query, and deduplicates sources by URL. It takes one source at each rank from every query before advancing to the next rank, then caps the combined list to `searchMaxResults`; this prevents one query's lower-ranked sources from displacing every source from later queries. If any search fails, the tool aborts its siblings, waits for every started search to settle, discards successful results, and returns the first failure. A one-item array returns the provider's result without multi-query formatting.
|
||||
|
||||
The multi-query orchestration lives in the tool consumer, not in the web seam or providers, because `WebSearchProvider.search` remains a single-query contract and the seam stays provider-neutral.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Rely on the existing parallel tool-call support.** Rejected: the model still sees a one-query schema and must decide to emit multiple `web_search` calls, which is exactly the friction that pushed it toward the MCP interface.
|
||||
|
||||
**Accept both `query` and `queries`.** Rejected: two optional fields make the model choose between equivalent representations and move the required exactly-one rule into prose and runtime validation. One required array represents both one and many searches with fewer invalid states.
|
||||
|
||||
**Add a multi-query request type to `WebSearchRequest`.** Rejected: providers are single-query backends, and changing the shared seam would force every provider to implement a feature only the model-facing consumer needs.
|
||||
|
||||
**Accept an unbounded `queries` array.** Rejected: one model action could start an arbitrary number of provider requests and concatenate an arbitrary number of provider answers. A deployment-owned bound keeps the model schema focused on search input while controlling cost and output growth.
|
||||
|
||||
**Add an overall native-search budget to `WebSearchRequest`.** Rejected: the generic seam cannot count provider-internal search units without leaking one provider's mechanism or accepting a limit that other providers cannot enforce. Deployments combine the consumer-owned `searchMaxQueries` bound with provider-owned controls such as `maxUses`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Models pass one required `queries` array for every native `web_search` call and can batch several distinct searches without switching to MCP search. The default query cap of four matches Codex `web.run`'s model-facing batch size while bounding concurrent provider calls; deployments can choose another positive integer independently of the source cap. Exact duplicate strings consume the input-array bound but cause only one provider call. Combined sources remain bounded by `searchMaxResults` and preserve each query's result ranking through round-robin merge. Provider answers in a multi-query result are prefixed with `### <query>` headings so the model can tell which answer came from which search.
|
||||
|
||||
Multi-query failure is all-or-nothing: a successful provider result is discarded if another query fails, and the call does not return until sibling cancellation reaches quiescence. `searchMaxQueries` and provider-owned controls are independently configurable and together form the search budget. A provider may perform several native searches inside one `ctx.web.search` call, so a model-backed provider with its own `maxUses` can permit up to `searchMaxQueries × maxUses` native searches; `searchMaxResults` bounds only the combined sources returned to the caller. The provider-neutral seam deliberately does not define an overall native-search counter.
|
||||
|
||||
The real Web composition snapshot issues one `queries` call through the DeepSeek search provider, observes two auxiliary provider requests, and pins the round-robin combined result, durable metadata, and joined search-card title. Package tests separately prove overlap before the first provider promise settles, query-cap rejection before provider dispatch, exact-query and source deduplication, uneven result exhaustion, truncation, caller cancellation propagation, and batch quiescence after failure.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: web_search 支持一次传入多个查询
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-17-web-search-multiple-queries.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
面向模型的 `web_search` 工具原来只接受单个 `query`。在同时把内部搜索后端以 MCP 方式暴露的部署中,模型更倾向于使用 MCP 搜索工具,因为它能一次传入多个关键词;模型也常常在调用原生 `web_search` 后觉得结果不够,再补一次 MCP 搜索。
|
||||
|
||||
## 决定
|
||||
|
||||
`web_search` 接受一个必填的 `queries` 字符串数组。单元素数组执行一次搜索。`searchMaxQueries` 限制数组大小与提供方请求扇出,默认值为 4,并出现在系统提示词指引与工具描述中。校验会在任何提供方调用开始前拒绝超限数组,随后移除完全相同的重复字符串,并保留它们首次出现的位置。
|
||||
|
||||
当 `queries` 包含多个不同条目时,`dsh-tool-web` 会通过 `ctx.web.search` 并发执行这些搜索,用来源查询标注提供方答案,并按 URL 对来源去重。它从每个查询取得同一排名的一条来源后再推进至下一排名,然后把组合列表限制在 `searchMaxResults` 上限内;这样,一个查询排名较低的来源不会挤掉后续查询的所有来源。任何搜索失败时,工具会中止其余搜索,等待所有已启动搜索结算,丢弃成功结果,并返回首次失败。单元素数组直接返回提供方结果,不添加多查询格式。
|
||||
|
||||
多查询编排放在工具消费方,而不是 web seam 或提供方,因为 `WebSearchProvider.search` 仍是单查询契约,seam 也保持提供方无关。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**依赖现有的并行工具调用能力。** 不采用:模型看到的仍然是单查询 schema,必须自行决定发起多次 `web_search` 调用,这正是把它推向 MCP 接口的摩擦点。
|
||||
|
||||
**同时接受 `query` 与 `queries`。** 不采用:两个可选字段会让模型在等价表示之间选择,并把必填且二选一的规则移入说明文本与运行时校验。一个必填数组用更少的无效状态同时表示一次与多次搜索。
|
||||
|
||||
**给 `WebSearchRequest` 增加多查询请求类型。** 不采用:提供方都是单查询后端,而且修改共享 seam 会迫使每个提供方实现只有模型侧消费方才需要的功能。
|
||||
|
||||
**接受无上限的 `queries` 数组。** 不采用:一次模型操作可以启动任意数量的提供方请求,并拼接任意数量的提供方答案。由部署拥有的上限既让模型 schema 聚焦搜索输入,也能控制成本与输出增长。
|
||||
|
||||
**给 `WebSearchRequest` 增加原生搜索总预算。** 不采用:通用 seam 若要计算提供方内部的搜索单位,要么泄漏某个提供方的机制,要么接受其他提供方无法强制执行的上限。部署会把消费方自有的 `searchMaxQueries` 上限与提供方自有的 `maxUses` 等控制项结合使用。
|
||||
|
||||
## 结果
|
||||
|
||||
模型在每次原生 `web_search` 调用中都传入一个必填的 `queries` 数组,并可在不转向 MCP 搜索的情况下批量执行多个不同搜索。默认查询上限 4 与 Codex `web.run` 面向模型的批量大小一致,同时限制并发提供方调用;部署可以独立于来源上限选择另一个正整数。完全相同的重复字符串会占用输入数组上限,但只会触发一次提供方调用。组合来源仍受 `searchMaxResults` 限制,并通过轮询合并保留每个查询的结果排名。多查询结果中的提供方答案会以 `### <query>` 标题标注,便于模型区分答案来自哪个搜索。
|
||||
|
||||
多查询失败采用全有或全无语义:如果另一个查询失败,成功的提供方结果也会被丢弃;在同批取消达到静默状态前,调用不会返回。`searchMaxQueries` 与提供方自有的控制项可以独立配置,并共同构成搜索预算。提供方可以在一次 `ctx.web.search` 调用内执行多次原生搜索,因此拥有自身 `maxUses` 的模型型提供方最多可以执行 `searchMaxQueries × maxUses` 次原生搜索;`searchMaxResults` 只限制返回给调用方的组合来源。提供方中立的 seam 有意不定义原生搜索总计数器。
|
||||
|
||||
真实 Web 组合快照通过 DeepSeek 搜索提供方发起一次 `queries` 调用,观察两次辅助提供方请求,并固定轮询组合结果、持久化元数据和拼接后的搜索卡片标题。包测试另行证明:第一个提供方 promise 结算前已经发起重叠调用;查询上限会在提供方分发前拒绝请求;完全相同查询与来源都会去重;不等长结果能够耗尽;截断、调用方取消传播以及失败后的批次静默状态保持正确。
|
||||
@@ -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-08-18-product-subagent-failure-facts.md
|
||||
2026-08-18-product-subagent-failure-facts.md: 50d8e918f288a6b8a9b90474499b2ed20731643f
|
||||
2026-08-18-product-subagent-failure-facts.zh.md: 7dc5a73637c90a1ca1123c86d754f95c68498fd9
|
||||
@@ -0,0 +1,87 @@
|
||||
# Agent Note: Product subagents expose bounded structured failure facts
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-18-product-subagent-failure-facts.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [Claude Code and Codex product providers](2026-08-04-claude-code-and-codex-subagent-backends.md) receive structured product failures, but a published run historically flattened most of them to the shared `error` stop reason. Product logs retained detail that the foreground parent and a [one-shot background Job](2026-08-12-product-subagent-one-shot-background-tasks.md) could not use to distinguish a product limit, an execution failure, or an early process exit.
|
||||
|
||||
Copying SDK error text, app-server payloads, or stderr into the result would expose task text, paths, environment values, credentials, or product internals. Adding shared error fields would also make the provider-neutral [subagent seam](2026-06-21-subagent-capability-seam.md) own product version vocabularies that change independently.
|
||||
|
||||
## Decision
|
||||
|
||||
Each product Provider owns the mapping from its pinned official error union, current operation, and managed process outcome to one fixed safe diagnostic line. `SubagentResult` remains unchanged: consumers receive the existing bounded `diagnostic` string and do not parse its product-private fields.
|
||||
|
||||
### Safe diagnostic
|
||||
|
||||
The structured line has this fixed order:
|
||||
|
||||
```text
|
||||
Product subagent failure (product: <product>; stage: <stage>; category: <category>; HTTP status: <status>; exit code: <code>; signal: <signal>)
|
||||
```
|
||||
|
||||
The Provider omits unavailable optional fields. Exit code and signal are independent facts and are each retained when observed. A contributing permission decision from the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) follows the structured line; the latest safe permission fact remains operation-local. The shared result boundary limits the complete text to 4096 UTF-8 bytes.
|
||||
|
||||
Successful results and local cancellation expose no failure fact. Raw product errors, stderr, tool input, paths, environment values, credentials, and protocol payloads never enter the diagnostic. Startup and cleanup rejections use the same safe line in their Error message. Original failures remain on internal cause chains; Provider Host logs and forwarded stderr remain product-local observation only.
|
||||
|
||||
### Claude Code facts
|
||||
|
||||
Agent SDK 0.3.220 defines four error subtypes: `error_during_execution`, `error_max_turns`, `error_max_budget_usd`, and `error_max_structured_output_retries`. The Claude Code Provider preserves each exact subtype as the category while keeping the shared stop reason `error`. An error-marked or blank success uses `invalid-success`, a missing result uses `missing-result`, a process exit before an SDK terminal result uses `process-exit`, and an unrecognized value or exception uses `unknown` without copying the value.
|
||||
|
||||
| Stage | Owned operation | Observable failure |
|
||||
| --- | --- | --- |
|
||||
| `query-start` | SDK query construction, native platform-payload startup, and unpublished rollback | `start()` rejects with fixed safe facts and any process outcome observed before rollback |
|
||||
| `query-run` | Published SDK message iteration and strict terminal-result validation | The run resolves as `error` with the exact known subtype or a fixed result category |
|
||||
| `process` | Managed CLI exits before the SDK supplies a terminal result | The run resolves as `error` with `process-exit` and the available exit code and signal |
|
||||
| `teardown` | Query close and managed process-tree release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait |
|
||||
|
||||
### Codex facts
|
||||
|
||||
Codex app-server 0.147.0 defines eleven string categories and five object variants. The Provider preserves `contextWindowExceeded`, `sessionBudgetExceeded`, `usageLimitExceeded`, `serverOverloaded`, `cyberPolicy`, `internalServerError`, `unauthorized`, `badRequest`, `threadRollbackFailed`, `sandboxError`, and `other`. It also preserves `httpConnectionFailed`, `responseStreamConnectionFailed`, `responseStreamDisconnected`, `responseTooManyFailedAttempts`, and `activeTurnNotSteerable`; the four connection/stream variants retain numeric `httpStatusCode`, while the active-turn variant does not expose `turnKind`. Unknown strings, objects with another variant set, malformed values, and unclassified exceptions use `unknown`.
|
||||
|
||||
| Stage | Owned operation | Observable failure |
|
||||
| --- | --- | --- |
|
||||
| `initialize` | App-server spawn and initialize/initialized handshake | `start()` rejects with fixed safe facts and any process outcome already observed |
|
||||
| `thread-start` | Ephemeral `thread/start` request and response validation | `start()` rejects with the thread stage and any available process outcome |
|
||||
| `turn-start` | Published `turn/start` request, provisional ids, and early frames | The run resolves as `error` with a safe unknown fallback when no structured category exists |
|
||||
| `turn` | Terminal notification, final-answer selection, and error-info mapping | The complete category and optional HTTP status reach the non-completed result |
|
||||
| `process` | Managed app-server exits before another terminal path settles | The run resolves as `error` with `process-exit` and any available code and signal |
|
||||
| `teardown` | Wire close and process-tree release | `dispose()` rejects independently; startup rollback aggregation exposes both startup and teardown lines |
|
||||
|
||||
`contextWindowExceeded` remains `max-tokens`; every other known or unknown Codex category remains `error`, and `cyberPolicy` does not become `refusal`.
|
||||
|
||||
### Ownership and lifecycle
|
||||
|
||||
| Fact or resource | Owner | Consumer behavior |
|
||||
| --- | --- | --- |
|
||||
| Product error category | Pinned official SDK or app-server version | The Provider maps only the declared structured union and uses `unknown` outside it |
|
||||
| Current failure stage | Product Provider operation | Derived at the failure site; never persisted or used as a recovery state |
|
||||
| Exit code and signal | `dsh-subprocess` process handle | The Provider displays observed values without inferring missing ones |
|
||||
| Diagnostic bytes and delivery | `dsh-subagent`, foreground tool, and Job runtime | The same bounded text is presented separately from assistant output in both scheduling modes |
|
||||
| Raw product failure | Product runtime, internal cause chain, and Host observation | It remains internal and never becomes model-visible result text |
|
||||
|
||||
## Verification
|
||||
|
||||
Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. Codex package tests pin all sixteen error-info variants, HTTP status presence and absence, all six stages, unknown fallback, stop-reason preservation, permission ordering, sanitization, cancellation, concurrency, and cleanup aggregation. The real SDK/CLI fixture produces an actual Claude `error_max_turns`; the real app-server fixture produces an actual Codex `internalServerError`; both fixtures cover process/protocol failure and whole-tree quiescence. The keyless ACP snapshot records each product's exact diagnostic in foreground error output, a background completion notice, and `job_output`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Return raw SDK errors, app-server payloads, or stderr.** These values can contain commands, paths, workspace content, environment values, credentials, or upstream prose. A fixed allowlisted mapping preserves actionable facts without expanding the model-visible trust boundary.
|
||||
|
||||
**Add a shared product-error enum or structured result fields.** Claude Code and Codex version their error unions independently. A shared enum would duplicate those authorities and force unrelated Providers and consumers to track product releases.
|
||||
|
||||
**Parse generic stderr and exception messages.** Free-form text is neither stable nor safe. Only pinned structured product fields and the managed process outcome qualify as diagnostic input.
|
||||
|
||||
**Persist stages or add a recovery controller.** The stage is derived from the current call site only when a failure is reported. Persistence, retries, resume, and remediation need separate ownership and user contracts.
|
||||
|
||||
**Map product limits to new shared stop reasons.** Claude Code turn and budget limits are not token-window exhaustion, and an error category does not establish refusal semantics. Existing stop reasons remain unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
The parent can distinguish important Claude Code limits and Codex budget, usage, service, policy, request, connection, stream, rollback, sandbox, and active-turn failures without receiving raw product text. Foreground and background scheduling preserve the same fact because both consume one `SubagentResult`.
|
||||
|
||||
The diagnostic is display text rather than a new public protocol. Callers may present it but must not branch on its punctuation or product-private category names. A pinned product-version upgrade must update the Provider mapping and evidence when its official error union changes.
|
||||
|
||||
This decision adds no product session persistence, retry policy, recovery state, stderr classifier, authentication or configuration taxonomy, progress stream, or human interaction path.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Agent Note: 产品 subagent 公开有界结构化失败事实
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-18-product-subagent-failure-facts.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
[Claude Code 与 Codex 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)会收到结构化产品失败,但已发布运行以往会把其中大多数压成共享的 `error` 终止原因。产品日志保留了细节,前台父 agent 与[一次性后台 Job](2026-08-12-product-subagent-one-shot-background-tasks.md)却无法据此区分产品限制、执行失败或进程提前退出。
|
||||
|
||||
若把 SDK 错误文本、app-server payload 或 stderr 复制进结果,就会暴露任务文本、路径、环境值、凭证或产品内部信息。若增加共享错误字段,又会让提供方无关的 [subagent seam](2026-06-21-subagent-capability-seam.md)拥有彼此独立变化的产品版本词汇。
|
||||
|
||||
## Decision
|
||||
|
||||
每个产品提供方分别拥有从锁定版本官方错误联合、当前操作和受管进程结果到一行固定安全诊断的映射。`SubagentResult` 保持不变:消费方仍接收现有的有界 `diagnostic` 字符串,而且不解析其中由产品私有的字段。
|
||||
|
||||
### 安全诊断
|
||||
|
||||
结构化行采用以下固定顺序:
|
||||
|
||||
```text
|
||||
Product subagent failure (product: <product>; stage: <stage>; category: <category>; HTTP status: <status>; exit code: <code>; signal: <signal>)
|
||||
```
|
||||
|
||||
提供方会省略不可用的可选字段。退出码与信号是相互独立的事实,只要已观测到就分别保留。来自[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)且参与失败的权限决定会跟在结构化行之后;最新的安全权限事实仍只属于当前操作。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。
|
||||
|
||||
成功结果与本地取消都不公开失败事实。原始产品错误、stderr、工具输入、路径、环境值、凭证和协议 payload 绝不会进入诊断。启动与清理拒绝会在 Error 消息中使用同一安全行。原始失败保留在内部 cause 链中;提供方 Host 日志与转发的 stderr 也只作为产品本地观测。
|
||||
|
||||
### Claude Code 事实
|
||||
|
||||
Agent SDK 0.3.220 定义四种错误子类型:`error_during_execution`、`error_max_turns`、`error_max_budget_usd` 和 `error_max_structured_output_retries`。Claude Code 提供方会把每种准确子类型保留为类别,同时维持共享终止原因 `error`。标记为错误或内容空白的成功消息使用 `invalid-success`,缺失结果使用 `missing-result`,SDK 给出终态结果前发生的进程退出使用 `process-exit`,无法识别的值或异常使用 `unknown`,且不会复制原值。
|
||||
|
||||
| 阶段 | 归属操作 | 可观察失败 |
|
||||
| --- | --- | --- |
|
||||
| `query-start` | SDK query 构造、原生平台载荷启动与未发布回滚 | `start()` 以固定安全事实和回滚前已观测到的进程结果拒绝 |
|
||||
| `query-run` | 已发布 SDK 消息迭代与严格终态结果校验 | 运行以 `error` 兑现,并携带准确已知子类型或固定结果类别 |
|
||||
| `process` | SDK 提供终态结果之前受管 CLI 已退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码和信号 |
|
||||
| `teardown` | Query 关闭与受管进程树释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 |
|
||||
|
||||
### Codex 事实
|
||||
|
||||
Codex app-server 0.147.0 定义十一种字符串类别与五种对象 variant。提供方会保留 `contextWindowExceeded`、`sessionBudgetExceeded`、`usageLimitExceeded`、`serverOverloaded`、`cyberPolicy`、`internalServerError`、`unauthorized`、`badRequest`、`threadRollbackFailed`、`sandboxError` 和 `other`。它还会保留 `httpConnectionFailed`、`responseStreamConnectionFailed`、`responseStreamDisconnected`、`responseTooManyFailedAttempts` 与 `activeTurnNotSteerable`;四种连接/stream variant 会保留数值 `httpStatusCode`,而 active-turn variant 不公开 `turnKind`。未知字符串、同时含其他 variant 的对象、格式错误值与未分类异常统一使用 `unknown`。
|
||||
|
||||
| 阶段 | 归属操作 | 可观察失败 |
|
||||
| --- | --- | --- |
|
||||
| `initialize` | App-server spawn 与 initialize/initialized 握手 | `start()` 以固定安全事实和已经观测到的进程结果拒绝 |
|
||||
| `thread-start` | 临时 `thread/start` 请求与响应校验 | `start()` 以线程阶段和可用进程结果拒绝 |
|
||||
| `turn-start` | 已发布 `turn/start` 请求、暂定 id 与早到 frame | 没有结构化类别时,运行以 `error` 和安全 unknown 回退兑现 |
|
||||
| `turn` | 终态通知、最终答案选择与 error-info 映射 | 完整类别与可选 HTTP status 进入非完成结果 |
|
||||
| `process` | 受管 app-server 在另一终态路径结算前退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码与信号 |
|
||||
| `teardown` | Wire 关闭与进程树释放 | `dispose()` 独立拒绝;启动回滚聚合会同时公开启动与 teardown 两行 |
|
||||
|
||||
`contextWindowExceeded` 仍是 `max-tokens`;其他所有已知或未知 Codex 类别仍是 `error`,`cyberPolicy` 不会变成 `refusal`。
|
||||
|
||||
### 所有权与生命周期
|
||||
|
||||
| 事实或资源 | Owner | 消费方行为 |
|
||||
| --- | --- | --- |
|
||||
| 产品错误类别 | 锁定版本的官方 SDK 或 app-server | 提供方只映射已声明的结构化联合,并对联合外值使用 `unknown` |
|
||||
| 当前失败阶段 | 产品提供方操作 | 只在失败点派生;绝不持久化,也不作为恢复状态 |
|
||||
| 退出码与信号 | `dsh-subprocess` 进程句柄 | 提供方展示已观测值,不推测缺失值 |
|
||||
| 诊断字节与送达 | `dsh-subagent`、前台工具与 Job 运行时 | 两种调度模式都把同一份有界文本与 assistant 输出分开呈现 |
|
||||
| 原始产品失败 | 产品运行时、内部 cause 链与 Host 观测 | 只保留在内部,绝不成为模型可见的结果文本 |
|
||||
|
||||
## Verification
|
||||
|
||||
Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。Codex 包测试固定全部十六种 error-info variant、HTTP status 存在与缺失、六个阶段、unknown 回退、终止原因保持不变、权限顺序、脱敏、取消、并发与清理聚合。真实 SDK/CLI fixture 会产生真实的 Claude `error_max_turns`,真实 app-server fixture 会产生真实的 Codex `internalServerError`;两个 fixture 都覆盖进程/协议失败与整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录两个产品各自的准确诊断。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**返回原始 SDK 错误、app-server payload 或 stderr。** 这些值可能包含命令、路径、工作区内容、环境值、凭证或上游文本。固定白名单映射可以保留可操作事实,同时不扩大模型可见的信任边界。
|
||||
|
||||
**增加共享产品错误 enum 或结构化结果字段。** Claude Code 与 Codex 各自独立版本化错误联合。共享 enum 会复制这些权威,并迫使无关提供方和消费方跟随产品版本。
|
||||
|
||||
**解析通用 stderr 与异常消息。** 自由文本既不稳定也不安全。只有锁定版本产品提供的结构化字段和受管进程结果可以成为诊断输入。
|
||||
|
||||
**持久化阶段或增加恢复控制器。** 阶段只在报告失败时从当前调用点派生。持久化、重试、resume 与修复需要独立的所有权和用户约定。
|
||||
|
||||
**把产品限制映射为新的共享终止原因。** Claude Code 的轮次和预算限制并不表示 token 窗口耗尽,错误类别也不能证明拒绝语义。既有终止原因保持不变。
|
||||
|
||||
## Consequences
|
||||
|
||||
父 agent 可以区分重要的 Claude Code 限制,以及 Codex 预算、用量、服务、策略、请求、连接、stream、回滚、sandbox 和 active-turn 失败,而不会收到原始产品文本。前台与后台调度会保留同一事实,因为二者都消费同一个 `SubagentResult`。
|
||||
|
||||
诊断只是展示文本,不是新的公开协议。调用方可以呈现它,但不得根据其标点或产品私有类别名称进行分支。锁定产品版本升级并改变官方错误联合时,必须同步更新提供方映射与证据。
|
||||
|
||||
本决策不增加产品会话持久化、重试策略、恢复状态、stderr 分类器、身份验证或配置分类体系、进度流或人工交互路径。
|
||||
@@ -261,17 +261,20 @@ jobs:
|
||||
name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
path: dist-python
|
||||
|
||||
- name: Install only the SDK into a clean venv and run zero-config
|
||||
- name: Install local SDK and runtime wheels into a clean venv
|
||||
env:
|
||||
VERSION: ${{ needs.plan.outputs.version }}
|
||||
RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }}
|
||||
SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m venv "$RUNNER_TEMP/dsh-sdk-smoke"
|
||||
"$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \
|
||||
--find-links dist-python \
|
||||
deepseek-harness-sdk=="$VERSION"
|
||||
"dist-python/$SDK_WHEEL" \
|
||||
"dist-python/$RUNTIME_WHEEL"
|
||||
"$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \
|
||||
--scenario sdk-default
|
||||
"$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \
|
||||
--scenario sdk-mcp
|
||||
|
||||
- name: Check Linux GLIBC requirements
|
||||
if: runner.os == 'Linux'
|
||||
@@ -297,7 +300,8 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
RUNNER_ARCH: ${{ runner.arch }}
|
||||
VERSION: ${{ needs.plan.outputs.version }}
|
||||
RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }}
|
||||
SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$RUNNER_ARCH" in
|
||||
@@ -305,10 +309,11 @@ jobs:
|
||||
ARM64) image=quay.io/pypa/manylinux_2_28_aarch64 ;;
|
||||
*) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;;
|
||||
esac
|
||||
docker run --rm -e VERSION -e DSH_TELEMETRY_DISABLED -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c '
|
||||
docker run --rm -e RUNTIME_WHEEL -e SDK_WHEEL -e DSH_TELEMETRY_DISABLED -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c '
|
||||
/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk
|
||||
/tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION"
|
||||
/tmp/dsh-sdk/bin/python -m pip install "/work/dist-python/$SDK_WHEEL" "/work/dist-python/$RUNTIME_WHEEL"
|
||||
/tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default
|
||||
/tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp
|
||||
'
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -68,10 +68,15 @@ jobs:
|
||||
print(f"version={release['pep440_version'](repository_version)}")
|
||||
PY
|
||||
|
||||
- name: Install and run the published entry path
|
||||
- name: Install local release wheels and run the public entry path
|
||||
env:
|
||||
VERSION: ${{ steps.compatibility-version.outputs.version }}
|
||||
run: |
|
||||
python -m pip install --find-links dist "deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}"
|
||||
python -m pip install \
|
||||
"dist/deepseek_harness_sdk-$VERSION-py3-none-any.whl" \
|
||||
"dist/deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl"
|
||||
python scripts/smoke-python-runtime.py --scenario sdk-default
|
||||
python scripts/smoke-python-runtime.py --scenario sdk-mcp
|
||||
|
||||
validate:
|
||||
name: Validate release candidate
|
||||
|
||||
+2
-1
@@ -45,6 +45,7 @@ sdk-wheel:
|
||||
- python -m venv .wheel-smoke
|
||||
- .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness-sdk=="$DSH_WHEEL_VERSION"
|
||||
- .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default
|
||||
- .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-mcp
|
||||
- |
|
||||
if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then
|
||||
readelf --version-info "$EXE" > glibc-versions.txt
|
||||
@@ -56,7 +57,7 @@ sdk-wheel:
|
||||
linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;;
|
||||
*) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;;
|
||||
esac
|
||||
docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_WHEEL_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default"
|
||||
docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_WHEEL_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp"
|
||||
fi
|
||||
- |
|
||||
if [ "$PLATFORM" = macos-arm64 ]; then
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
// The command image-attachment envelope over the BUILT client graph (real
|
||||
// bundles via AppWebEntry, keyless FixtureApiClient transport): an enter
|
||||
// submission carrying composer images resolves only through a command whose
|
||||
// descriptor declares `input.images`. A non-declaring command refuses with
|
||||
// one composer error banner and everything retained; a declaring command
|
||||
// consumes the images — serialized through the real draft-image chain into
|
||||
// the commands/execute payload — and clears the composer on success, including
|
||||
// when the image is the whole `/plan` task.
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { expect, it } from 'vitest'
|
||||
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
|
||||
|
||||
installAssembledBootEnv()
|
||||
|
||||
/** Open a fresh fixture session and return its composer textarea. */
|
||||
async function freshComposer(): Promise<HTMLTextAreaElement> {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
|
||||
if (start === null) throw new Error('fixture Workspace new-session action missing')
|
||||
fireEvent.click(start)
|
||||
return await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) as HTMLTextAreaElement
|
||||
}
|
||||
|
||||
/** Paste one tiny PNG into the composer and wait for its rail thumbnail. */
|
||||
async function pasteImage(textarea: HTMLTextAreaElement, name: string): Promise<void> {
|
||||
const image = new File([new Uint8Array([137, 80, 78, 71])], name, { type: 'image/png' })
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
|
||||
getData: () => '',
|
||||
},
|
||||
})
|
||||
await waitFor(() => {
|
||||
const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
|
||||
if (rail === null) throw new Error('attachment rail missing')
|
||||
expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name)
|
||||
}, { timeout: 5_000 })
|
||||
}
|
||||
|
||||
it('refuses an image-carrying submit to a non-declaring command and keeps draft and images', async () => {
|
||||
mountAssembledApp()
|
||||
const textarea = await freshComposer()
|
||||
await pasteImage(textarea, 'ref.png')
|
||||
|
||||
// /echo is a leadingInput fixture command without `input.images`.
|
||||
fireEvent.change(textarea, { target: { value: '/echo hello' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
|
||||
// The refusal rides the same transient error banner as other composer
|
||||
// failures; session activity remains on its separate status live region.
|
||||
const notice = await waitFor(() => {
|
||||
const el = [...document.querySelectorAll('[role="alert"]')]
|
||||
.find(candidate => candidate.textContent?.includes('image attachments') ?? false)
|
||||
if (el === undefined) throw new Error('composer refusal banner missing')
|
||||
return el
|
||||
}, { timeout: 5_000 })
|
||||
expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first')
|
||||
expect([...document.querySelectorAll('[role="status"]')]
|
||||
.some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false)
|
||||
// The whole envelope is retained: draft text and the rail thumbnail.
|
||||
expect(textarea.value).toBe('/echo hello')
|
||||
const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
|
||||
expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png'])
|
||||
})
|
||||
|
||||
it('consumes images through a declaring command and clears the composer on success', async () => {
|
||||
mountAssembledApp()
|
||||
const textarea = await freshComposer()
|
||||
await pasteImage(textarea, 'goal-ref.png')
|
||||
|
||||
// /goal declares `input.images` in the fixture catalog; the claim submit
|
||||
// serializes the pasted bytes and the fixture executor admits them.
|
||||
fireEvent.change(textarea, { target: { value: '/goal rebuild the cathedral' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textarea.value).toBe('')
|
||||
expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
|
||||
}, { timeout: 5_000 })
|
||||
})
|
||||
|
||||
it('submits a bare /plan with an image as an image-only plan request', async () => {
|
||||
mountAssembledApp()
|
||||
const textarea = await freshComposer()
|
||||
await pasteImage(textarea, 'plan-task.png')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: '/plan' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textarea.value).toBe('')
|
||||
expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
|
||||
}, { timeout: 5_000 })
|
||||
expect([...document.querySelectorAll('[role="alert"]')]
|
||||
.some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,336 @@
|
||||
// Web e2e scenario: with the feedback note editor open, the assistant IconActions
|
||||
// row stays one intact line (no wrapping, nothing pushed out), and the note
|
||||
// editor floats above the transcript in a popover that escapes the conversation
|
||||
// column's overflow clip and stays inside the viewport.
|
||||
//
|
||||
// The hazard this pins: a slot-contributed note editor (260px textarea plus
|
||||
// Save and Cancel) cannot fit the shared IconActions row at ANY viewport, and an
|
||||
// inline expansion made the row wider than the column — full-screen desktop
|
||||
// included — so the branch action and the clock were pushed out of view by later
|
||||
// flex items. The fix is to not mount the editor in the row at all: it is a
|
||||
// popover portaled to document.body and fixed-positioned from the note trigger's
|
||||
// rect, so the row keeps its single 28px line of icons and the trigger, and the
|
||||
// panel cannot be cropped by the column's overflow because it lives outside it.
|
||||
//
|
||||
// The sweep records, per viewport, whether the open editor keeps the actions row
|
||||
// on one line with zero overflow, whether the panel is outside the column (proof
|
||||
// it escapes the clip), whether the panel stays inside the viewport (proof the
|
||||
// clamp works), and whether it sits by its trigger. All relations, no absolute
|
||||
// pixels: the column width follows the viewport, the sidebar, and the platform's
|
||||
// scrollbar, so a golden carrying pixels would document the platform, not the
|
||||
// behavior.
|
||||
//
|
||||
// Zero model calls: a settled transcript is cold-seeded, so nothing streams.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-layout', import.meta.url))
|
||||
/**
|
||||
* Committed golden of the popover relations at every stop. Booleans and counts
|
||||
* only, never absolute coordinates.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
/** Borrowed read-only: this scenario needs any settled assistant message to rate. */
|
||||
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
|
||||
const SEED_ID = 'message-feedback-layout-e2e'
|
||||
/** Viewport widths from full-screen desktop down to a narrow window. */
|
||||
const WIDTHS = [1680, 1280, 1024, 900, 700, 600]
|
||||
|
||||
/** One viewport stop: how the row reads with the note editor closed and open, plus the popover's own relations. */
|
||||
export interface PopoverMetrics {
|
||||
/** Viewport width the stop was measured at. */
|
||||
width: number
|
||||
/** The row's scrollable overflow with the note editor closed (natural row width). */
|
||||
rowOverflowClosed: number
|
||||
/** The row's scrollable overflow with the note editor open; must equal the closed value. */
|
||||
rowOverflowOpen: number
|
||||
/** Flex lines the row occupies with the note editor open; the editor must not reflow it. */
|
||||
rowLines: number
|
||||
/** Row items whose right edge escapes the column, editor closed. */
|
||||
itemsOutsideColumnClosed: number
|
||||
/** Row items whose right edge escapes the column, editor open; must equal the closed value. */
|
||||
itemsOutsideColumnOpen: number
|
||||
/** True when the portaled panel is NOT inside the column (escapes its overflow clip). */
|
||||
panelOutsideColumn: boolean
|
||||
/** True when the panel lies fully inside the viewport (the clamp holds). */
|
||||
panelWithinViewport: boolean
|
||||
/** Horizontal separation between the panel's left edge and the note trigger's, in px. */
|
||||
panelToTriggerGap: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the feedback row (and the open popover, when present) at the current
|
||||
* viewport. The same reader serves the closed and open readings so the two
|
||||
* sides differ only by whether the editor is open.
|
||||
* @param page - the page under test.
|
||||
* @param width - the viewport width already applied, recorded with the reading.
|
||||
* @param editorOpen - true to also read the popover's relations; throws if it is absent.
|
||||
* @returns the stop's relations.
|
||||
*/
|
||||
function measurePopover(page: Page, width: number, editorOpen: boolean): Promise<PopoverMetrics> {
|
||||
return page.evaluate(({ viewportWidth, open }) => {
|
||||
const rated = document.querySelector<HTMLElement>('button[aria-label="Remove rating"]')
|
||||
if (rated === null) throw new Error('no rated feedback control in the DOM')
|
||||
const row = rated.parentElement?.closest<HTMLElement>('div[class*="actions"]') ?? null
|
||||
if (row === null) throw new Error('the IconActions row is not an ancestor of the feedback control')
|
||||
const trigger = row.querySelector<HTMLElement>('button[aria-haspopup="dialog"]')
|
||||
if (trigger === null) throw new Error('the note trigger is not in the row')
|
||||
|
||||
/**
|
||||
* The real flex items of the row. A slot contributor (the feedback strip)
|
||||
* arrives as a `display: contents` wrapper (the `assistant-actions` slot
|
||||
* renders inside a transparent `data-slot` div), which reports an all-zero
|
||||
* rect; a zero box would be miscounted as a phantom flex line. The actual
|
||||
* items are the boxes inside it.
|
||||
* @param element - the row whose items to read.
|
||||
* @returns the real flex-item boxes, in flex/DOM order.
|
||||
*/
|
||||
const flexItemBoxes = (element: HTMLElement): DOMRect[] => {
|
||||
const boxes: DOMRect[] = []
|
||||
for (const child of Array.from(element.children)) {
|
||||
const el = child as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (el.style.display === 'contents') {
|
||||
boxes.push(...flexItemBoxes(el))
|
||||
} else if (rect.height > 0 && rect.width > 0) {
|
||||
boxes.push(rect)
|
||||
}
|
||||
}
|
||||
return boxes
|
||||
}
|
||||
/**
|
||||
* Group items into flex lines by overlapping vertical extent.
|
||||
* @param boxes - the row items' boxes, in DOM order.
|
||||
* @returns the number of distinct lines.
|
||||
*/
|
||||
const countFlexLines = (boxes: DOMRect[]): number => {
|
||||
const centres: number[] = []
|
||||
for (const box of boxes) {
|
||||
const centre = box.top + box.height / 2
|
||||
if (!centres.some(known => Math.abs(known - centre) <= box.height / 2)) centres.push(centre)
|
||||
}
|
||||
return centres.length
|
||||
}
|
||||
|
||||
const column = row.closest<HTMLElement>('[data-conversation-scroll]')
|
||||
const columnRight = (column?.getBoundingClientRect().left ?? 0) + (column?.clientWidth ?? 0)
|
||||
const itemRects = flexItemBoxes(row)
|
||||
// A half-pixel tolerance: subpixel layout puts a contained edge a fraction
|
||||
// over the boundary on some device scale factors.
|
||||
const itemsOutsideColumn = itemRects.filter(box => box.right > columnRight + 0.5).length
|
||||
// The editor is a portal, so the row measures identically whether the
|
||||
// editor is open or not; the closed/open fields differ by call so the sweep
|
||||
// can assert a zero delta on them.
|
||||
const overflow = row.scrollWidth - row.clientWidth
|
||||
|
||||
let builder: {
|
||||
panelOutsideColumn: boolean
|
||||
panelWithinViewport: boolean
|
||||
panelToTriggerGap: number
|
||||
}
|
||||
if (!open) {
|
||||
builder = { panelOutsideColumn: true, panelWithinViewport: true, panelToTriggerGap: 0 }
|
||||
} else {
|
||||
const panel = document.body.querySelector<HTMLElement>('[role="dialog"]')
|
||||
if (panel === null) throw new Error('the note popover is not open')
|
||||
const panelBox = panel.getBoundingClientRect()
|
||||
const triggerBox = trigger.getBoundingClientRect()
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
builder = {
|
||||
// The panel portals out of the column, so the clip cannot reach it.
|
||||
panelOutsideColumn: column === null ? true : !column.contains(panel),
|
||||
panelWithinViewport:
|
||||
panelBox.left >= -0.5
|
||||
&& panelBox.right <= vw + 0.5
|
||||
&& panelBox.top >= -0.5
|
||||
&& panelBox.bottom <= vh + 0.5,
|
||||
// The panel is fixed from the trigger's left, so a zero gap says it is
|
||||
// anchored; a clamp can only widen it.
|
||||
panelToTriggerGap: Math.abs(panelBox.left - triggerBox.left),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: viewportWidth,
|
||||
rowOverflowClosed: overflow,
|
||||
rowOverflowOpen: overflow,
|
||||
rowLines: countFlexLines(itemRects),
|
||||
itemsOutsideColumnClosed: itemsOutsideColumn,
|
||||
itemsOutsideColumnOpen: itemsOutsideColumn,
|
||||
...builder,
|
||||
}
|
||||
}, { viewportWidth: width, open: editorOpen })
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the golden body: one line per stop, relations and counts only. The
|
||||
* row-overflow and outside-column readings are deltas (open minus closed) so
|
||||
* the golden records that opening the editor leaves the row untouched, not an
|
||||
* absolute count that many unrelated controls could move.
|
||||
* @param stops - the measured stops, in sweep order.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
function renderGeometry(stops: PopoverMetrics[]): string {
|
||||
return [
|
||||
'# Assistant actions row with the feedback note popover open',
|
||||
'',
|
||||
'| viewport | row overflow delta | row lines | items-outside delta '
|
||||
+ '| panel outside the column | panel within the viewport | panel-to-trigger gap |',
|
||||
'| --- | --- | --- | --- | --- | --- | --- |',
|
||||
...stops.map(stop => `| ${String(stop.width)}px | ${String(stop.rowOverflowOpen - stop.rowOverflowClosed)}px `
|
||||
+ `| ${String(stop.rowLines)} | ${String(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed)} `
|
||||
+ `| ${String(stop.panelOutsideColumn)} | ${String(stop.panelWithinViewport)} `
|
||||
+ `| ${String(stop.panelToTriggerGap)}px |`),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: the feedback note editor floats above the column', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser, 900)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
/**
|
||||
* Open the seeded transcript. The first treeitem is the collapsible group
|
||||
* row; the session itself is the row beneath it.
|
||||
* @returns nothing.
|
||||
*/
|
||||
async function openSeededSession(): Promise<void> {
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 15_000 })
|
||||
await sessionRow.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize to a viewport and read the row once its width stops moving. The
|
||||
* frame eases its column tracks, so reading straight after a resize can
|
||||
* report the previous viewport's relation.
|
||||
* @param width - viewport width to settle at.
|
||||
* @param editorOpen - whether the note editor is currently open; reads the popover relations when so.
|
||||
* @returns the row's (and popover's) readings at that width.
|
||||
*/
|
||||
const settleAt = async (width: number, editorOpen: boolean): Promise<PopoverMetrics> => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
let previous = -1
|
||||
await expect.poll(async () => {
|
||||
const current = await page.evaluate(() =>
|
||||
document.querySelector('[data-conversation-scroll]')?.clientWidth ?? -1)
|
||||
const settled = current === previous
|
||||
previous = current
|
||||
return settled
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
// The popover is JS-positioned from the trigger rect and re-places on
|
||||
// resize/scroll, so once the column width stops moving we nudge it to the
|
||||
// final layout; otherwise the panel can sit at a transient position from
|
||||
// mid-resize and the anchor reading would be off.
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('resize')))
|
||||
return measurePopover(page, width, editorOpen)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate a message, then for every stop read the row once with the note editor
|
||||
* closed and once with it open, handing the SAME measured readings to both
|
||||
* assertions so the golden and the assertions describe one measurement
|
||||
* rather than two runs that could disagree.
|
||||
* @returns the stops in {@link WIDTHS} order.
|
||||
*/
|
||||
let swept: Promise<PopoverMetrics[]> | undefined
|
||||
const sweep = (): Promise<PopoverMetrics[]> => {
|
||||
swept ??= (async () => {
|
||||
await openSeededSession()
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
// The controller defers its list read to the first hover or focus, so the
|
||||
// strip has to be touched before it can be rated.
|
||||
const like = page.getByRole('button', { name: 'Good response' }).first()
|
||||
await like.waitFor({ timeout: 30_000 })
|
||||
await like.scrollIntoViewIfNeeded()
|
||||
await like.hover()
|
||||
await like.click()
|
||||
await page.getByRole('button', { name: 'Remove rating' }).first()
|
||||
.waitFor({ timeout: 15_000 })
|
||||
const noteTrigger = page.getByRole('button', { name: 'Add a note' }).first()
|
||||
const stops: PopoverMetrics[] = []
|
||||
for (const width of WIDTHS) {
|
||||
// Reset to the closed baseline at each stop before opening.
|
||||
if (await noteTrigger.getAttribute('aria-expanded') === 'true') await noteTrigger.click()
|
||||
const closed = await settleAt(width, false)
|
||||
await page.getByRole('button', { name: 'Add a note' }).first().click()
|
||||
await page.getByRole('dialog').waitFor({ timeout: 10_000 })
|
||||
const open = await settleAt(width, true)
|
||||
stops.push({
|
||||
width,
|
||||
rowOverflowClosed: closed.rowOverflowClosed,
|
||||
rowOverflowOpen: open.rowOverflowOpen,
|
||||
rowLines: open.rowLines,
|
||||
itemsOutsideColumnClosed: closed.itemsOutsideColumnClosed,
|
||||
itemsOutsideColumnOpen: open.itemsOutsideColumnOpen,
|
||||
panelOutsideColumn: open.panelOutsideColumn,
|
||||
panelWithinViewport: open.panelWithinViewport,
|
||||
panelToTriggerGap: open.panelToTriggerGap,
|
||||
})
|
||||
}
|
||||
return stops
|
||||
})()
|
||||
return swept
|
||||
}
|
||||
|
||||
it('keeps the actions row untouched by the note popover, which stays in the viewport', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout'))
|
||||
const stops = await sweep()
|
||||
for (const stop of stops) {
|
||||
// The popover lives outside the row, so opening it must not change the
|
||||
// row at all. This is the vacuity guard of the whole redesign: an inline
|
||||
// editor would widen or reflow the row, pushing the delta off zero.
|
||||
expect(stop.rowOverflowOpen - stop.rowOverflowClosed, `viewport ${String(stop.width)}`).toBe(0)
|
||||
expect(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed, `viewport ${String(stop.width)}`).toBe(0)
|
||||
// The row is one 28px line; the editor never forces a reflow.
|
||||
expect(stop.rowLines, `viewport ${String(stop.width)}`).toBe(1)
|
||||
// The panel escapes the column's overflow clip by living outside it.
|
||||
expect(stop.panelOutsideColumn, `viewport ${String(stop.width)}`).toBe(true)
|
||||
// The placement clamps the panel inside the viewport at every width.
|
||||
expect(stop.panelWithinViewport, `viewport ${String(stop.width)}`).toBe(true)
|
||||
// The panel stays anchored to its trigger rather than drifting off.
|
||||
expect(stop.panelToTriggerGap, `viewport ${String(stop.width)}`).toBeLessThanOrEqual(4)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 180_000)
|
||||
|
||||
it('matches the committed geometry golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout-golden'))
|
||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE)
|
||||
}, 180_000)
|
||||
|
||||
it('kept the console clean', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
// Web e2e scenario: at the 800×720 viewport the plan chip and the model
|
||||
// trigger keep disjoint click areas, and clicking the chip at its center
|
||||
// leaves plan mode through the real command channel. This is the browser
|
||||
// regression the external report asked for (dsh-external/issues#107 →
|
||||
// deepseek-harness#1406): "increase an 800×720 browser regression test and
|
||||
// assert that the plan center hits the plan button".
|
||||
//
|
||||
// Plan mode is entered through the real /plan command with no argument:
|
||||
// the command handler commits plan/mode active on the live agent without a
|
||||
// model round (the lifecycle-chrome precedent), so the test needs no model
|
||||
// call in any mode and no API key in replay/refresh; a providers-only
|
||||
// fixture mounts the model catalog without a script to consume. Plan state
|
||||
// folds from the session log (`plan/mode`, last one wins); the chip executes
|
||||
// /plan off through commands.execute, which needs the live agent
|
||||
// connectFreshWorkspace keeps.
|
||||
//
|
||||
// The geometry golden records stable facts — viewport membership on both
|
||||
// axes for the chip and the trigger, and disjoint click areas — never
|
||||
// absolute coordinates, whose pixel values depend on installed fonts and
|
||||
// differ between macOS and Linux. The center hit-test is Playwright's
|
||||
// actionability check: clicking the chip fails in a real engine when the
|
||||
// element center does not receive pointer events. jsdom resolves no layout,
|
||||
// so only a real engine can answer any of these facts.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
// Type-only: pulls the plan/mode SessionEventMap merge so the discriminant
|
||||
// filter below types as the plan-mode event in the host aggregate.
|
||||
import type {} from '@deepseek-ai/dsh-plan-mode'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/plan-narrow-viewport', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
/** The reported viewport: 800×720, where the composer card is 448px wide at 0.0.1. */
|
||||
const VIEWPORT = { width: 800, height: 720 } as const
|
||||
|
||||
/** Chip aria-label on the English page; the seat renders only while plan is the effective target. */
|
||||
const CHIP_ARIA = 'Plan mode on, press to turn off'
|
||||
|
||||
describe('web e2e: plan chip click area at the narrow viewport', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
// replayProvidersOnly mounts the provider catalog without any recorded
|
||||
// script to consume (no model call happens — the /plan command never
|
||||
// steers a message), so the model trigger renders its real long label,
|
||||
// which is what made the reported overlap measurable.
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayProvidersOnly: true })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser, VIEWPORT.height)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('keeps the plan chip and model trigger disjoint and exits plan mode by click', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-narrow-viewport'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
await input.fill('/plan ')
|
||||
await input.press('Enter')
|
||||
|
||||
// The command handler commits plan/mode active immediately (no model
|
||||
// round), so the chip renders and the composer control row — the surface
|
||||
// under test — is the one visible.
|
||||
const chip = page.getByRole('button', { name: CHIP_ARIA })
|
||||
const trigger = page.getByRole('button', { name: /Select model/ })
|
||||
await chip.waitFor({ timeout: 30_000 })
|
||||
await trigger.waitFor({ timeout: 10_000 })
|
||||
// The regression depends on the real model label width: a bare fallback
|
||||
// trigger would fit beside the chip even on the pre-fix layout. The
|
||||
// directory loads asynchronously, so poll for the real label.
|
||||
await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 }).toContain('DeepSeek-V4-Flash')
|
||||
const chipBox = await chip.boundingBox()
|
||||
const triggerBox = await trigger.boundingBox()
|
||||
expect(chipBox).not.toBeNull()
|
||||
expect(triggerBox).not.toBeNull()
|
||||
|
||||
// The reported acceptance as numbers: both controls in viewport and
|
||||
// disjoint click areas (a non-zero overlap would fail), and — in the
|
||||
// click below — the chip center receiving the pointer.
|
||||
const chipInViewport = chipBox!.x >= 0 && chipBox!.x + chipBox!.width <= VIEWPORT.width
|
||||
&& chipBox!.y >= 0 && chipBox!.y + chipBox!.height <= VIEWPORT.height
|
||||
const triggerInViewport = triggerBox!.x >= 0 && triggerBox!.x + triggerBox!.width <= VIEWPORT.width
|
||||
&& triggerBox!.y >= 0 && triggerBox!.y + triggerBox!.height <= VIEWPORT.height
|
||||
const overlapLeft = Math.max(chipBox!.x, triggerBox!.x)
|
||||
const overlapTop = Math.max(chipBox!.y, triggerBox!.y)
|
||||
const overlapRight = Math.min(chipBox!.x + chipBox!.width, triggerBox!.x + triggerBox!.width)
|
||||
const overlapBottom = Math.min(chipBox!.y + chipBox!.height, triggerBox!.y + triggerBox!.height)
|
||||
const overlapArea = Math.max(0, overlapRight - overlapLeft) * Math.max(0, overlapBottom - overlapTop)
|
||||
|
||||
const golden = [
|
||||
'# Plan chip and model trigger at the 800×720 viewport',
|
||||
'',
|
||||
'- Plan chip fully in viewport: ' + (chipInViewport ? 'true' : 'false'),
|
||||
'- Model trigger fully in viewport: ' + (triggerInViewport ? 'true' : 'false'),
|
||||
'- Click areas disjoint: ' + (overlapArea === 0 ? 'true' : 'false'),
|
||||
].join('\n').trimEnd()
|
||||
await compareOrRefreshGolden(LAYOUT_EXPECTED, golden, MODE)
|
||||
expect(overlapArea).toBe(0)
|
||||
expect(chipInViewport).toBe(true)
|
||||
expect(triggerInViewport).toBe(true)
|
||||
|
||||
// Exit through the real command channel: the click at the chip's center
|
||||
// executes /plan off and the folded projection flips inactive, so the chip
|
||||
// unmounts. Playwright's click() targets the element center by default and
|
||||
// its actionability check fails the click when that point is covered by
|
||||
// the model trigger — the reported bug as a failing click rather than a
|
||||
// coordinate probe.
|
||||
await chip.click()
|
||||
await expect.poll(() => page.getByRole('button', { name: CHIP_ARIA }).count(), { timeout: 15_000 }).toBe(0)
|
||||
// The click must have committed the exit: the last plan/mode event flips
|
||||
// inactive (the /plan command's entry event stays active:true earlier in
|
||||
// the log, so the pair proves the exit and not just the entry).
|
||||
const planModes = sessionEvents.filter(
|
||||
(event): event is SessionEvent<'plan/mode'> => event.type === 'plan/mode',
|
||||
)
|
||||
expect(planModes.at(-1)?.data.active).toBe(false)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 200_000)
|
||||
|
||||
it('keeps the snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'layout.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,7 @@
|
||||
// llm seam post-boot with installLlmReplay on the settled root ctx
|
||||
// (the plugin-row path discards the ReplayHandle; the direct install keeps
|
||||
// assertConsumed for the teardown fixture-consumption check).
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -179,7 +179,11 @@ export interface WebScaffold {
|
||||
harnessHome: string
|
||||
/** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */
|
||||
whenTurnSettled(timeoutMs?: number): Promise<SessionId>
|
||||
/** Tear everything down; asserts the replay fixture was fully consumed first (replay/refresh). */
|
||||
/**
|
||||
* Tear everything down; asserts the replay fixture was fully consumed first
|
||||
* (replay/refresh), unless booted with replayProvidersOnly (whose fixture
|
||||
* is validated call-free at boot).
|
||||
*/
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -196,9 +200,20 @@ export interface LaunchOptions {
|
||||
* in replay/refresh modes; ignored in record mode (the real adapter
|
||||
* answers). Omit for scenarios issuing no model calls — a stray stream then
|
||||
* fails loud with NO_ADAPTER (llm-deepseek is disabled and no replay row
|
||||
* mounts).
|
||||
* mounts). With {@link replayProvidersOnly}, the fixture must record no
|
||||
* model calls (its header alone mounts the catalog).
|
||||
*/
|
||||
replayFixture?: string
|
||||
/**
|
||||
* Mount the replay provider catalog (the model directory the UI shows)
|
||||
* without consuming any recorded script: for scenarios that never call a
|
||||
* model but need the real provider/model labels rendered. Requires
|
||||
* {@link replayFixture} whose log records no model calls, and rejects
|
||||
* {@link replayOverride} and {@link replayChildFixtures}; the teardown
|
||||
* consumption check is skipped for this mode. `replayFixture` without this
|
||||
* flag keeps the consumption check.
|
||||
*/
|
||||
replayProvidersOnly?: boolean
|
||||
/**
|
||||
* Recorded child logs assigned in child creation order. Each child owns its
|
||||
* own positional replay cursor across initial and continuation turns.
|
||||
@@ -547,6 +562,36 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// disable llm-deepseek; the first-run lane keeps it mounted but has no
|
||||
// replay fixture and never streams. The direct install, unlike the plugin
|
||||
// row, returns the ReplayHandle for the teardown consumption check.
|
||||
if (options.replayProvidersOnly) {
|
||||
if (options.replayFixture === undefined) {
|
||||
throw new Error('replayProvidersOnly requires replayFixture (its file supplies the header)')
|
||||
}
|
||||
const fixtureText = readFileSync(options.replayFixture, 'utf8')
|
||||
// The consumption check is skipped for this mode, so no script source
|
||||
// may carry callable entries: reject override/child sources outright
|
||||
// and any call-bearing fixture.
|
||||
if (options.replayOverride !== undefined || options.replayChildFixtures !== undefined) {
|
||||
throw new Error('replayProvidersOnly cannot combine with replayOverride or replayChildFixtures')
|
||||
}
|
||||
// A fixture without a session header row must not mount the catalog
|
||||
// silently: the consumption-skip assumes the header-only shape.
|
||||
let headerType: unknown
|
||||
try {
|
||||
headerType = (JSON.parse(fixtureText.trimStart().split('\n', 1)[0] ?? '') as { type?: unknown }).type
|
||||
} catch {
|
||||
headerType = undefined
|
||||
}
|
||||
if (headerType !== 'session') {
|
||||
throw new Error('replayProvidersOnly fixture must open with a session header row')
|
||||
}
|
||||
const recorded = parseSessionLog(fixtureText)
|
||||
const hasModelCall = recorded.some(event => (
|
||||
event.type === 'assistant/chunk' || event.type === 'request/header' || event.type === 'tool/call'
|
||||
))
|
||||
if (hasModelCall) {
|
||||
throw new Error('replayProvidersOnly fixture must record no model calls')
|
||||
}
|
||||
}
|
||||
if (mode !== 'record' && options.replayFixture !== undefined) {
|
||||
replayHandle = installLlmReplay(ctx, {
|
||||
file: options.replayFixture,
|
||||
@@ -608,11 +653,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
const failures: unknown[] = []
|
||||
// Fixture-consumption check first, while the run's binding state is
|
||||
// still authoritative — a scenario that drove fewer model calls than
|
||||
// recorded fails here instead of drifting green.
|
||||
try {
|
||||
replayHandle?.assertConsumed()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
// recorded fails here instead of drifting green. Skipped for
|
||||
// replayProvidersOnly, whose fixture is validated call-free at boot.
|
||||
if (!options.replayProvidersOnly) {
|
||||
try {
|
||||
replayHandle?.assertConsumed()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Assistant actions row with the feedback note popover open
|
||||
|
||||
| viewport | row overflow delta | row lines | items-outside delta | panel outside the column | panel within the viewport | panel-to-trigger gap |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1680px | 0px | 1 | 0 | true | true | 0px |
|
||||
| 1280px | 0px | 1 | 0 | true | true | 0px |
|
||||
| 1024px | 0px | 1 | 0 | true | true | 0px |
|
||||
| 900px | 0px | 1 | 0 | true | true | 0px |
|
||||
| 700px | 0px | 1 | 0 | true | true | 0px |
|
||||
| 600px | 0px | 1 | 0 | true | true | 0px |
|
||||
@@ -0,0 +1,5 @@
|
||||
# Plan chip and model trigger at the 800×720 viewport
|
||||
|
||||
- Plan chip fully in viewport: true
|
||||
- Model trigger fully in viewport: true
|
||||
- Click areas disjoint: true
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
|
||||
@@ -1,8 +1,8 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785456000000,"cwd":"{{cwd}}"}
|
||||
{"type":"user/message","seq":0,"time":1785456000001,"data":{"content":[{"type":"text","text":"Use web_search to search exactly \"DeepSeek Harness snapshot search\". Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":0,"time":1785456000001,"data":{"content":[{"type":"text","text":"Use web_search once with queries [\"DeepSeek Harness snapshot search\",\"DeepSeek Harness multi-query search\"]. Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"assistant/chunk","seq":1,"time":1785456000002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":2,"time":1785456000003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_web_search","name":"web_search","argumentsDelta":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1785456000004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_web_search","name":"web_search","arguments":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":2,"time":1785456000003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_web_search","name":"web_search","argumentsDelta":"{\"queries\":[\"DeepSeek Harness snapshot search\",\"DeepSeek Harness multi-query search\"]}"}}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1785456000004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_web_search","name":"web_search","arguments":"{\"queries\":[\"DeepSeek Harness snapshot search\",\"DeepSeek Harness multi-query search\"]}"}}}}
|
||||
{"type":"assistant/chunk","seq":4,"time":1785456000005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785456000006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1785456000007,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use web_search to search exactly" [disabled]
|
||||
- button "Use web_search once with queries" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- button "Session log":
|
||||
@@ -9,17 +9,17 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}}
|
||||
- text: Use web_search once with queries ["DeepSeek Harness snapshot search","DeepSeek Harness multi-query search"]. Then reply exactly SEARCH_DONE and stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Search DeepSeek Harness snapshot search":
|
||||
- button "Search DeepSeek Harness snapshot search, DeepSeek Harness multi-query search":
|
||||
- img
|
||||
- img
|
||||
- text: Search DeepSeek Harness snapshot search
|
||||
- text: Search DeepSeek Harness snapshot search, DeepSeek Harness multi-query search
|
||||
- paragraph: SEARCH_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
|
||||
@@ -22,32 +22,32 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/web-search-round', impor
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/web-search-round/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/web-search-round/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const QUERY = 'DeepSeek Harness snapshot search'
|
||||
const PROMPT = `Use web_search to search exactly "${QUERY}". Then reply exactly SEARCH_DONE and stop.`
|
||||
const QUERIES = ['DeepSeek Harness snapshot search', 'DeepSeek Harness multi-query search'] as const
|
||||
const PROMPT = `Use web_search once with queries ${JSON.stringify(QUERIES)}. Then reply exactly SEARCH_DONE and stop.`
|
||||
const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY')
|
||||
const SEARCH_CREDENTIAL = 'snapshot-search-key'
|
||||
|
||||
/**
|
||||
* Provider results the double returns, exceeding the shipped `searchMaxResults`
|
||||
* so the seam's cap and the card's scroll container are both exercised. Each row
|
||||
* carries a title, a snippet, and a date, so 8 kept rows exceed the `.sources`
|
||||
* 320px max-height.
|
||||
* Provider results the double returns per query. The combined result exceeds
|
||||
* the shipped `searchMaxResults`, so the tool's round-robin cap and the card's
|
||||
* scroll container are both exercised. Each row carries a title, a snippet,
|
||||
* and a date, so 8 kept rows exceed the `.sources` 320px max-height.
|
||||
*/
|
||||
const PROVIDER_RESULT_COUNT = 12
|
||||
const PROVIDER_RESULT_COUNT = 6
|
||||
|
||||
/** One provider result's URL, by 1-based provider order. */
|
||||
function resultUrl(ordinal: number): string {
|
||||
return `https://docs.example.test/search/${ordinal}`
|
||||
function resultUrl(queryIndex: number, ordinal: number): string {
|
||||
return `https://docs.example.test/search/${queryIndex + 1}/${ordinal}`
|
||||
}
|
||||
|
||||
/** One provider result's title, by 1-based provider order. */
|
||||
function resultTitle(ordinal: number): string {
|
||||
return `Snapshot Search Result ${ordinal}`
|
||||
function resultTitle(queryIndex: number, ordinal: number): string {
|
||||
return `Snapshot Search ${queryIndex + 1} Result ${ordinal}`
|
||||
}
|
||||
|
||||
/** One provider result's citation excerpt, by 1-based provider order. */
|
||||
function resultSnippet(ordinal: number): string {
|
||||
return `Snapshot search excerpt ${ordinal}: the harness replays this source list from a local endpoint.`
|
||||
function resultSnippet(queryIndex: number, ordinal: number): string {
|
||||
return `Snapshot search ${queryIndex + 1} excerpt ${ordinal}: the harness replays this source list from a local endpoint.`
|
||||
}
|
||||
|
||||
/** One provider result's `page_age`, by 1-based provider order (July 2026 days 01..12). */
|
||||
@@ -58,6 +58,19 @@ function resultPageAge(ordinal: number): string {
|
||||
/** The 1-based provider ordinals, in provider order. */
|
||||
const RESULT_ORDINALS = Array.from({ length: PROVIDER_RESULT_COUNT }, (_value, index) => index + 1)
|
||||
|
||||
/** Sources kept after round-robin merging reaches the shipped combined cap. */
|
||||
const KEPT_SOURCES = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map((_query, queryIndex) => ({
|
||||
url: resultUrl(queryIndex, ordinal),
|
||||
title: resultTitle(queryIndex, ordinal),
|
||||
snippet: resultSnippet(queryIndex, ordinal),
|
||||
publishedAt: resultPageAge(ordinal),
|
||||
}))).slice(0, WEB_SEARCH_MAX_RESULTS)
|
||||
|
||||
/** URLs omitted after the combined source cap is reached. */
|
||||
const DROPPED_SOURCE_URLS = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map(
|
||||
(_query, queryIndex) => resultUrl(queryIndex, ordinal),
|
||||
)).slice(WEB_SEARCH_MAX_RESULTS)
|
||||
|
||||
interface CapturedSearchRequest {
|
||||
path: string
|
||||
apiKey: string | undefined
|
||||
@@ -71,11 +84,19 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
const parsedBody = JSON.parse(body) as unknown
|
||||
captured.push({
|
||||
path: request.url ?? '',
|
||||
apiKey: typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : undefined,
|
||||
body: JSON.parse(body) as unknown,
|
||||
body: parsedBody,
|
||||
})
|
||||
const serializedBody = JSON.stringify(parsedBody)
|
||||
const queryIndex = QUERIES.findIndex(query => serializedBody.includes(`Perform a web search for the query: ${query}`))
|
||||
if (queryIndex < 0) {
|
||||
response.writeHead(400, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: 'unknown fixture query' }))
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
content: [
|
||||
@@ -84,16 +105,16 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s
|
||||
text: `Found ${PROVIDER_RESULT_COUNT} sources.`,
|
||||
citations: RESULT_ORDINALS.map(ordinal => ({
|
||||
type: 'web_search_result_location',
|
||||
url: resultUrl(ordinal),
|
||||
cited_text: resultSnippet(ordinal),
|
||||
url: resultUrl(queryIndex, ordinal),
|
||||
cited_text: resultSnippet(queryIndex, ordinal),
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: 'web_search_tool_result',
|
||||
content: RESULT_ORDINALS.map(ordinal => ({
|
||||
type: 'web_search_result',
|
||||
url: resultUrl(ordinal),
|
||||
title: resultTitle(ordinal),
|
||||
url: resultUrl(queryIndex, ordinal),
|
||||
title: resultTitle(queryIndex, ordinal),
|
||||
page_age: resultPageAge(ordinal),
|
||||
})),
|
||||
},
|
||||
@@ -173,28 +194,39 @@ describe('web e2e: shipped default web search', () => {
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('uses the real provider and persists the capped structured result', () => {
|
||||
expect(searchRequests).toHaveLength(1)
|
||||
expect(searchRequests[0]).toMatchObject({
|
||||
path: '/messages',
|
||||
apiKey: SEARCH_CREDENTIAL,
|
||||
body: {
|
||||
expect(searchRequests).toHaveLength(QUERIES.length)
|
||||
for (const query of QUERIES) {
|
||||
const request = searchRequests.find(candidate => JSON.stringify(candidate.body).includes(query))
|
||||
if (request === undefined) throw new Error(`missing provider request for query: ${query}`)
|
||||
expect(request).toMatchObject({ path: '/messages', apiKey: SEARCH_CREDENTIAL })
|
||||
expect(request.body).toMatchObject({
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Perform a web search for the query: ${QUERY}` }],
|
||||
content: [{ type: 'text', text: `Perform a web search for the query: ${query}` }],
|
||||
}],
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
const tools = (request.body as { tools?: unknown }).tools
|
||||
expect(tools).toHaveLength(1)
|
||||
expect((tools as unknown[])[0]).toMatchObject({ type: 'web_search_20250305', name: 'web_search' })
|
||||
}
|
||||
|
||||
const auxiliaryRequest = sessionEvents.find(
|
||||
const auxiliaryRequests = sessionEvents.filter(
|
||||
(event): event is Extract<SessionEvent, { type: 'web/deepseek-search-llm-request' }> =>
|
||||
event.type === 'web/deepseek-search-llm-request',
|
||||
)
|
||||
expect(auxiliaryRequest?.data).toEqual({
|
||||
endpoint: `${searchBaseURL}/messages`,
|
||||
apiVersion: '2023-06-01',
|
||||
body: searchRequests[0]?.body,
|
||||
})
|
||||
expect(auxiliaryRequests).toHaveLength(QUERIES.length)
|
||||
for (const query of QUERIES) {
|
||||
const request = searchRequests.find(candidate => JSON.stringify(candidate.body).includes(query))
|
||||
const auxiliaryRequest = auxiliaryRequests.find(event => JSON.stringify(event.data.body).includes(query))
|
||||
if (request === undefined || auxiliaryRequest === undefined) {
|
||||
throw new Error(`missing paired provider request for query: ${query}`)
|
||||
}
|
||||
expect(auxiliaryRequest.data).toEqual({
|
||||
endpoint: `${searchBaseURL}/messages`,
|
||||
apiVersion: '2023-06-01',
|
||||
body: request.body,
|
||||
})
|
||||
}
|
||||
|
||||
const searchCall = sessionEvents.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
|
||||
@@ -209,25 +241,19 @@ describe('web e2e: shipped default web search', () => {
|
||||
const content = searchResult.data.message.content[0]
|
||||
expect(content.isError).toBe(false)
|
||||
const rendered = content.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
// The seam caps the provider's list at the shipped searchMaxResults before
|
||||
// the tool renders it, so the kept prefix is model-visible and the dropped
|
||||
// suffix is not.
|
||||
for (const ordinal of RESULT_ORDINALS.slice(0, WEB_SEARCH_MAX_RESULTS)) {
|
||||
expect(rendered).toContain(`[${resultTitle(ordinal)}](${resultUrl(ordinal)})`)
|
||||
// The tool interleaves sources from both seam results before applying the
|
||||
// combined cap, so each query remains represented in model-visible output.
|
||||
for (const source of KEPT_SOURCES) {
|
||||
expect(rendered).toContain(`[${source.title}](${source.url})`)
|
||||
}
|
||||
for (const ordinal of RESULT_ORDINALS.slice(WEB_SEARCH_MAX_RESULTS)) {
|
||||
expect(rendered).not.toContain(resultUrl(ordinal))
|
||||
for (const url of DROPPED_SOURCE_URLS) {
|
||||
expect(rendered).not.toContain(url)
|
||||
}
|
||||
expect(rendered).toContain(
|
||||
`(Showing the first ${WEB_SEARCH_MAX_RESULTS} sources. Refine the query for more.)`,
|
||||
)
|
||||
expect(searchResult.data.meta).toMatchObject({
|
||||
sources: RESULT_ORDINALS.slice(0, WEB_SEARCH_MAX_RESULTS).map(ordinal => ({
|
||||
url: resultUrl(ordinal),
|
||||
title: resultTitle(ordinal),
|
||||
snippet: resultSnippet(ordinal),
|
||||
publishedAt: resultPageAge(ordinal),
|
||||
})),
|
||||
sources: KEPT_SOURCES,
|
||||
truncated: true,
|
||||
})
|
||||
})
|
||||
@@ -250,8 +276,7 @@ describe('web e2e: shipped default web search', () => {
|
||||
const card = page.locator('[data-web="search"]')
|
||||
const sources = card.locator('ol')
|
||||
await sources.waitFor({ timeout: 10_000 })
|
||||
// The card draws exactly the sources the model saw: the seam's cap, not the
|
||||
// provider's list length.
|
||||
// The card draws exactly the sources the model saw after the combined cap.
|
||||
expect(await sources.locator('li').count()).toBe(WEB_SEARCH_MAX_RESULTS)
|
||||
// The list is complete in the DOM, so the card carries no expand control.
|
||||
expect(await card.locator('button').count()).toBe(0)
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"tests/live-interactions.e2e.ts",
|
||||
"tests/question-composer.e2e.ts",
|
||||
"tests/approval-composer.e2e.ts",
|
||||
"tests/plan-control-row.e2e.ts",
|
||||
"tests/plan-review.e2e.ts",
|
||||
"tests/steering.e2e.ts",
|
||||
"tests/navigation-panes.e2e.ts",
|
||||
@@ -59,6 +60,7 @@
|
||||
"tests/web-search-round.e2e.ts",
|
||||
"tests/message-actions.e2e.ts",
|
||||
"tests/message-feedback.e2e.ts",
|
||||
"tests/message-feedback-layout.e2e.ts",
|
||||
"tests/markdown-images.e2e.ts",
|
||||
"tests/math-rendering.e2e.ts",
|
||||
"tests/markdown-cjk-strong.e2e.ts",
|
||||
|
||||
@@ -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/config-catalog.md
|
||||
config-catalog.md: fc8c694de61fa66b472b7b825fa0e984b1e4a044
|
||||
config-catalog.zh.md: bcef55e34e414de2233f3d0d0964683ea64b61ec
|
||||
config-catalog.md: 7cfd1c2f12b6478bec14b2d4a731475bd6e5bda0
|
||||
config-catalog.zh.md: dda0aa8e994fb28b82d8eafdd402160189aaf94d
|
||||
|
||||
@@ -1379,7 +1379,7 @@ export interface PlanModeConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts)
|
||||
Source: [`packages/plan/plan-mode/src/index.ts:71`](../packages/plan/plan-mode/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-pwsh-local"></a>
|
||||
|
||||
@@ -2114,7 +2114,7 @@ export interface Config {
|
||||
export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number]
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent-claude-code/src/index.ts:37`](../packages/subagent/subagent-claude-code/src/index.ts)
|
||||
Source: [`packages/subagent/subagent-claude-code/src/index.ts:38`](../packages/subagent/subagent-claude-code/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-subagent-codex"></a>
|
||||
|
||||
@@ -2145,7 +2145,7 @@ export type CodexPermissionMode =
|
||||
| 'dangerously-bypass-approvals-and-sandbox'
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts)
|
||||
Source: [`packages/subagent/subagent-codex/src/index.ts:36`](../packages/subagent/subagent-codex/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>
|
||||
|
||||
@@ -2796,7 +2796,7 @@ Source: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/s
|
||||
Requires: `tools` · `web` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */
|
||||
/** Plugin config: which web tools to register, search bounds, per-tool budgets, and the fetch output cap. */
|
||||
export interface Config {
|
||||
/** Register `web_search`. Defaults to true. */
|
||||
search?: boolean
|
||||
@@ -2804,6 +2804,8 @@ export interface Config {
|
||||
fetch?: boolean
|
||||
/** Upper bound on sources returned by one `web_search` call. */
|
||||
searchMaxResults?: number
|
||||
/** Upper bound on queries accepted by one `web_search` call. */
|
||||
searchMaxQueries?: number
|
||||
/** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
|
||||
fetchTimeoutMs?: number
|
||||
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
|
||||
|
||||
@@ -1381,7 +1381,7 @@ export interface PlanModeConfig {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts)
|
||||
来源:[`packages/plan/plan-mode/src/index.ts:71`](../packages/plan/plan-mode/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-pwsh-local"></a>
|
||||
|
||||
@@ -2116,7 +2116,7 @@ export interface Config {
|
||||
export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number]
|
||||
```
|
||||
|
||||
来源:[`packages/subagent/subagent-claude-code/src/index.ts:37`](../packages/subagent/subagent-claude-code/src/index.ts)
|
||||
来源:[`packages/subagent/subagent-claude-code/src/index.ts:38`](../packages/subagent/subagent-claude-code/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-subagent-codex"></a>
|
||||
|
||||
@@ -2147,7 +2147,7 @@ export type CodexPermissionMode =
|
||||
| 'dangerously-bypass-approvals-and-sandbox'
|
||||
```
|
||||
|
||||
来源:[`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts)
|
||||
来源:[`packages/subagent/subagent-codex/src/index.ts:36`](../packages/subagent/subagent-codex/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>
|
||||
|
||||
@@ -2800,7 +2800,7 @@ export interface Config {
|
||||
需要:`tools` · `web` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: which web tools to register, the source cap, per-tool budgets, and the fetch output cap. */
|
||||
/** Plugin config: which web tools to register, search bounds, per-tool budgets, and the fetch output cap. */
|
||||
export interface Config {
|
||||
/** Register `web_search`. Defaults to true. */
|
||||
search?: boolean
|
||||
@@ -2808,6 +2808,8 @@ export interface Config {
|
||||
fetch?: boolean
|
||||
/** Upper bound on sources returned by one `web_search` call. */
|
||||
searchMaxResults?: number
|
||||
/** Upper bound on queries accepted by one `web_search` call. */
|
||||
searchMaxQueries?: number
|
||||
/** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
|
||||
fetchTimeoutMs?: number
|
||||
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
|
||||
|
||||
@@ -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/event-producer-consumer.md
|
||||
event-producer-consumer.md: c7b474a15c701781a70019f0703c0d60da87bcae
|
||||
event-producer-consumer.zh.md: 2e0667288ea44f83b7030e686db194f04d550a9b
|
||||
event-producer-consumer.md: c906ee6329fac66e7391c266213dd150dd5b8e09
|
||||
event-producer-consumer.zh.md: 77bf401b7215bd263c0d84f04e0eabe6b28b7915
|
||||
|
||||
@@ -22,7 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, [`team`](../packages/experimental/team) |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:72`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
|
||||
| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
|
||||
| `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
|
||||
| `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, [`team`](../packages/experimental/team) |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:72`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
|
||||
| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
|
||||
| `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
|
||||
| `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:391`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
|
||||
|
||||
@@ -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/module-graph.md
|
||||
module-graph.md: 207a4ae20f24e4ff369ac154272b916fe6a2c7da
|
||||
module-graph.zh.md: fdb05d6f92184ec72bdbe59e4022313d91827aa7
|
||||
module-graph.md: b50b1de343ed633bd882f3d73324f7b828eb1980
|
||||
module-graph.zh.md: a808ab1b61f1acb83833809b42c5ecab47ad87b9
|
||||
|
||||
@@ -522,8 +522,10 @@ flowchart TD
|
||||
pkg_message_feedback --> pkg_storage_domain
|
||||
pkg_message_feedback --> pkg_typert_protocol
|
||||
pkg_commands --> pkg_agent
|
||||
pkg_commands --> pkg_attachment
|
||||
pkg_commands --> pkg_brand
|
||||
pkg_commands --> pkg_invariants
|
||||
pkg_commands --> pkg_llm
|
||||
pkg_commands --> pkg_scope
|
||||
pkg_commands --> pkg_session
|
||||
pkg_commands --> pkg_typert_protocol
|
||||
@@ -614,6 +616,7 @@ flowchart TD
|
||||
pkg_command_goal --> pkg_commands
|
||||
pkg_command_goal --> pkg_goal
|
||||
pkg_command_goal --> pkg_invariants
|
||||
pkg_command_goal --> pkg_llm
|
||||
pkg_goal_round_driver --> pkg_agent
|
||||
pkg_goal_round_driver --> pkg_goal
|
||||
pkg_goal_round_driver --> pkg_invariants
|
||||
@@ -1491,7 +1494,7 @@ flowchart TD
|
||||
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
@@ -1510,7 +1513,7 @@ flowchart TD
|
||||
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) |
|
||||
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
|
||||
@@ -524,8 +524,10 @@ flowchart TD
|
||||
pkg_message_feedback --> pkg_storage_domain
|
||||
pkg_message_feedback --> pkg_typert_protocol
|
||||
pkg_commands --> pkg_agent
|
||||
pkg_commands --> pkg_attachment
|
||||
pkg_commands --> pkg_brand
|
||||
pkg_commands --> pkg_invariants
|
||||
pkg_commands --> pkg_llm
|
||||
pkg_commands --> pkg_scope
|
||||
pkg_commands --> pkg_session
|
||||
pkg_commands --> pkg_typert_protocol
|
||||
@@ -616,6 +618,7 @@ flowchart TD
|
||||
pkg_command_goal --> pkg_commands
|
||||
pkg_command_goal --> pkg_goal
|
||||
pkg_command_goal --> pkg_invariants
|
||||
pkg_command_goal --> pkg_llm
|
||||
pkg_goal_round_driver --> pkg_agent
|
||||
pkg_goal_round_driver --> pkg_goal
|
||||
pkg_goal_round_driver --> pkg_invariants
|
||||
@@ -1493,7 +1496,7 @@ flowchart TD
|
||||
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
@@ -1512,7 +1515,7 @@ flowchart TD
|
||||
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) |
|
||||
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
|
||||
@@ -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/persistence-catalog.md
|
||||
persistence-catalog.md: cf796ca322027886b1a0b78d69ac1d3a98d9459f
|
||||
persistence-catalog.zh.md: 5f82254813ef6fb0b3c9244a2bd03fb2177e5559
|
||||
persistence-catalog.md: b680bccf22f7840663e5268eb3feeb6b16f7fd42
|
||||
persistence-catalog.zh.md: 4b50582fa55dbdc672d8c45debe108b97a55f0e2
|
||||
|
||||
@@ -256,7 +256,7 @@ Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/interaction/commands/src/types.ts:95`](../packages/interaction/commands/src/types.ts)
|
||||
Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts)
|
||||
|
||||
<a id="commandrun--log-only"></a>
|
||||
|
||||
@@ -276,7 +276,7 @@ Source: [`packages/interaction/commands/src/types.ts:95`](../packages/interactio
|
||||
'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
|
||||
```
|
||||
|
||||
Source: [`packages/interaction/commands/src/types.ts:88`](../packages/interaction/commands/src/types.ts)
|
||||
Source: [`packages/interaction/commands/src/types.ts:96`](../packages/interaction/commands/src/types.ts)
|
||||
|
||||
### `compaction/*`
|
||||
|
||||
@@ -527,7 +527,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/
|
||||
'plan/mode': { active: boolean }
|
||||
```
|
||||
|
||||
Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts)
|
||||
Source: [`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/src/index.ts)
|
||||
|
||||
### `request/*`
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/interaction/commands/src/types.ts:95`](../packages/interaction/commands/src/types.ts)
|
||||
来源:[`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts)
|
||||
|
||||
<a id="commandrun--log-only"></a>
|
||||
|
||||
@@ -278,7 +278,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
|
||||
```
|
||||
|
||||
来源:[`packages/interaction/commands/src/types.ts:88`](../packages/interaction/commands/src/types.ts)
|
||||
来源:[`packages/interaction/commands/src/types.ts:96`](../packages/interaction/commands/src/types.ts)
|
||||
|
||||
### `compaction/*`
|
||||
|
||||
@@ -529,7 +529,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
'plan/mode': { active: boolean }
|
||||
```
|
||||
|
||||
来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts)
|
||||
来源:[`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/src/index.ts)
|
||||
|
||||
### `request/*`
|
||||
|
||||
|
||||
@@ -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/subsystems/attachment.md
|
||||
attachment.md: 21e60dbc40504f22229ef98a2dd112eda82fffdd
|
||||
attachment.zh.md: 886e569b6db9f2a5b1dca39125785d8286e22c7a
|
||||
attachment.md: 748d3feb47ff6bdf2ab6849f2509a3c495fcd87a
|
||||
attachment.zh.md: eed28affb2c40726a088ddf2f0e17077d5a22df6
|
||||
|
||||
@@ -52,6 +52,18 @@ The reference records intrinsic dimensions and encoded length so clients can lay
|
||||
|
||||
## Commit and verified-read payloads
|
||||
|
||||
```ts type-equiv
|
||||
/** Base64-encoded image upload accompanying one wire request. */
|
||||
interface EncodedImageAttachment {
|
||||
/** Declared media type, verified against the decoded bytes during admission. */
|
||||
mediaType: ImageMediaType
|
||||
/** Canonical base64 encoding of the image bytes. */
|
||||
data: string
|
||||
/** Optional display name; it is never interpreted as a path. */
|
||||
name?: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Request to validate and durably commit one image. */
|
||||
interface SaveImageAttachment {
|
||||
@@ -71,7 +83,7 @@ interface StoredImageAttachment {
|
||||
}
|
||||
```
|
||||
|
||||
`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion.
|
||||
`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `admitEncodedImages()` is the wire entry for base64 uploads: it enforces canonical base64, then delegates batch admission to `saveImages()`, which owns the count and aggregate-byte limits and the validate-all-before-save order. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -123,5 +135,5 @@ abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
|
||||
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
|
||||
```
|
||||
|
||||
Source: [`packages/attachment/attachment/src/index.ts:31`](../../packages/attachment/attachment/src/index.ts)
|
||||
Source: [`packages/attachment/attachment/src/index.ts:33`](../../packages/attachment/attachment/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -52,6 +52,18 @@ interface ImageAttachmentLimits {
|
||||
|
||||
## 提交与经校验读取的数据
|
||||
|
||||
```ts type-equiv
|
||||
/** Base64-encoded image upload accompanying one wire request. */
|
||||
interface EncodedImageAttachment {
|
||||
/** Declared media type, verified against the decoded bytes during admission. */
|
||||
mediaType: ImageMediaType
|
||||
/** Canonical base64 encoding of the image bytes. */
|
||||
data: string
|
||||
/** Optional display name; it is never interpreted as a path. */
|
||||
name?: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Request to validate and durably commit one image. */
|
||||
interface SaveImageAttachment {
|
||||
@@ -71,7 +83,7 @@ interface StoredImageAttachment {
|
||||
}
|
||||
```
|
||||
|
||||
`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。
|
||||
`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`admitEncodedImages()` 是面向 base64 上传的 wire 入口:强制执行规范 base64,随后把批量准入委托给 `saveImages()`,由后者负责张数与聚合字节上限以及先全量校验再保存的顺序。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -123,5 +135,5 @@ abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
|
||||
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
|
||||
```
|
||||
|
||||
Source: [`packages/attachment/attachment/src/index.ts:31`](../../packages/attachment/attachment/src/index.ts)
|
||||
Source: [`packages/attachment/attachment/src/index.ts:33`](../../packages/attachment/attachment/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -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/subsystems/commands.md
|
||||
commands.md: a4589d875fafdda7404e8c2d54fb739a4e945990
|
||||
commands.zh.md: 460784442257cc081fb73646c51885a432efadb5
|
||||
commands.md: a9752915d4eae22d448b5480f2d50746fd02ec5f
|
||||
commands.zh.md: 2fe3d71bafaf6653251a8ab53832dd4701152194
|
||||
|
||||
@@ -8,13 +8,21 @@ Source: [`packages/interaction/commands/src/index.ts`](../../packages/interactio
|
||||
|
||||
## Input metadata
|
||||
|
||||
The service exposes one optional unstructured-input hint. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition.
|
||||
The service exposes one optional unstructured-input descriptor: a hint plus an image-acceptance flag. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition.
|
||||
|
||||
```ts type-equiv
|
||||
/** Immutable metadata for a command's optional unstructured input. */
|
||||
interface CommandInputDescriptor {
|
||||
/** Placeholder shown before the user supplies free-form input. */
|
||||
readonly hint: string
|
||||
/**
|
||||
* Whether composer image attachments may accompany an invocation. Absent or
|
||||
* false = the executor rejects an invocation carrying images and capable
|
||||
* composers refuse the submission before dispatch. A declaring command's
|
||||
* handler receives the admitted durable blocks and owns every further
|
||||
* grammar decision, including rejecting sub-commands that cannot use them.
|
||||
*/
|
||||
readonly images?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
@@ -55,6 +63,14 @@ interface CommandInvocation {
|
||||
readonly agent: Agent
|
||||
/** Exact text following the registered command name, including separator whitespace. */
|
||||
readonly rawInput: string
|
||||
/**
|
||||
* Durably admitted image blocks accompanying this invocation, in submission
|
||||
* order; empty unless the definition declares `input.images`. The handler
|
||||
* owns their model-visible use — the registry never schedules them itself —
|
||||
* and a handler whose grammar cannot use them in this invocation returns an
|
||||
* error so the dispatching composer retains the originals.
|
||||
*/
|
||||
readonly attachments: readonly ImageBlock[]
|
||||
/** Cancellation signal owned by the dispatching UI request. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
@@ -150,18 +166,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined
|
||||
* handler-failure path is contained so the handler's own error stays the
|
||||
* reported failure.
|
||||
*
|
||||
* Image admission is enforced here, not in the composer: images sent to a
|
||||
* command that does not declare `input.images`, an absent attachment store,
|
||||
* and an exceeded attachment limit each settle as an error result before
|
||||
* the handler runs, and a rejected batch publishes no durable object.
|
||||
*
|
||||
* @param agent - exact receiving agent.
|
||||
* @param line - complete slash-command line.
|
||||
* @param images - base64-encoded composer images accompanying the line, in
|
||||
* submission order; empty for a plain invocation.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns the settled execution (result + lifecycle pairing id), or
|
||||
* `undefined` when syntax or name does not resolve.
|
||||
*/
|
||||
@Remote async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<CommandExecution | undefined>
|
||||
@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise<CommandExecution | undefined>
|
||||
```
|
||||
|
||||
Types: [Agent](core.md)
|
||||
Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md)
|
||||
|
||||
Source: [`packages/interaction/commands/src/index.ts:225`](../../packages/interaction/commands/src/index.ts)
|
||||
Source: [`packages/interaction/commands/src/index.ts:250`](../../packages/interaction/commands/src/index.ts)
|
||||
|
||||
<a id="commands-events"></a>
|
||||
|
||||
@@ -183,5 +206,5 @@ A command was registered or unregistered. This is an unfiltered registry notific
|
||||
'commands/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/interaction/commands/src/types.ts:72`](../../packages/interaction/commands/src/types.ts)
|
||||
Source: [`packages/interaction/commands/src/types.ts:80`](../../packages/interaction/commands/src/types.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -8,13 +8,21 @@
|
||||
|
||||
## 输入元数据
|
||||
|
||||
该服务公开一个可选的非结构化输入提示。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。
|
||||
该服务公开一个可选的非结构化输入描述符:提示文本加图片接受标志。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。
|
||||
|
||||
```ts type-equiv
|
||||
/** Immutable metadata for a command's optional unstructured input. */
|
||||
interface CommandInputDescriptor {
|
||||
/** Placeholder shown before the user supplies free-form input. */
|
||||
readonly hint: string
|
||||
/**
|
||||
* Whether composer image attachments may accompany an invocation. Absent or
|
||||
* false = the executor rejects an invocation carrying images and capable
|
||||
* composers refuse the submission before dispatch. A declaring command's
|
||||
* handler receives the admitted durable blocks and owns every further
|
||||
* grammar decision, including rejecting sub-commands that cannot use them.
|
||||
*/
|
||||
readonly images?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
@@ -55,6 +63,14 @@ interface CommandInvocation {
|
||||
readonly agent: Agent
|
||||
/** Exact text following the registered command name, including separator whitespace. */
|
||||
readonly rawInput: string
|
||||
/**
|
||||
* Durably admitted image blocks accompanying this invocation, in submission
|
||||
* order; empty unless the definition declares `input.images`. The handler
|
||||
* owns their model-visible use — the registry never schedules them itself —
|
||||
* and a handler whose grammar cannot use them in this invocation returns an
|
||||
* error so the dispatching composer retains the originals.
|
||||
*/
|
||||
readonly attachments: readonly ImageBlock[]
|
||||
/** Cancellation signal owned by the dispatching UI request. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
@@ -150,18 +166,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined
|
||||
* handler-failure path is contained so the handler's own error stays the
|
||||
* reported failure.
|
||||
*
|
||||
* Image admission is enforced here, not in the composer: images sent to a
|
||||
* command that does not declare `input.images`, an absent attachment store,
|
||||
* and an exceeded attachment limit each settle as an error result before
|
||||
* the handler runs, and a rejected batch publishes no durable object.
|
||||
*
|
||||
* @param agent - exact receiving agent.
|
||||
* @param line - complete slash-command line.
|
||||
* @param images - base64-encoded composer images accompanying the line, in
|
||||
* submission order; empty for a plain invocation.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns the settled execution (result + lifecycle pairing id), or
|
||||
* `undefined` when syntax or name does not resolve.
|
||||
*/
|
||||
@Remote async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<CommandExecution | undefined>
|
||||
@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise<CommandExecution | undefined>
|
||||
```
|
||||
|
||||
Types: [Agent](core.md)
|
||||
Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md)
|
||||
|
||||
Source: [`packages/interaction/commands/src/index.ts:225`](../../packages/interaction/commands/src/index.ts)
|
||||
Source: [`packages/interaction/commands/src/index.ts:250`](../../packages/interaction/commands/src/index.ts)
|
||||
|
||||
<a id="commands-events"></a>
|
||||
|
||||
@@ -183,5 +206,5 @@ A command was registered or unregistered. This is an unfiltered registry notific
|
||||
'commands/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/interaction/commands/src/types.ts:72`](../../packages/interaction/commands/src/types.ts)
|
||||
Source: [`packages/interaction/commands/src/types.ts:80`](../../packages/interaction/commands/src/types.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -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/subsystems/plan.md
|
||||
plan.md: 4e6eb98e7c7cce295feeed0150984934f1a853e5
|
||||
plan.zh.md: f8236e6cbeca841bdab630aa831e844cc68179a0
|
||||
plan.md: 1f6863a24aa56773430be904e5a27c27384c9bff
|
||||
plan.zh.md: 056bce946b608876ac958f2d33d871e9622c7187
|
||||
|
||||
@@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop
|
||||
|
||||
Types: [Agent](core.md)
|
||||
|
||||
Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts)
|
||||
Source: [`packages/plan/plan-mode/src/index.ts:188`](../../packages/plan/plan-mode/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop
|
||||
|
||||
Types: [Agent](core.md)
|
||||
|
||||
Source: [`packages/plan/plan-mode/src/index.ts:184`](../../packages/plan/plan-mode/src/index.ts)
|
||||
Source: [`packages/plan/plan-mode/src/index.ts:188`](../../packages/plan/plan-mode/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -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/subsystems/web.md
|
||||
web.md: 3bcd3ac24927c8c51baeabd770e2bd91c5ad1b77
|
||||
web.zh.md: 3348be2b808dc286364f5a795b236cb299acd6a6
|
||||
web.md: 7a5a95e0c465924b06696ff83204a82af574f593
|
||||
web.zh.md: 1eb5915b46fa01f9e79437bccee786939358b4db
|
||||
|
||||
@@ -12,13 +12,14 @@ Search and fetch share no request schema and no business logic, but they are del
|
||||
|
||||
## Search request and result
|
||||
|
||||
The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`.
|
||||
Each seam request carries exactly one `query`. The `dsh-tool-web` consumer accepts a required `queries` array and fans it out into separate seam requests; a one-item array performs one search. `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* What one search-capable backend can return. The model-facing argument is just
|
||||
* a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged
|
||||
* and enforced on the way back by the seam (see {@link WebSearchResult}).
|
||||
* What one search-capable backend is asked to search. Each request carries one
|
||||
* query; a consumer may issue several requests. `maxResults` is a
|
||||
* `dsh-tool-web`-layer bound passed through unchanged and enforced on the way
|
||||
* back by the seam (see {@link WebSearchResult}).
|
||||
*/
|
||||
interface WebSearchRequest {
|
||||
readonly query: string
|
||||
|
||||
@@ -12,13 +12,14 @@ Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architect
|
||||
|
||||
## 搜索请求与结果
|
||||
|
||||
面向模型的工具参数仅为一个 `query`;`maxResults` 是消费方自有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `8`),通过 seam 传递并在返回时强制执行——如果提供方返回超量,seam 截断 `sources[]` 并设置 `truncated`。
|
||||
每个 seam 请求只携带一个 `query`。消费方 `dsh-tool-web` 接受必填的 `queries` 数组,并把它扇出为多个独立 seam 请求;单元素数组执行一次搜索。`maxResults` 是消费方自有的上限(`dsh-tool-web` 的 `searchMaxResults` 配置,默认 `8`),通过 seam 传递并在返回时强制执行——如果提供方返回超量,seam 截断 `sources[]` 并设置 `truncated`。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* What one search-capable backend can return. The model-facing argument is just
|
||||
* a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged
|
||||
* and enforced on the way back by the seam (see {@link WebSearchResult}).
|
||||
* What one search-capable backend is asked to search. Each request carries one
|
||||
* query; a consumer may issue several requests. `maxResults` is a
|
||||
* `dsh-tool-web`-layer bound passed through unchanged and enforced on the way
|
||||
* back by the seam (see {@link WebSearchResult}).
|
||||
*/
|
||||
interface WebSearchRequest {
|
||||
readonly query: string
|
||||
|
||||
@@ -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/tool-catalog.md
|
||||
tool-catalog.md: b3f59ed76ad1a26a4da207c52bf0e64c40148a94
|
||||
tool-catalog.zh.md: 27ae60554393fc390386b7b0aef64f6d0758cf28
|
||||
tool-catalog.md: 3ffc2f1c4211b93812e240d2a02a23d70b7ef5fe
|
||||
tool-catalog.zh.md: e5751ae05d019b88f26cd65a9588052b7c73c390
|
||||
|
||||
@@ -2168,19 +2168,22 @@ Source: [`packages/web/tool-web/src/index.ts`](../packages/web/tool-web/src/inde
|
||||
|
||||
### `web_search`
|
||||
|
||||
Search the web for current information. Returns an optional summary answer and a list of source URLs.
|
||||
Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query."
|
||||
"queries": {
|
||||
"type": "array",
|
||||
"description": "Required search queries; accepts 1–4 items and merges their results.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
"queries"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -2172,19 +2172,22 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
|
||||
|
||||
### `web_search`
|
||||
|
||||
在 Web 上搜索最新信息。返回可选的摘要答案和源 URL 列表。
|
||||
在 Web 上搜索最新信息。在必填的 `queries` 数组中提供 1–4 个查询。返回可选的摘要答案和来源 URL 列表。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query."
|
||||
"queries": {
|
||||
"type": "array",
|
||||
"description": "Required search queries; accepts 1–4 items and merges their results.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"query"
|
||||
"queries"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
+28
-10
@@ -11,7 +11,28 @@ import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
export const name = 'subagent-result-diagnostic'
|
||||
export const inject = ['subagents']
|
||||
|
||||
const DIAGNOSTIC = 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt'
|
||||
const RESULTS = [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-0000000000d1',
|
||||
diagnostic: 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)',
|
||||
output: [{ type: 'text' as const, text: 'partial assistant text' }],
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-0000000000d2',
|
||||
diagnostic: 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)',
|
||||
output: [],
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-0000000000d3',
|
||||
diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)',
|
||||
output: [{ type: 'text' as const, text: 'partial assistant text' }],
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-4000-8000-0000000000d4',
|
||||
diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)',
|
||||
output: [],
|
||||
},
|
||||
] as const
|
||||
|
||||
class DiagnosticProvider implements SubagentProvider {
|
||||
readonly name = 'snapshot-diagnostic'
|
||||
@@ -24,19 +45,16 @@ class DiagnosticProvider implements SubagentProvider {
|
||||
throw new Error('snapshot diagnostic provider start aborted')
|
||||
}
|
||||
const index = this.starts++
|
||||
if (index > 1) {
|
||||
throw new Error('snapshot diagnostic provider expected exactly two starts')
|
||||
const fixture = RESULTS[index]
|
||||
if (fixture === undefined) {
|
||||
throw new Error('snapshot diagnostic provider expected exactly four starts')
|
||||
}
|
||||
return {
|
||||
id: SessionId(index === 0
|
||||
? '00000000-0000-4000-8000-0000000000d1'
|
||||
: '00000000-0000-4000-8000-0000000000d2'),
|
||||
id: SessionId(fixture.id),
|
||||
localAgent: undefined,
|
||||
result: Promise.resolve({
|
||||
output: index === 0
|
||||
? [{ type: 'text' as const, text: 'partial assistant text' }]
|
||||
: [],
|
||||
diagnostic: DIAGNOSTIC,
|
||||
output: [...fixture.output],
|
||||
diagnostic: fixture.diagnostic,
|
||||
stopReason: 'error' as const,
|
||||
}),
|
||||
dispose: async () => {},
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools." }
|
||||
{ "op": "prompt", "text": "Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools." }
|
||||
]
|
||||
}
|
||||
|
||||
+36
-6
@@ -3,8 +3,8 @@
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" } },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_claude_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
@@ -13,8 +13,8 @@
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" } },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_claude_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
@@ -23,8 +23,38 @@
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_claude_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_codex_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_codex_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_codex_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-2\",\"wait\":true}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-2\",\"wait\":true}" } },
|
||||
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
|
||||
{ "type": "finish", "reason": { "kind": "tool-calls" } }
|
||||
]
|
||||
|
||||
+61
-28
@@ -1,51 +1,84 @@
|
||||
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"}]}}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"eb9f20a0-9eac-480c-9904-71a1ffbb742a"}]}}
|
||||
{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"eb9f20a0-9eac-480c-9904-71a1ffbb742a"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Use subagent_codex in the foreground","messageSeqs":[4],"source":{"kind":"fallback"}}}
|
||||
{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Observe four diagnostic failures with","messageSeqs":[4],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"92e33995-2f02-4ad5-aec1-9df82cf4d583"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}
|
||||
{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_diagnostic_foreground"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"4e84e7b3-40c1-488e-b119-45e8bd7ce448"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"3cc2d0b5-97a5-4685-af60-ed7f7db8f69a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}}
|
||||
{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_claude_foreground"},"content":[{"type":"tool-result","toolCallId":"call_claude_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"8743817e-158e-45cb-88d9-a695b2653eca"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":17,"time":1786781990613,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":18,"time":1786781990618,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":19,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}}}}
|
||||
{"type":"assistant/chunk","seq":22,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":23,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"2fb444e2-7a52-4963-988e-b1ecbc3744d5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}
|
||||
{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"}]}}
|
||||
{"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_diagnostic_background"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"3377f724-b4a7-4ce1-bed7-774f174917d6"}},"sourceEventSeqs":[25],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"b504312a-1dc5-46ce-87a5-12a5817511b9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}}
|
||||
{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe Claude background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Claude background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cl…"},"role":"user","id":"0fdb9ddf-1657-4455-9941-e6a9daa8ae4a"}]}}
|
||||
{"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_claude_background"},"content":[{"type":"tool-result","toolCallId":"call_claude_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"fe60646b-0551-4703-aa03-c8cb5460d356"}},"sourceEventSeqs":[25],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":28,"time":1786781990627,"data":{"turn":1,"step":2}}
|
||||
{"type":"agent/inbox/spliced","seq":29,"time":1786781990627,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":30,"time":1786781990632,"data":{"turn":1,"step":3}}
|
||||
{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe Claude background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Claude background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cl…"},"role":"user","id":"0fdb9ddf-1657-4455-9941-e6a9daa8ae4a"},"surfaceOp":"append"}
|
||||
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}
|
||||
{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}
|
||||
{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}
|
||||
{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}
|
||||
{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f43f988b-bc08-4811-8671-8edc0613f0d0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}
|
||||
{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_diagnostic_output"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]"}],"isError":false}],"role":"user","id":"6785120f-ae46-48d0-9f3f-d6cd1e6fc5d7"}},"sourceEventSeqs":[38],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"c48a520a-74ed-42ee-9d93-ee59899975b0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}
|
||||
{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_claude_output"},"content":[{"type":"tool-result","toolCallId":"call_claude_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]"}],"isError":false}],"role":"user","id":"45bc0705-7243-4173-a119-4c0655af8dc1"}},"sourceEventSeqs":[38],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":40,"time":1786781990640,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":41,"time":1786781990645,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":43,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DIAGNOSTICS"}}}
|
||||
{"type":"assistant/chunk","seq":44,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}}
|
||||
{"type":"assistant/chunk","seq":45,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
|
||||
{"type":"assistant/chunk","seq":46,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":47,"time":1786781990649,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"49b868e8-2608-47e0-aaf8-b308ffe8194d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":48,"time":1786781990650,"data":{"turn":1,"step":4}}
|
||||
{"type":"turn/end","seq":49,"time":1786781990650,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":43,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}}}
|
||||
{"type":"assistant/chunk","seq":44,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}}}}
|
||||
{"type":"assistant/chunk","seq":45,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":46,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":47,"time":1786781990649,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"89ab3728-fc3f-4825-97e5-383d46568d8c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":48,"time":1786994591759,"data":{"turn":1,"step":4,"callId":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}}
|
||||
{"type":"tool/result","seq":49,"time":1786994591762,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_codex_foreground"},"content":[{"type":"tool-result","toolCallId":"call_codex_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"0a8fd87c-eacb-457b-a5ad-29dd88f599aa"}},"sourceEventSeqs":[48],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":50,"time":1786994591762,"data":{"turn":1,"step":4}}
|
||||
{"type":"step/start","seq":51,"time":1786994591767,"data":{"turn":1,"step":5}}
|
||||
{"type":"assistant/chunk","seq":52,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":53,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}}}
|
||||
{"type":"assistant/chunk","seq":54,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}}}}
|
||||
{"type":"assistant/chunk","seq":55,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":56,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":57,"time":1786994591771,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"996da601-acb8-49c9-8dd7-e60a88a8f1a2"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[52,53,54,55,56],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":58,"time":1786994591772,"data":{"turn":1,"step":5,"callId":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}}
|
||||
{"type":"agent/inbox/spliced","seq":59,"time":1786994591775,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-2 (subagent: Observe Codex background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Codex background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cod…"},"role":"user","id":"5f1d4517-50d4-48ef-8acc-8f9361ecb185"}]}}
|
||||
{"type":"tool/result","seq":60,"time":1786994591775,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"call_codex_background"},"content":[{"type":"tool-result","toolCallId":"call_codex_background","content":[{"type":"text","text":"started background subagent job subagent-2"}],"isError":false}],"role":"user","id":"a8ba8362-275b-4bca-8b88-d1ef84d325a3"}},"sourceEventSeqs":[58],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":61,"time":1786994591776,"data":{"turn":1,"step":5}}
|
||||
{"type":"agent/inbox/spliced","seq":62,"time":1786994591776,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":63,"time":1786994591781,"data":{"turn":1,"step":6}}
|
||||
{"type":"user/message","seq":64,"time":1786994591781,"data":{"content":[{"type":"text","text":"background job subagent-2 (subagent: Observe Codex background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Codex background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cod…"},"role":"user","id":"5f1d4517-50d4-48ef-8acc-8f9361ecb185"},"surfaceOp":"append"}
|
||||
{"type":"assistant/chunk","seq":65,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":66,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-2\",\"wait\":true}"}}}
|
||||
{"type":"assistant/chunk","seq":67,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}}}}
|
||||
{"type":"assistant/chunk","seq":68,"time":1786994591789,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":69,"time":1786994591789,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":70,"time":1786994591789,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"cfc1726c-5d9e-486a-aa0f-057219e16dfd"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":71,"time":1786994591789,"data":{"turn":1,"step":6,"callId":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}}
|
||||
{"type":"tool/result","seq":72,"time":1786994591797,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"call_codex_output"},"content":[{"type":"tool-result","toolCallId":"call_codex_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]"}],"isError":false}],"role":"user","id":"9671e5ee-f443-4548-8fd2-b0b76f00b629"}},"sourceEventSeqs":[71],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":73,"time":1786994591797,"data":{"turn":1,"step":6}}
|
||||
{"type":"step/start","seq":74,"time":1786994591802,"data":{"turn":1,"step":7}}
|
||||
{"type":"assistant/chunk","seq":75,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":76,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DIAGNOSTICS"}}}
|
||||
{"type":"assistant/chunk","seq":77,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}}
|
||||
{"type":"assistant/chunk","seq":78,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
|
||||
{"type":"assistant/chunk","seq":79,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":80,"time":1786994591806,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"49b868e8-2608-47e0-aaf8-b308ffe8194d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[75,76,77,78,79],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":81,"time":1786994591806,"data":{"turn":1,"step":7}}
|
||||
{"type":"turn/end","seq":82,"time":1786994591807,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
|
||||
@@ -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/attachment/attachment/README.md
|
||||
README.md: 4fe608552492c33d2bd9acddce51ea1cf20acae4
|
||||
README.zh.md: a3093fc9dd1f926cb1c54831c6302eb7bbca25c5
|
||||
README.md: 19232bd4bb86ed33e56fcdca93999967822422ab
|
||||
README.zh.md: e5e7aab7c1af30b2b101bdcd218044cd1095ae0d
|
||||
|
||||
@@ -6,6 +6,8 @@ The durable attachment seam. `ctx.attachments` validates and durably commits imm
|
||||
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
|
||||
|
||||
`admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference.
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
|
||||
|
||||
`admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。
|
||||
|
||||
## 模型体验
|
||||
|
||||
该包通过角色无关的核心 `ImageBlock`,以及解析其持久引用的提供方适配器,间接影响模型。
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
"exports": {
|
||||
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
|
||||
"./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" },
|
||||
"./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" },
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"],
|
||||
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts"],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Wire-form admission of base64-encoded image uploads. @module @deepseek-ai/dsh-attachment/admission */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { AttachmentError } from './error.ts'
|
||||
import type { AttachmentStore } from './index.ts'
|
||||
import type { EncodedImageAttachment, ImageAttachmentRef, SaveImageAttachment } from './types.ts'
|
||||
|
||||
/** Decode one upload payload while rejecting non-canonical base64 forms. */
|
||||
function decodeBase64(data: string): Uint8Array {
|
||||
const decoded = Buffer.from(data, 'base64')
|
||||
if (data.length === 0 || decoded.toString('base64') !== data) {
|
||||
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
|
||||
}
|
||||
return new Uint8Array(decoded)
|
||||
}
|
||||
|
||||
/** Store input for one decoded upload. */
|
||||
function saveInput(image: EncodedImageAttachment): SaveImageAttachment {
|
||||
return {
|
||||
data: decodeBase64(image.data),
|
||||
mediaType: image.mediaType,
|
||||
...image.name === undefined ? {} : { name: image.name },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit one wire image batch: enforce canonical base64 on every member, then
|
||||
* delegate batch admission — count and aggregate-byte limits, media-type and
|
||||
* per-image validation, ordered commit — to {@link AttachmentStore.saveImages}.
|
||||
* The shared entry for every RPC endpoint accepting browser uploads.
|
||||
* @param attachments - the deployment attachment store owning batch policy.
|
||||
* @param images - base64-encoded uploads in caller order.
|
||||
* @returns durable references in the same order as `images`.
|
||||
* @throws AttachmentError on a non-canonical payload or a refused batch.
|
||||
*/
|
||||
export async function admitEncodedImages(
|
||||
attachments: AttachmentStore,
|
||||
images: readonly EncodedImageAttachment[],
|
||||
): Promise<readonly ImageAttachmentRef[]> {
|
||||
return attachments.saveImages(images.map(saveInput))
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import type {
|
||||
export { AttachmentId } from './brand.ts'
|
||||
export { AttachmentError, isImageAdmissionError } from './error.ts'
|
||||
export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
|
||||
export { admitEncodedImages } from './admission.ts'
|
||||
export type {
|
||||
AttachmentId as AttachmentIdType,
|
||||
EncodedImageAttachment,
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
ImageMediaType,
|
||||
|
||||
@@ -34,6 +34,16 @@ export interface ImageAttachmentLimits {
|
||||
mediaTypes: readonly ImageMediaType[]
|
||||
}
|
||||
|
||||
/** Base64-encoded image upload accompanying one wire request. */
|
||||
export interface EncodedImageAttachment {
|
||||
/** Declared media type, verified against the decoded bytes during admission. */
|
||||
mediaType: ImageMediaType
|
||||
/** Canonical base64 encoding of the image bytes. */
|
||||
data: string
|
||||
/** Optional display name; it is never interpreted as a path. */
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** Request to validate and durably commit one image. */
|
||||
export interface SaveImageAttachment {
|
||||
data: Uint8Array
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import { admitEncodedImages } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types'
|
||||
|
||||
const PNG = 'AAAA' // canonical base64, 3 bytes
|
||||
|
||||
/** Delegation double: records the exact saveImages batch and answers ordered refs. */
|
||||
function storeOf() {
|
||||
const store = {
|
||||
saveImages: vi.fn((inputs: readonly SaveImageAttachment[]) => Promise.resolve(inputs.map((input, index): ImageAttachmentRef => ({
|
||||
attachmentId: `att-${index + 1}` as ImageAttachmentRef['attachmentId'],
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 1,
|
||||
height: 1,
|
||||
...input.name === undefined ? {} : { name: input.name },
|
||||
})))),
|
||||
}
|
||||
return { store: store as unknown as AttachmentStore, mocks: store }
|
||||
}
|
||||
|
||||
describe('admitEncodedImages', () => {
|
||||
it('decodes every member and delegates one ordered batch to saveImages', async () => {
|
||||
const { store, mocks } = storeOf()
|
||||
const refs = await admitEncodedImages(store, [
|
||||
{ mediaType: 'image/png', data: PNG, name: 'first.png' },
|
||||
{ mediaType: 'image/jpeg', data: PNG, name: 'second.jpg' },
|
||||
])
|
||||
expect(mocks.saveImages).toHaveBeenCalledTimes(1)
|
||||
const batch = mocks.saveImages.mock.calls[0]?.[0] as readonly SaveImageAttachment[]
|
||||
expect(batch.map(input => [input.name, input.mediaType, input.data.byteLength]))
|
||||
.toEqual([['first.png', 'image/png', 3], ['second.jpg', 'image/jpeg', 3]])
|
||||
expect(refs.map(ref => ref.attachmentId)).toEqual(['att-1', 'att-2'])
|
||||
})
|
||||
|
||||
it('omits the name from store inputs when the upload has none', async () => {
|
||||
const { store, mocks } = storeOf()
|
||||
const refs = await admitEncodedImages(store, [{ mediaType: 'image/webp', data: PNG }])
|
||||
const batch = mocks.saveImages.mock.calls[0]?.[0] as readonly SaveImageAttachment[]
|
||||
expect('name' in (batch[0] as object)).toBe(false)
|
||||
expect(refs[0]?.name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('delegates an empty batch unchanged', async () => {
|
||||
const { store, mocks } = storeOf()
|
||||
await expect(admitEncodedImages(store, [])).resolves.toEqual([])
|
||||
expect(mocks.saveImages).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('rejects non-canonical and empty base64 payloads before any store call', async () => {
|
||||
const { store, mocks } = storeOf()
|
||||
for (const data of ['', 'AAA', '!!!!']) {
|
||||
await expect(admitEncodedImages(store, [{ mediaType: 'image/png', data }]))
|
||||
.rejects.toMatchObject({ name: 'AttachmentError', code: 'INVALID_IMAGE_BASE64' })
|
||||
}
|
||||
expect(mocks.saveImages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates the store batch rejection unchanged', async () => {
|
||||
const { store, mocks } = storeOf()
|
||||
const refused = Object.assign(new Error('Image batch exceeds the configured image-count limit.'), { code: 'TOO_MANY_IMAGES' })
|
||||
mocks.saveImages.mockRejectedValueOnce(refused)
|
||||
await expect(admitEncodedImages(store, [{ mediaType: 'image/png', data: PNG }])).rejects.toBe(refused)
|
||||
})
|
||||
})
|
||||
@@ -545,7 +545,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
|
||||
// the todo turn for the same reason turn 66 is: the standing plan retires at
|
||||
// the next turn/start, so a turn after it would empty the dock's plan strip.
|
||||
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(70, 'web_search', '{"queries":["deepseek harness architecture"]}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
|
||||
// Turn 72: max-tokens sample — the provider ends the turn at its output cap
|
||||
@@ -660,8 +660,11 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
// The web tools keep a GENERIC pending card and add the `web` result card
|
||||
// only at result time (the contract's result-only web shape); their pending
|
||||
// kind matches the result kind so a call and its result read as one category.
|
||||
case 'web_search':
|
||||
return { card: 'generic', title: `Search ${str(args.query)}`, kind: 'search', rawInput: args }
|
||||
case 'web_search': {
|
||||
const queries = Array.isArray(args.queries) ? args.queries.filter((query): query is string => typeof query === 'string' && query !== '') : []
|
||||
const title = queries.join(', ')
|
||||
return { card: 'generic', title: `Search ${title}`, kind: 'search', rawInput: args }
|
||||
}
|
||||
case 'web_fetch':
|
||||
return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
|
||||
default:
|
||||
@@ -742,26 +745,34 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture parallel of the plan unit's double-event fold: `command/run`
|
||||
* records named `plan` with recorded input set the wanted target (`off` →
|
||||
* false, else true); `plan/mode` commits and clears it. `wanted` is exposed
|
||||
* for the prompt boundary (the fixture's step/start parallel).
|
||||
* Fixture parallel of the plan unit's lifecycle fold. The paired
|
||||
* `command/done` retains successful plan selections and drops failures;
|
||||
* `plan/mode` commits one. `wanted` is exposed for the prompt boundary (the
|
||||
* fixture's step/start parallel).
|
||||
*/
|
||||
function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } {
|
||||
let active = false
|
||||
let wanted: boolean | null = null
|
||||
let running: { commandId: unknown; wanted: boolean } | null = null
|
||||
for (const event of log) {
|
||||
const item = event as unknown as { type: string; data?: Record<string, unknown> }
|
||||
if (item.type === 'command/run' && item.data?.['name'] === 'plan') {
|
||||
const args = item.data['args']
|
||||
if (typeof args !== 'string') continue
|
||||
wanted = args.trim() !== 'off'
|
||||
running = { commandId: item.data['commandId'], wanted: args.trim() !== 'off' }
|
||||
} else if (item.type === 'command/done'
|
||||
&& item.data !== undefined
|
||||
&& running !== null
|
||||
&& item.data['commandId'] === running.commandId) {
|
||||
wanted = item.data['kind'] === 'success' && running.wanted !== active ? running.wanted : null
|
||||
running = null
|
||||
} else if (item.type === 'plan/mode') {
|
||||
active = item.data?.['active'] === true
|
||||
wanted = null
|
||||
}
|
||||
}
|
||||
return { active, pending: wanted !== null && wanted !== active, wanted }
|
||||
const selected = running?.wanted ?? wanted
|
||||
return { active, pending: selected !== null && selected !== active, wanted: selected }
|
||||
}
|
||||
|
||||
/** The plan projection's wire view over the full log. */
|
||||
@@ -1734,13 +1745,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
value: [
|
||||
{ name: 'compact', description: 'fixture:压缩当前会话上下文' },
|
||||
{ name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } },
|
||||
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>' } },
|
||||
{ name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '<objective>', images: true } },
|
||||
{ name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '<preset>' } },
|
||||
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } },
|
||||
{ name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } },
|
||||
],
|
||||
}
|
||||
},
|
||||
execute(id: SessionId, line: string): RpcResult<CommandExecution | undefined> {
|
||||
execute(id: SessionId, line: string, images: readonly unknown[] = []): RpcResult<CommandExecution | undefined> {
|
||||
const missing = requireGoalSession(id)
|
||||
if (missing !== undefined) return missing
|
||||
// Structured split mirroring the Host parser: name + verbatim rawInput
|
||||
@@ -1748,6 +1759,29 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
const match = /^\/(\S+)((?:\s.*)?)$/.exec(line.trim())
|
||||
const name = match?.[1]
|
||||
const args = match?.[2] ?? ''
|
||||
// Mirror the Host image policy AFTER command resolution, matching the
|
||||
// executor's order (an unknown name answers undefined and logs no
|
||||
// lifecycle): the declaration rejection covers every known command
|
||||
// without `input.images`, and the two producer grammar rejections cover
|
||||
// the declaring commands' control-only lines. The fixture stores no
|
||||
// bytes, so an accepted batch is acknowledged and dropped.
|
||||
const known = ['permission', 'goal', 'compact', 'echo', 'plan']
|
||||
if (images.length > 0 && name !== undefined && known.includes(name)) {
|
||||
const rejection = name !== 'goal' && name !== 'plan'
|
||||
? `/${name} does not accept image attachments`
|
||||
: name === 'goal' && args.trim() === ''
|
||||
? 'Image attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.'
|
||||
: name === 'plan' && args.trim() === 'off'
|
||||
? 'Image attachments cannot accompany /plan off.'
|
||||
: undefined
|
||||
if (rejection !== undefined) {
|
||||
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
|
||||
append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } })
|
||||
const result: CommandResult = { kind: 'error', text: rejection }
|
||||
append(id, { type: 'command/done', data: { commandId, ...result } })
|
||||
return { ok: true, value: { commandId, result } }
|
||||
}
|
||||
}
|
||||
if (name === 'permission') {
|
||||
const preset = args.trim()
|
||||
const commandId = `fx-cmd-${logOf(id).length}` as CommandId
|
||||
@@ -3012,6 +3046,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
args: {
|
||||
agentId: SessionId
|
||||
line?: string
|
||||
images?: readonly unknown[]
|
||||
ref?: { id: string; revision: number }
|
||||
request?: { objective?: string; maxGoalRounds?: number }
|
||||
}
|
||||
@@ -3019,7 +3054,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
const sessionId = args.agentId
|
||||
switch (endpoint) {
|
||||
case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId))
|
||||
case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string))
|
||||
case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? []))
|
||||
case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, {
|
||||
objective: args.request?.objective as string,
|
||||
...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds },
|
||||
|
||||
@@ -28,13 +28,15 @@ const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${reqCount++}`)
|
||||
describe('createFixtureApi commands/skills', () => {
|
||||
it('serves the addressed session catalog', async () => {
|
||||
const { rpc } = createFixtureFaces()
|
||||
const commands = await callRemote<{ name: string; input?: { hint: string } }[]>(
|
||||
const commands = await callRemote<{ name: string; input?: { hint: string; images?: boolean } }[]>(
|
||||
rpc, 'commands/list', { agentId: sid('fx-alpha') })
|
||||
expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan'])
|
||||
// input hint rides only the commands declaring it.
|
||||
const echo = commands.find(c => c.name === 'echo')
|
||||
expect(echo?.input?.hint).toBeTruthy()
|
||||
expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined()
|
||||
// Image acceptance is declared per descriptor; only goal and plan carry it.
|
||||
expect(commands.filter(c => c.input?.images === true).map(c => c.name)).toEqual(['goal', 'plan'])
|
||||
})
|
||||
|
||||
it('rejects a catalog request for an unknown session', async () => {
|
||||
@@ -80,6 +82,70 @@ describe('createFixtureApi commands/skills', () => {
|
||||
expect(missing).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
})
|
||||
|
||||
it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => {
|
||||
const { api, rpc } = createFixtureFaces()
|
||||
const frames: unknown[] = []
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux(req({}), abort.signal)
|
||||
const pump = (async () => {
|
||||
for await (const frame of stream) {
|
||||
frames.push(frame.payload)
|
||||
if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
|
||||
}
|
||||
})()
|
||||
const png = { mediaType: 'image/png', data: 'AA==' }
|
||||
const refused = await callRemote<{ commandId: string; result: { kind: string; text?: string } } | undefined>(
|
||||
rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hi', images: [png] })
|
||||
expect(refused?.commandId).toBeTruthy()
|
||||
expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept image attachments' })
|
||||
await pump
|
||||
const events = frames
|
||||
.filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
|
||||
.map(f => f.event)
|
||||
expect(events).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: '/echo does not accept image attachments' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('a declaring command accepts an image-carrying execute', async () => {
|
||||
const { rpc } = createFixtureFaces()
|
||||
const png = { mediaType: 'image/png', data: 'AA==' }
|
||||
const accepted = await callRemote<{ result: { kind: string } } | undefined>(
|
||||
rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal ship it', images: [png] })
|
||||
expect(accepted?.result.kind).toBe('success')
|
||||
const planMessage = await callRemote<{ result: { kind: string } } | undefined>(
|
||||
rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan sketch the layout', images: [png] })
|
||||
expect(planMessage?.result.kind).toBe('success')
|
||||
const imageOnlyPlan = await callRemote<{ result: { kind: string } } | undefined>(
|
||||
rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan', images: [png] })
|
||||
expect(imageOnlyPlan?.result.kind).toBe('success')
|
||||
})
|
||||
|
||||
it('mirrors the producer grammar rejections for control-only declaring lines', async () => {
|
||||
const { rpc } = createFixtureFaces()
|
||||
const png = { mediaType: 'image/png', data: 'AA==' }
|
||||
const bareGoal = await callRemote<{ result: { kind: string; text?: string } } | undefined>(
|
||||
rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal', images: [png] })
|
||||
expect(bareGoal?.result).toEqual({
|
||||
kind: 'error',
|
||||
text: 'Image attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.',
|
||||
})
|
||||
const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>(
|
||||
rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan off', images: [png] })
|
||||
expect(refused?.result).toEqual({
|
||||
kind: 'error',
|
||||
text: 'Image attachments cannot accompany /plan off.',
|
||||
})
|
||||
})
|
||||
|
||||
it('answers no execution for an unknown name even when images accompany it', async () => {
|
||||
const { rpc } = createFixtureFaces()
|
||||
const png = { mediaType: 'image/png', data: 'AA==' }
|
||||
expect(await callRemote(rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/nope', images: [png] }))
|
||||
.toBeUndefined()
|
||||
})
|
||||
|
||||
it('answers no execution for unknown names and non-command lines', async () => {
|
||||
const { rpc } = createFixtureFaces()
|
||||
for (const line of ['/nope', 'plain text', '/']) {
|
||||
|
||||
@@ -356,7 +356,7 @@ export class Session implements SessionFace {
|
||||
* @returns the admission result, or the error branch on transport failure.
|
||||
*/
|
||||
async command(line: string): Promise<RemoteResult<{ matched: boolean }>> {
|
||||
const result = await this.remote.commands.execute(this.sessionId, line)
|
||||
const result = await this.remote.commands.execute(this.sessionId, line, [])
|
||||
if (!result.ok) return result
|
||||
return { ok: true, value: { matched: result.value !== undefined } }
|
||||
}
|
||||
|
||||
@@ -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/client/ui-commands/README.md
|
||||
README.md: 67110ffd8c1ad11e56ca9293a9064c66dd08c81d
|
||||
README.zh.md: 40fe21850dd289d2a5c91bd88d4f22c087731b80
|
||||
README.md: 2140495a44110d5e4b33e4cc8f539959752ac185
|
||||
README.zh.md: afa47cd18505b9afbd3e867d131e9796db598895
|
||||
|
||||
@@ -8,6 +8,8 @@ Client command API (`ctx.commandUi`): the session-keyed command-directory cache,
|
||||
|
||||
`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
|
||||
|
||||
`matchEnter` also enforces the submission envelope: when the composer submits with image attachments, only a host command declaring `input.images` proceeds (its claim carries `images: true` and its submit forwards the serialized payloads to `command.execute`); every other command route — contribution popup, decorated popup, non-declaring claim, bare detached execute — throws the localized `notice.imagesUnsupported` refusal, which the input machine publishes as one error notice and the composer renders as a transient Toast banner with the draft and images retained. An image-carrying submit whose host handler answers an error result maps to an error outcome so the composer keeps the images; imageless submits keep the plain success mapping because the durable flow node owns the outcome rendering.
|
||||
|
||||
After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. Listener failures are logged and contained one by one; they cannot change the already-admitted command result or prevent later listeners from running.
|
||||
|
||||
Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
|
||||
|
||||
`matchEnter` 还强制执行提交信封:composer 携带图片附件提交时,只有声明了 `input.images` 的宿主命令继续(其 claim 携带 `images: true`,其 submit 把序列化载荷转交 `command.execute`);其余每条命令路径——contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行——都会抛出本地化的 `notice.imagesUnsupported` 拒绝,输入状态机发布一条错误通知,composer 以瞬态 Toast 横幅呈现它,草稿与图片原样保留。带图提交若宿主处理器返回错误结果,则映射为错误 outcome,composer 保留图片;不带图的提交维持原有的一律成功映射,因为结果呈现由持久化 flow 节点负责。
|
||||
|
||||
`command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。监听器失败会逐项记录并隔离,不会改变已经准入的命令结果,也不会阻止后续监听器运行。
|
||||
|
||||
菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和贡献项顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。
|
||||
|
||||
@@ -9,6 +9,7 @@ export const zh = {
|
||||
'status.empty': '无选项',
|
||||
'overlay.aria': '/{command} 选项',
|
||||
'listbox.aria': '/{command} 匹配项',
|
||||
'notice.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The command namespace key union. */
|
||||
@@ -23,4 +24,5 @@ export const en = {
|
||||
'status.empty': 'No options',
|
||||
'overlay.aria': '/{command} options',
|
||||
'listbox.aria': '/{command} matches',
|
||||
'notice.imagesUnsupported': '/{command} does not accept image attachments; remove them first',
|
||||
} satisfies Record<CommandKey, string>
|
||||
|
||||
@@ -14,9 +14,10 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
|
||||
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {
|
||||
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick,
|
||||
SubmitOutcome,
|
||||
SubmitEnvelope, SubmitImageAttachment, SubmitOutcome,
|
||||
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts'
|
||||
import type { CommandDescriptor } from './directory.ts'
|
||||
@@ -122,6 +123,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
|
||||
private readonly directory: CommandDirectory
|
||||
private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
|
||||
/** `command`-namespace translator (composer refusal notices). */
|
||||
private readonly t: TranslateNS<'command'>
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (plugin fiber; the service registers
|
||||
@@ -129,6 +132,9 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'commandUi')
|
||||
const locale = ctx.get('locale')
|
||||
if (locale === undefined) throw new Error('ui-commands: locale service unavailable')
|
||||
this.t = locale.bind('command')
|
||||
this.directory = new CommandDirectory(async (sessionId) => {
|
||||
if (this.sessions().subagentAddress(sessionId) !== undefined) return []
|
||||
const result = await ctx.remote.commands.list(sessionId)
|
||||
@@ -143,7 +149,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
candidates: (session, req) => this.candidates(session, req),
|
||||
onPick: pick => this.dispatch(pick),
|
||||
matchSpace: (session, token) => this.matchSpace(session, token),
|
||||
matchEnter: (session, line, signal) => this.matchEnter(session, line, signal),
|
||||
matchEnter: (session, line, signal, envelope) => this.matchEnter(session, line, signal, envelope),
|
||||
warm: (session) => { this.directory.warm(session.sessionId) },
|
||||
}), 'command: slash source')
|
||||
ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() })
|
||||
@@ -302,8 +308,19 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
* warmup failure rejects — never a silent downgrade). Contributions and
|
||||
* bare host commands act on the bare token only; leadingInput claims
|
||||
* args-tolerant.
|
||||
*
|
||||
* Envelope policy: an enter submission carrying images resolves only
|
||||
* through a command declaring image acceptance. Every other command route —
|
||||
* popup, non-accepting claim, bare detached execute — throws the refusal
|
||||
* so the machine surfaces one composer notice and the draft and images
|
||||
* stay in place; nothing executes and nothing is dropped.
|
||||
*/
|
||||
private async matchEnter(session: ClientSessionContext, line: string, signal: AbortSignal): Promise<PickOutcome> {
|
||||
private async matchEnter(
|
||||
session: ClientSessionContext,
|
||||
line: string,
|
||||
signal: AbortSignal,
|
||||
envelope: SubmitEnvelope,
|
||||
): Promise<PickOutcome> {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('/')) return undefined
|
||||
const ws = trimmed.search(/\s/)
|
||||
@@ -311,9 +328,13 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
const bare = ws === -1
|
||||
const name = token.slice(1)
|
||||
if (name === '') return undefined
|
||||
const refuseImages = (): never => {
|
||||
throw new Error(this.t('notice.imagesUnsupported', { command: name }))
|
||||
}
|
||||
const contribution = this.live.contributions.get(name)
|
||||
if (contribution !== undefined && contribution.available(session)) {
|
||||
if (!bare) return undefined
|
||||
if (envelope.images > 0) refuseImages()
|
||||
this.openPopup(name, contribution.ui, session, { via: 'enter', token })
|
||||
return 'handled'
|
||||
}
|
||||
@@ -325,12 +346,17 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
if (bare) {
|
||||
const decoration = this.live.decorations.get(name)
|
||||
if (decoration !== undefined && decoration.available(session)) {
|
||||
if (envelope.images > 0) refuseImages()
|
||||
this.openPopup(name, decoration.ui, session, { via: 'enter', token })
|
||||
return 'handled'
|
||||
}
|
||||
}
|
||||
if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) }
|
||||
if (desc.input !== undefined) {
|
||||
if (envelope.images > 0 && desc.input.images !== true) refuseImages()
|
||||
return { claim: this.leadingClaim(desc, session) }
|
||||
}
|
||||
if (!bare) return undefined
|
||||
if (envelope.images > 0) refuseImages()
|
||||
this.consumeVia(session.sessionId, { via: 'enter', token })
|
||||
this.runDetached(desc, session, trimmed)
|
||||
return 'handled'
|
||||
@@ -354,7 +380,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
return {
|
||||
token,
|
||||
...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
|
||||
submit: (args, _actx) => this.execute(session, token + args),
|
||||
...(desc.input?.images === true ? { images: true } : {}),
|
||||
submit: (args, _actx, images) => this.execute(session, token + args, images),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,16 +392,24 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
|
||||
* plain success regardless of its handler outcome, because the host
|
||||
* executor durably logged the lifecycle (`command/run`/`command/done`) and
|
||||
* the outcome renders as a persistent flow node — the composer never
|
||||
* echoes it. Transport failures throw.
|
||||
* echoes it. A handler error result reports an error outcome so the
|
||||
* composer keeps the submission (draft and images) for correction.
|
||||
* Transport failures throw.
|
||||
*/
|
||||
private async execute(
|
||||
session: ClientSessionContext,
|
||||
line: string,
|
||||
images: readonly SubmitImageAttachment[] = [],
|
||||
): Promise<SubmitOutcome> {
|
||||
const result = await this.ctx.remote.commands.execute(session.sessionId, line)
|
||||
const result = await this.ctx.remote.commands.execute(session.sessionId, line, images)
|
||||
if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
|
||||
if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` }
|
||||
this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result)
|
||||
// An image-carrying submission consumed its images only on handler
|
||||
// success; an error outcome keeps draft and images in the composer.
|
||||
if (images.length > 0 && result.value.result.kind === 'error') {
|
||||
return { kind: 'error', text: result.value.result.text }
|
||||
}
|
||||
return { kind: 'success' }
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
|
||||
import type { CommandDescriptor } from '../src/client/directory.ts'
|
||||
import { CommandUiRuntime } from '../src/client/service.ts'
|
||||
@@ -32,7 +32,7 @@ const S2_CMDS: CommandDescriptor[] = [
|
||||
{ name: 'attach', description: 'scoped shadow', input: { hint: 'path' } },
|
||||
]
|
||||
|
||||
type ExecuteValue = { matched: boolean; commandId?: string }
|
||||
type ExecuteValue = { matched: boolean; commandId?: string; result?: CommandResult }
|
||||
|
||||
interface BenchOptions {
|
||||
/** Scripted catalog per list payload; default serves the fixed catalogs by session. */
|
||||
@@ -67,7 +67,7 @@ async function bench(opts: BenchOptions = {}) {
|
||||
const ctx = new Context()
|
||||
const registered = new Map<string, InputTriggerSource>()
|
||||
const listCalls: Array<{ sessionId: SessionId }> = []
|
||||
const executeCalls: Array<{ sessionId: SessionId; line: string }> = []
|
||||
const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitImageAttachment[] }> = []
|
||||
// The service reads the generated commands Remote, which delivers the
|
||||
// carrier's outcome, so a programmed failure answers the error branch.
|
||||
const commandsRemote = {
|
||||
@@ -80,13 +80,13 @@ async function bench(opts: BenchOptions = {}) {
|
||||
return value.commands
|
||||
})
|
||||
},
|
||||
execute: async (sessionId: SessionId, line: string) => {
|
||||
executeCalls.push({ sessionId, line })
|
||||
execute: async (sessionId: SessionId, line: string, images: readonly SubmitImageAttachment[] = []) => {
|
||||
executeCalls.push({ sessionId, line, images })
|
||||
return await carried(async () => {
|
||||
const fallback = (): Promise<ExecuteValue> => Promise.resolve({ matched: true })
|
||||
const value = await (opts.execute ?? fallback)({ sessionId, line })
|
||||
return value.matched
|
||||
? { commandId: value.commandId ?? 'fake-command', result: { kind: 'success' as const } }
|
||||
? { commandId: value.commandId ?? 'fake-command', result: value.result ?? { kind: 'success' as const } }
|
||||
: undefined
|
||||
})
|
||||
},
|
||||
@@ -98,6 +98,11 @@ async function bench(opts: BenchOptions = {}) {
|
||||
return () => { registered.delete(key) }
|
||||
},
|
||||
})
|
||||
// Deterministic key-echo translator: notice assertions read `key{json}`.
|
||||
ctx.provide('locale', {
|
||||
bind: (ns: string) => (key: string, params?: Record<string, unknown>) =>
|
||||
`${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`,
|
||||
})
|
||||
// Real scope tags behind a fake sessions face.
|
||||
const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
|
||||
ctx.provide('sessions', {
|
||||
@@ -297,9 +302,9 @@ describe('decorations (bare-invocation UI on host commands)', () => {
|
||||
command.decorate(goalDecoration())
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled')
|
||||
expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { images: 0 })).toBe('handled')
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
|
||||
const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal)
|
||||
const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { images: 0 })
|
||||
if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim')
|
||||
expect(argued.claim.token).toBe('/goal ')
|
||||
})
|
||||
@@ -318,7 +323,7 @@ describe('decorations (bare-invocation UI on host commands)', () => {
|
||||
command.decorate(goalDecoration({ name: 'phantom' }))
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined()
|
||||
expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { images: 0 })).toBeUndefined()
|
||||
expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined()
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
@@ -327,8 +332,8 @@ describe('decorations (bare-invocation UI on host commands)', () => {
|
||||
const { command, source, warm, executeCalls } = await bench()
|
||||
command.decorate(goalDecoration({ name: 'plan', available: () => false }))
|
||||
await warm(proj('s1'))
|
||||
expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled')
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 })).toBe('handled')
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
|
||||
})
|
||||
|
||||
it('duplicate decoration names fail loud', async () => {
|
||||
@@ -383,7 +388,7 @@ describe('dispatch (menu column)', () => {
|
||||
expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled')
|
||||
expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }])
|
||||
await vi.waitFor(() => {
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
|
||||
expect(executions).toEqual([{
|
||||
sessionId: sid('s1'),
|
||||
name: 'plan',
|
||||
@@ -440,7 +445,7 @@ describe('matchEnter (enter column)', () => {
|
||||
const { source } = await bench({
|
||||
commands: () => new Promise((resolve) => { release = resolve }),
|
||||
})
|
||||
const wait = source.matchEnter!(proj('s1'), '/goal args', signal())
|
||||
const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { images: 0 })
|
||||
release({ commands: S1_CMDS })
|
||||
const outcome = await wait
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
@@ -451,14 +456,14 @@ describe('matchEnter (enter column)', () => {
|
||||
const { source } = await bench({
|
||||
commands: () => Promise.reject(new Error('warmup boom')),
|
||||
})
|
||||
await expect(source.matchEnter!(proj('s1'), '/goal', signal())).rejects.toThrow('warmup boom')
|
||||
await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { images: 0 })).rejects.toThrow('warmup boom')
|
||||
})
|
||||
|
||||
it('leadingInput claims args-tolerant (bare and with trailing text)', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s1'))
|
||||
for (const line of ['/goal', '/goal refactor the loop']) {
|
||||
const outcome = await source.matchEnter!(proj('s1'), line, signal())
|
||||
const outcome = await source.matchEnter!(proj('s1'), line, signal(), { images: 0 })
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
expect(outcome.claim.token).toBe('/goal ')
|
||||
}
|
||||
@@ -473,16 +478,16 @@ describe('matchEnter (enter column)', () => {
|
||||
return true
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/plan', signal())).resolves.toBe('handled')
|
||||
await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 0 })).resolves.toBe('handled')
|
||||
expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }])
|
||||
await Promise.resolve()
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
|
||||
})
|
||||
|
||||
it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => {
|
||||
const { source, warm, executeCalls } = await bench()
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/plan now', signal())).resolves.toBeUndefined()
|
||||
await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { images: 0 })).resolves.toBeUndefined()
|
||||
expect(executeCalls).toEqual([])
|
||||
})
|
||||
|
||||
@@ -490,18 +495,86 @@ describe('matchEnter (enter column)', () => {
|
||||
const { command, source, mint, listCalls } = await bench()
|
||||
command.register(themeContribution())
|
||||
const scope = mint('s1')
|
||||
await expect(source.matchEnter!(proj('s1'), '/theme', signal())).resolves.toBe('handled')
|
||||
await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 0 })).resolves.toBe('handled')
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true)
|
||||
expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady
|
||||
await expect(source.matchEnter!(proj('s1'), '/theme dark', signal())).resolves.toBeUndefined()
|
||||
await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { images: 0 })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('unknown name, bare "/", and non-slash lines → undefined', async () => {
|
||||
const { source, warm } = await bench()
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/nope', signal())).resolves.toBeUndefined()
|
||||
await expect(source.matchEnter!(proj('s1'), '/', signal())).resolves.toBeUndefined()
|
||||
await expect(source.matchEnter!(proj('s1'), 'plain text', signal())).resolves.toBeUndefined()
|
||||
await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { images: 0 })).resolves.toBeUndefined()
|
||||
await expect(source.matchEnter!(proj('s1'), '/', signal(), { images: 0 })).resolves.toBeUndefined()
|
||||
await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { images: 0 })).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchEnter envelope policy (images)', () => {
|
||||
const signal = () => new AbortController().signal
|
||||
const IMG_CMDS: CommandDescriptor[] = [
|
||||
...S1_CMDS,
|
||||
{ name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', images: true } },
|
||||
]
|
||||
const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' }
|
||||
|
||||
it('a leadingInput command not declaring acceptance refuses; a declaring one claims with images minted', async () => {
|
||||
const { source, warm } = await bench({ commands: () => Promise.resolve({ commands: IMG_CMDS }) })
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { images: 1 }))
|
||||
.rejects.toThrow('command:notice.imagesUnsupported{"command":"goal"}')
|
||||
const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { images: 1 })
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
expect(outcome.claim.token).toBe('/vision ')
|
||||
expect(outcome.claim.images).toBe(true)
|
||||
})
|
||||
|
||||
it('bare popup routes refuse images: contribution and decorated host both stay closed', async () => {
|
||||
const { command, source, mint, warm } = await bench()
|
||||
command.register(themeContribution())
|
||||
command.decorate({ name: 'plan', available: () => true, ui: themeUi() })
|
||||
const scope = mint('s1')
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 1 }))
|
||||
.rejects.toThrow('command:notice.imagesUnsupported{"command":"theme"}')
|
||||
await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 2 }))
|
||||
.rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}')
|
||||
expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
|
||||
})
|
||||
|
||||
it('bare host detached execute refuses images before any RPC', async () => {
|
||||
const { source, warm, executeCalls } = await bench()
|
||||
await warm(proj('s1'))
|
||||
await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 1 }))
|
||||
.rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}')
|
||||
expect(executeCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('claim.submit forwards the images to execute; consumption follows the handler outcome', async () => {
|
||||
let result: CommandResult = { kind: 'error', text: 'handler refused' }
|
||||
const { source, warm, executeCalls } = await bench({
|
||||
commands: () => Promise.resolve({ commands: IMG_CMDS }),
|
||||
execute: () => Promise.resolve({ matched: true, result }),
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { images: 1 })
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
// Handler error: the error outcome keeps draft and images in the composer.
|
||||
await expect(outcome.claim.submit('x', new Context(), [png]))
|
||||
.resolves.toEqual({ kind: 'error', text: 'handler refused' })
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/vision x', images: [png] }])
|
||||
result = { kind: 'success', text: 'described' }
|
||||
await expect(outcome.claim.submit('x', new Context(), [png])).resolves.toEqual({ kind: 'success' })
|
||||
})
|
||||
|
||||
it('an imageless submission keeps the always-success admission mapping over a handler error', async () => {
|
||||
const { source, warm } = await bench({
|
||||
execute: () => Promise.resolve({ matched: true, result: { kind: 'error', text: 'late failure' } }),
|
||||
})
|
||||
await warm(proj('s1'))
|
||||
const outcome = source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
await expect(outcome.claim.submit('x', new Context(), [])).resolves.toEqual({ kind: 'success' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -513,8 +586,8 @@ describe('execute payload', () => {
|
||||
await warm(proj('s1'))
|
||||
const outcome = source.matchSpace!(proj('s1'), '/goal')
|
||||
if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim')
|
||||
const settled = await outcome.claim.submit('ship it', new Context())
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it' }])
|
||||
const settled = await outcome.claim.submit('ship it', new Context(), [])
|
||||
expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it', images: [] }])
|
||||
// Pure admission: no outcome text ever rides the submit result — the
|
||||
// durable command lifecycle events render the outcome in the flow.
|
||||
expect(settled).toEqual({ kind: 'success' })
|
||||
@@ -539,7 +612,7 @@ describe('execute payload', () => {
|
||||
b.ctx.on('command/executed', rejectingListener)
|
||||
b.ctx.on('command/executed', after)
|
||||
|
||||
await expect(outcome.claim.submit('ship it', new Context())).resolves.toEqual({ kind: 'success' })
|
||||
await expect(outcome.claim.submit('ship it', new Context(), [])).resolves.toEqual({ kind: 'success' })
|
||||
expect(after).toHaveBeenCalledOnce()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
@@ -557,10 +630,10 @@ describe('execute payload', () => {
|
||||
return outcome.claim
|
||||
}
|
||||
const first = await claimOf({ execute: () => Promise.resolve({ matched: false }) })
|
||||
const bad = await first.submit('x', new Context())
|
||||
const bad = await first.submit('x', new Context(), [])
|
||||
expect(bad.kind).toBe('error')
|
||||
const second = await claimOf({ execute: () => Promise.resolve({ matched: true }) })
|
||||
await expect(second.submit('', new Context())).resolves.toEqual({ kind: 'success' })
|
||||
await expect(second.submit('', new Context(), [])).resolves.toEqual({ kind: 'success' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -584,7 +657,7 @@ describe('detached admission notices', () => {
|
||||
|
||||
// Admission miss (matched:false): immediate composer feedback stays.
|
||||
mode = 'miss'
|
||||
await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)
|
||||
await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 })
|
||||
await flush()
|
||||
expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }])
|
||||
|
||||
@@ -663,7 +736,7 @@ describe('popupFor', () => {
|
||||
consumes.push(r)
|
||||
return true
|
||||
})
|
||||
await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
|
||||
await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 })
|
||||
const popup = command.popupFor(scope.ctx)
|
||||
await Promise.resolve()
|
||||
await popup.select(0)
|
||||
@@ -674,7 +747,7 @@ describe('popupFor', () => {
|
||||
const { command, source, mint } = await bench()
|
||||
command.register(themeContribution())
|
||||
const scope = mint('s1')
|
||||
await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal)
|
||||
await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 })
|
||||
const popup = command.popupFor(scope.ctx)
|
||||
expect(popup.state.getSnapshot().open).toBe(true)
|
||||
|
||||
|
||||
@@ -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/client/ui-conversation/README.md
|
||||
README.md: d1a265b5789d9f1d9b5e630e0548ae5f619eebbf
|
||||
README.zh.md: 3f303391d39bc040b4a6a5a2d1f6a34fe8891919
|
||||
README.md: d9b774bdf5bfc2beaa33fe0d3ada8263b863798d
|
||||
README.zh.md: 94da3def811fb901132f53fd6dbf4de0ccd6b3c8
|
||||
|
||||
@@ -36,7 +36,7 @@ Keyboard message submission resolves delivery from the addressed session's runni
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code.
|
||||
Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code. Attached images are part of the submission envelope on every send path: a slash-command submit either consumes them (a claim declaring `images` has them serialized through the hub's `commandImages` plumbing, passed to `claim.submit`, and cleared plus released only on a success outcome) or refuses the whole submission with the `command.imagesUnsupported` notice while draft and images stay in place — a command can never consume the text and strand the images.
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `InputTriggerController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-input-trigger's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
|
||||
逐会话 UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听(composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts` 的 `attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。
|
||||
图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听(composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts` 的 `attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。已附加的图片在每条发送路径上都是提交信封的一部分:斜杠命令提交要么消费它们(声明 `images` 的 claim 经 hub 的 `commandImages` 管道序列化图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放),要么以 `command.imagesUnsupported` 通知拒绝整个提交,草稿与图片原样保留——命令不可能消费了文字却把图片留在原地。
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `InputTriggerController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-input-trigger 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface SessionInput extends InputTarget {
|
||||
setDraft(text: string): void
|
||||
/** Append ordered browser-owned image ids; busy admission phases refuse. */
|
||||
addImages(ids: readonly DraftAttachmentId[]): boolean
|
||||
/** Remove one browser-owned image id. */
|
||||
/** Remove one browser-owned image id; busy admission phases refuse. */
|
||||
removeImage(id: DraftAttachmentId): void
|
||||
/** Drop ids whose browser-owned objects no longer exist. */
|
||||
pruneImages(ids: readonly DraftAttachmentId[]): void
|
||||
@@ -75,7 +75,7 @@ export interface InputActions {
|
||||
setDraft(text: string): void
|
||||
/** Append ordered browser-owned image ids; busy admission phases refuse. */
|
||||
addImages(ids: readonly DraftAttachmentId[]): boolean
|
||||
/** Remove one browser-owned image id. */
|
||||
/** Remove one browser-owned image id; busy admission phases refuse. */
|
||||
removeImage(id: DraftAttachmentId): void
|
||||
/** Drop ids whose browser-owned objects no longer exist. */
|
||||
pruneImages(ids: readonly DraftAttachmentId[]): void
|
||||
@@ -214,7 +214,7 @@ export interface InputState {
|
||||
readonly draftRev: number
|
||||
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
|
||||
/** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
|
||||
readonly claim?: { readonly token: string; readonly hint?: string }
|
||||
readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean }
|
||||
/** Chip occurrence table, sorted by offset (one U+FFFC per entry). */
|
||||
readonly occurrences: readonly Occurrence[]
|
||||
/** Live paste-match attempt (absent when no paste is matchable). */
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
|
||||
ReferenceInsert, InputTriggerController, TokenSpan,
|
||||
ReferenceInsert, InputTriggerController, SubmitImageAttachment, TokenSpan,
|
||||
} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type {
|
||||
DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
|
||||
@@ -46,6 +46,15 @@ export interface SessionInputDeps {
|
||||
steerQueue?: (() => void) | undefined
|
||||
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
|
||||
defaultSink(text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode): void
|
||||
/** Command-plane image plumbing (the hub owns the conversation face and the copy). */
|
||||
commandImages: {
|
||||
/** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */
|
||||
serialize(ids: readonly DraftAttachmentId[]): Promise<readonly SubmitImageAttachment[]>
|
||||
/** Free consumed draft images after a successful command submit. */
|
||||
release(ids: readonly DraftAttachmentId[]): void
|
||||
/** Localized composer notice for a claimed command that does not accept images. */
|
||||
unsupportedNotice(token: string): string
|
||||
}
|
||||
}
|
||||
|
||||
/** Guard tier from the machine phase. */
|
||||
@@ -69,7 +78,7 @@ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
|
||||
export class SessionInputShell implements SessionInput {
|
||||
/** Published machine state + queue overlay (the InputZone currency source). */
|
||||
readonly state: SnapshotStore<InputState>
|
||||
/** Latest surfaced notice (null after clear); the wiring renders it beside the error strip. */
|
||||
/** Latest surfaced notice (null after clear); the bar renders errors as banners and information inline. */
|
||||
readonly notices: SnapshotStore<InputNotice | null> = createSnapshotStore<InputNotice | null>(null)
|
||||
/** The public provide-channel action face (one stable identity per session). */
|
||||
readonly actions: InputActions = {
|
||||
@@ -116,8 +125,13 @@ export class SessionInputShell implements SessionInput {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Remove one image id from this draft. */
|
||||
/**
|
||||
* Remove one image id from this draft. Busy admission phases refuse, like
|
||||
* {@link addImages}: a removal landing while a command submit serializes
|
||||
* would otherwise vanish from the rail yet still ride the in-flight send.
|
||||
*/
|
||||
removeImage(id: DraftAttachmentId): void {
|
||||
if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return
|
||||
const next = this.imageIds.filter(candidate => candidate !== id)
|
||||
if (next.length === this.imageIds.length) return
|
||||
this.imageIds = next
|
||||
@@ -200,6 +214,15 @@ export class SessionInputShell implements SessionInput {
|
||||
if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds], mode)
|
||||
return
|
||||
}
|
||||
// Claimed pre-gate: a claim that does not declare image acceptance never
|
||||
// submits while images are attached — one notice, everything retained.
|
||||
// Enter-time adjudication applies the same policy for unclaimed lines
|
||||
// inside the command source itself.
|
||||
const before = this.snapshot
|
||||
if (before.phase === 'claimed' && this.imageIds.length > 0 && before.claim?.images !== true) {
|
||||
this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft))
|
||||
return
|
||||
}
|
||||
this.run(this.core.dispatch({ type: 'enter', mode }))
|
||||
const phase = this.snapshot.phase
|
||||
if (phase === 'adjudicating' || phase === 'submitting') {
|
||||
@@ -456,7 +479,7 @@ export class SessionInputShell implements SessionInput {
|
||||
this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
|
||||
return
|
||||
}
|
||||
inputTriggers.adjudicate(draft.trim(), attempt.signal).then(
|
||||
inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then(
|
||||
(outcome: PickOutcome) => {
|
||||
if (this.dead(attempt)) return
|
||||
this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome }))
|
||||
@@ -469,13 +492,31 @@ export class SessionInputShell implements SessionInput {
|
||||
)
|
||||
}
|
||||
|
||||
/** The submit transaction: claim.submit against the session scope; ok maps from the outcome kind. */
|
||||
/**
|
||||
* The submit transaction: claim.submit against the session scope; ok maps
|
||||
* from the outcome kind. An accepting claim receives the serialized draft
|
||||
* images, which are cleared and released only on a success outcome; a
|
||||
* failure (serialize, transport, or handler error) keeps draft and images
|
||||
* for correction.
|
||||
*/
|
||||
private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void {
|
||||
const imageIds = claim.images === true ? [...this.imageIds] : []
|
||||
Promise.resolve()
|
||||
.then(() => claim.submit(args, this.deps.actx))
|
||||
.then(async () => {
|
||||
const images = imageIds.length > 0 ? await this.deps.commandImages.serialize(imageIds) : []
|
||||
// Serialization may outlive the attempt (large files, session
|
||||
// teardown); a dead attempt must not reach the Host executor.
|
||||
if (this.dead(attempt)) return undefined
|
||||
return claim.submit(args, this.deps.actx, images)
|
||||
})
|
||||
.then(
|
||||
(outcome) => {
|
||||
if (this.dead(attempt)) return
|
||||
if (outcome === undefined || this.dead(attempt)) return
|
||||
if (outcome.kind === 'success' && imageIds.length > 0) {
|
||||
const submitted = new Set(imageIds)
|
||||
this.imageIds = this.imageIds.filter(id => !submitted.has(id))
|
||||
this.deps.commandImages.release(imageIds)
|
||||
}
|
||||
this.run(this.core.dispatch({
|
||||
type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome,
|
||||
}))
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* real host entity, so the sink is one unconditional prompt path.
|
||||
*/
|
||||
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InputTriggerController } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type { InputTriggerController, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { queueReadFaceOf } from '../queue/store.ts'
|
||||
import type { ComposerKeyboard, DraftAttachmentId, SessionInputResolver, SessionInput } from './contract.ts'
|
||||
@@ -30,6 +30,7 @@ interface ConversationAttachmentFace {
|
||||
imageIds: readonly DraftAttachmentId[],
|
||||
mode: InputSubmitMode,
|
||||
): Promise<void>
|
||||
serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise<readonly SubmitImageAttachment[]>
|
||||
releaseDraftImage(id: DraftAttachmentId): void
|
||||
}
|
||||
|
||||
@@ -77,6 +78,20 @@ export class InputHub implements SessionInputResolver {
|
||||
queue: queueReadFaceOf(session),
|
||||
defaultSink: (text, imageIds, mode) => { this.sink(session, text, imageIds, mode) },
|
||||
steerQueue: () => { void this.steerQueue(session, shell) },
|
||||
commandImages: {
|
||||
serialize: ids => this.conversation().serializeDraftImages(ids),
|
||||
// Asymmetric with serialize on purpose: release settles AFTER the
|
||||
// submit RPC, where session teardown may already have unloaded the
|
||||
// conversation service (the same tolerance as the scope disposer
|
||||
// above); leaked preview URLs then die with the document.
|
||||
release: (ids) => {
|
||||
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
|
||||
for (const imageId of ids) conversation?.releaseDraftImage(imageId)
|
||||
},
|
||||
unsupportedNotice: token => this.t('command.imagesUnsupported', {
|
||||
command: token.trim().replace(/^\//u, ''),
|
||||
}),
|
||||
},
|
||||
})
|
||||
this.shells.set(id, shell)
|
||||
// The one teardown axis: listeners, shell, and map entries all ride the
|
||||
@@ -144,7 +159,7 @@ export class InputHub implements SessionInputResolver {
|
||||
* Default sink: optimistic clear + prompt. The session is always a real
|
||||
* host entity (materialized when its workspace was picked), so there is
|
||||
* exactly one path; a failed first prompt is an ordinary prompt failure
|
||||
* (error strip via promptError, draft restored only while untouched).
|
||||
* (banner via promptError, draft restored only while untouched).
|
||||
*/
|
||||
private sink(
|
||||
session: SessionFace,
|
||||
|
||||
@@ -136,7 +136,15 @@ export class InputMachine {
|
||||
imageIds: [],
|
||||
draftRev: this.draftRev,
|
||||
phase: this.phase,
|
||||
...(c ? { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}) } } : {}),
|
||||
...(c
|
||||
? {
|
||||
claim: {
|
||||
token: c.token,
|
||||
...(c.hint !== undefined ? { hint: c.hint } : {}),
|
||||
...(c.images === true ? { images: true } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
occurrences: this.occurrences,
|
||||
...(this.paste !== undefined ? { paste: this.paste } : {}),
|
||||
queue: EMPTY_QUEUE,
|
||||
|
||||
@@ -134,6 +134,7 @@ export const zh = {
|
||||
'command.failed': '命令失败',
|
||||
'command.done': '已完成',
|
||||
'command.title': '命令',
|
||||
'command.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片',
|
||||
'approval.waiting': '等待审批',
|
||||
'approval.detail.aria': '审批详情',
|
||||
'approval.escalation': '工具 {toolName} 请求越权执行',
|
||||
@@ -304,6 +305,7 @@ export const en = {
|
||||
'command.failed': 'Command failed',
|
||||
'command.done': 'Completed',
|
||||
'command.title': 'Command',
|
||||
'command.imagesUnsupported': '/{command} does not accept image attachments; remove them first',
|
||||
'approval.waiting': 'Waiting for approval',
|
||||
'approval.detail.aria': 'Approval details',
|
||||
'approval.escalation': 'Tool {toolName} requests privileged execution',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user