diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index f8786470c7..2c9391bc96 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -334,6 +334,9 @@ "simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml": "sha256:eef600eafd70a576b2ac16a74f5dd5010ee601376008a90be10f1da56d746cee", "simplification/2026-07-02-remove-stream-chunk-mirror.md": "sha256:0c22a896260c6eb8991cc8babd8172f7b6889fc41bb891d748f34650b3eee5ec", "simplification/2026-07-02-remove-stream-chunk-mirror.zh.md": "sha256:ae7a3c2450b16fdf2f8da9e1f83cd987bf387671ecc8ebb76d2e7541695e7ee1", + "simplification/2026-07-04-drop-image-content-block.i18n.yaml": "sha256:6af8bc85be1fc23b445b17ff96df69488afa82c7194fcb47beb7a6b7b5a1de6a", + "simplification/2026-07-04-drop-image-content-block.md": "sha256:31e619d09405044a4db4951084f8d12417868b8f255ca26a44af0a5a13d43e6b", + "simplification/2026-07-04-drop-image-content-block.zh.md": "sha256:8fa6558bbb014a38853796102099ffbd5d8151371d68d40b2bf262ad07f4c976", "simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml": "sha256:e4c992a27ae0e37e5ef663c2cddf55eefe20387fd6103bebf655834d8e75e9db", "simplification/2026-07-04-drop-inert-request-knobs.md": "sha256:8735c2b868a85b13235e0491a0fa7b9570dd090eef5170324fc5e93782687b67", "simplification/2026-07-04-drop-inert-request-knobs.zh.md": "sha256:78b243f5d580f2a6fbbdb7d26574295d6ed74feb8d9bba34bbcdf4aa87624b5c", diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml similarity index 69% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml index f51aaf86b4..f718de7c8a 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md -2026-07-04-drop-image-content-block.md: 5d0357270973ac20234d8ce72c403f27ce2ad0e0 -2026-07-04-drop-image-content-block.zh.md: a13133059cdf4f6e6e8fe44e313a9795a57c10c9 +2026-07-04-drop-image-content-block.md: 362afbbe7c5c1a3b46b1e8abda3b0fec77d1a0e6 +2026-07-04-drop-image-content-block.zh.md: 07fbbd1f165887bf3305ccfa7ba137656ce0a99c diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md index 5d03572709..362afbbe7c 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md @@ -1,6 +1,7 @@ # Agent Note: Drop the `image` content block until a path can honor it Status: implemented +Archived: 2026-08-19 English | [中文](2026-07-04-drop-image-content-block.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md index a13133059c..07fbbd1f16 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md @@ -1,6 +1,7 @@ # Agent Note: 移除 `image` 内容块,直到有路径能真正处理它 Status: implemented +Archived: 2026-08-19 [English](2026-07-04-drop-image-content-block.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index 0b150d8090..0c0e629d26 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md -2026-06-11-content-block-vocabulary.md: 5228724bb9101307db9929aaf7831b477c2a6022 -2026-06-11-content-block-vocabulary.zh.md: b43de335917c6b5304a94b296fcb8253827b0600 +2026-06-11-content-block-vocabulary.md: a31df6a7d16ea7cba649702fdb474dab34533c1b +2026-06-11-content-block-vocabulary.zh.md: 5ac882e9de7dea02cc6534aee99c46869cc9363f diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md index 5228724bb9..a31df6a7d1 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -22,7 +22,7 @@ In-session context injection (`context/message`) and mid-turn steering originall ## Consequences - Reasoning has a core home without provider-specific shapes. -- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md). +- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md). - Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Notes. - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. - IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index b43de33591..5ac882e9de 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -22,7 +22,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 ## 后果 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。 -- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。 +- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md)。 - 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 - 跨包边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 38a665a0fd..85a8dd684b 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 93b6cd1bd058499e71948d3909de8e4c076b445e -2026-06-18-shared-persistence-write-coordinator.zh.md: 9e1bc736d4dba5e59b763710976425fa0ae76c30 +2026-06-18-shared-persistence-write-coordinator.md: 286bbb7d5cd3720109db0d0abc0bb72ddbfcbdcd +2026-06-18-shared-persistence-write-coordinator.zh.md: d24398c4bc9445739b4cdeb9f2ed176577060932 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 93b6cd1bd0..286bbb7d5c 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -16,6 +16,10 @@ Composition, not inheritance. The coordinator is a concrete class the backend ho The coordinator holds one lifecycle entry for each exact live `Session`: initialization plus a package-private write controller that owns pending events, a fixed batching deadline, the active write, failure retention, and the shared flush barrier. Each `session/event` enters that bounded write path, and `session/flush` bypasses the wait to observe quiescence. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns controller consolidation; the [bounded batching decision](2026-08-08-bounded-session-persistence-write-batching.md) owns scheduling cadence. +Creation borrows the exact `Session.events` snapshot as its persistence seed. `Session` has already detached, validated, and deeply frozen every event, and the snapshot array remains stable when later appends replace the cached view. The coordinator and its backend hooks only read this typed in-process value, so cloning the complete log again would duplicate the ownership work described by the [agent-scope runtime decision](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify). Public persistence `append()` still snapshots caller-owned input at its API boundary. + +Prepared-session suffixes and events admitted to the write-behind queue retain their existing copies. Those paths establish asynchronous queue ownership one suffix or event at a time and have no measured whole-log clone cost; removing their copies remains a separate ownership audit rather than part of creation-seed borrowing. + The coordinator retires a session from `session/disposed`: it waits for the controller's initialization and current flush, serializes a final drain, and removes the controller and owned per-id state only after success. A failure leaves the controller discoverable for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still current, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters write-path listeners, flushes every remaining controller, awaits per-id operations, and then closes the backend. ### The hook interface (`PersistenceBackend`) diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 9e1bc736d4..d24398c4bc 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -16,6 +16,10 @@ Status: implemented 协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.md)定义。 +创建流程将 `Session.events` 的原始快照借作持久化种子。`Session` 已经分离、验证并深度冻结每个事件,后续追加会替换缓存视图,因此该快照数组保持稳定。协调器及其后端钩子只读取这个有类型的进程内值;再次克隆完整日志会重复 [agent scope 运行时决策](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify)规定的所有权工作。持久化服务的公开 `append()` 仍在 API 边界为调用方拥有的输入创建快照。 + +已准备 Session 的后缀,以及进入 write-behind 队列的事件,仍保留现有复制。这些路径会逐个后缀或事件建立异步队列所有权,且没有已测得的完整日志克隆成本;移除这些复制属于单独的所有权审计,不属于创建种子的借用决策。 + 协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。 ### 钩子接口(`PersistenceBackend`) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 79be5c180b..21c607072c 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 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 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index fa2f86893b..40433d99e5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -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--` 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--` 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` 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` 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` 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. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 509bec18ed..54030fa4b0 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -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--` 写入 `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--` 写入 `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` 标签流水线,构建一个 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` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `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` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `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 打开,直到轮次结束。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 5db1a9baf8..20edc37d09 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: c90979ff3ade89c11af7fb9a73d536aa2c1daa12 -2026-08-04-configuration-source-ownership.zh.md: 31a813eddf4aefaf4bce1fd9999660abc8032c29 +2026-08-04-configuration-source-ownership.md: 2cd09ae2daca2b15657caa18ff210fa178c2999b +2026-08-04-configuration-source-ownership.zh.md: 76625cade4b1034c2327344945f80e3a4fec5bdc diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index c90979ff3a..2cd09ae2da 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -42,7 +42,7 @@ The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, **The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `LaunchEnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for the decisions where a layer must be unreachable, not because the project is one of them today. -**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. +**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. @@ -53,7 +53,7 @@ The line is that these take effect with no user action, before any turn, outside ## Consequences - The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. -- A `.env` holding `DSH_*`, `PATH`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. +- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 31a813eddf..76625cade4 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -43,7 +43,7 @@ inherited process environment (read-only, wins) **harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`LaunchEnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制是为「某一层必须不可达」的那些决策准备的,而项目层今天不在其列。 -**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 +**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR`、`PAGER`、`BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 这条界线在于:它们无需任何用户动作、在任何轮次开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具每次发出 `bash -c` 时执行项目指定的文件——项目的代码在 agent(智能体)的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 @@ -54,7 +54,7 @@ inherited process environment (read-only, wins) ## Consequences - Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 -- 含 `DSH_*`、`PATH` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 +- 含 `DSH_*`、`PATH`、`BROWSER` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml new file mode 100644 index 0000000000..50cdedbe5b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md +2026-08-10-cancelled-stream-prefix-finalize.md: 0cae25b786922fba8204d68ca9c0a669e43d76a0 +2026-08-10-cancelled-stream-prefix-finalize.zh.md: e961ea6a51f74dcc244e4ad8970eae4cbe4c9a6c diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md new file mode 100644 index 0000000000..0cae25b786 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md @@ -0,0 +1,39 @@ +# Agent Note: Cancelled streams finalize their delivered prefix + +Status: implemented + +English | [中文](2026-08-10-cancelled-stream-prefix-finalize.zh.md) + +## Problem + +A cancelled stream can leave `assistant/chunk` events that clients continue rendering while `deriveMessages()` excludes them because no `assistant/message` records the delivered prefix. A follow-up such as "expand on your second point" then lacks text the user read, and a fork at the cancelled turn inherits the same gap. + +The model history must contain assistant content that remains visible to the user after cancellation. + +## Decision + +`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, logged chunk seqs, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`. + +`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. Provider `error` and `aborted` finishes leave the stream-consumption scope before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed request. + +Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provenance. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results. + +## Alternatives considered + +**Always discard the prefix.** This avoids a new durable marker but makes every cancel-then-follow-up and fork omit assistant content that remains visible to the user. + +**Assemble the prefix from chunks during projection.** `deriveMessages()` and client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative assistant message for the prefix. This also expands model history beyond the three `SurfaceEventType` events. + +**Retain complete tool calls with synthetic aborted results.** These calls never dispatched, so synthetic results would claim an execution outcome that did not occur and add content the user did not receive as a tool result. + +**Append a model-visible interruption message such as `[interrupted by user]`.** This can tell the model that the prefix is incomplete, but it requires a separate source type, projection rule, UI treatment, and localized wording. The durable aborted `turn/end` preserves the fact needed for that later decision. + +## Consequences + +Post-cancel follow-ups and forks include the delivered prefix. The ACP bridge drains ordered assistant output before settling the prompt, so the final `agent_message_chunk` update precedes the cancelled stop reason. + +Terminal provider errors still discard their streamed prefix. That asymmetry remains because an error turn ends without the user's cancellation decision and requires its own retention policy. + +## Testing + +`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications. diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md new file mode 100644 index 0000000000..e961ea6a51 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 被取消的流定稿其已送达前缀 + +Status: implemented + +[English](2026-08-10-cancelled-stream-prefix-finalize.md) | 中文 + +## Problem + +被取消的流可能留下客户端继续渲染的 `assistant/chunk` 事件,但如果没有 `assistant/message` 记录已送达前缀,`deriveMessages()` 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。 + +模型历史必须包含取消后仍对用户可见的 assistant 内容。 + +## Decision + +`ReactLoopAgent.step()` 在消费模型流期间捕捉取消,此时 `BlockAssembler`、已记录的分片 seq 和提供方路由可以确定已送达前缀。循环把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`。 + +`BlockAssembler.interruptedBlocks()` 按流顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` 块。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时不追加 assistant 消息。提供方的 `error` 和 `aborted` finish 会在 `agent/request-error` 前离开流消费范围,因此提供方故障和恢复期间的取消都不会提交失败请求的内容。 + +Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`。Chat 渲染 Stopped 标记,Trajectory 则在 `step/end` 后把提供方请求保持在 error 生命周期,并保留持久结果 seq 和提供方信息。工具执行期间的取消遵循工具调度器约定,因为 assistant 消息已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。 + +## Alternatives considered + +**始终丢弃前缀。** 这能避免新增持久标记,但每次取消后的追问和分支都会缺少仍对用户可见的 assistant 内容。 + +**在投影时从分片组装前缀。** `deriveMessages()` 和客户端 Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 assistant 消息。这还会让模型历史超出三类 `SurfaceEventType` 事件。 + +**保留完整工具调用并合成 aborted 结果。** 这些调用从未分派,合成结果会声称一个并未发生的执行结果,还会增加用户未收到的工具结果内容。 + +**追加 `[interrupted by user]` 之类模型可见的打断消息。** 这可以告诉模型前缀并不完整,但需要独立的来源类型、投影规则、UI 处理和本地化文案。持久的 aborted `turn/end` 保留了该后续决策所需的事实。 + +## Consequences + +取消后的追问和分支会包含已送达前缀。ACP 桥会在结算 prompt 前排空按序传送的 assistant 输出,因此最后一条 `agent_message_chunk` 更新先于 cancelled stop reason。 + +终局提供方错误仍会丢弃已流出前缀。该不对称保留,因为 error 轮次的结束不来自用户的取消决定,需要独立的保留策略。 + +## Testing + +`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index f5b6075335..1ce9bd62d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md -2026-08-10-session-log-version-mechanism.md: 058f3a98b67e35d6c6e46f7bb8bc625d419af7f1 -2026-08-10-session-log-version-mechanism.zh.md: 8738f3bbabd34804401859d847ca69b69a6b5d2b +2026-08-10-session-log-version-mechanism.md: 8515d04ebfbd9e23ff5b58ca8b4bb888f8ba5dc9 +2026-08-10-session-log-version-mechanism.zh.md: 04e66b5da3a14d1c3f919cb5cdb8ded0f76a2baf diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml new file mode 100644 index 0000000000..80a1959b5a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +2026-08-11-pwsh-persistent-pty.md: 8353b3ab3cdbf20add22a55acb03312c94283602 +2026-08-11-pwsh-persistent-pty.zh.md: 95048a02416dfcf5f0ef2837d99a561008f6496f diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md new file mode 100644 index 0000000000..8353b3ab3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -0,0 +1,65 @@ +# Agent Note: Persistent pwsh over the terminal seam on Windows + +Status: implemented + +English | [中文](2026-08-11-pwsh-persistent-pty.zh.md) + +## Problem + +The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-terminal-bash` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work. + +The gap excluded Windows workflows whose state lives in a terminal: stepping a debugger, exploring in a Python or Node REPL, or returning to a shell after interrupting its foreground command — the same class of work the persistent bash pty serves on POSIX. + +Two foundations already existed. the terminal service itself (`ctx.terminals` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows. + +## Decision + +A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `terminal-bash`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows. + +### Windows substrate in `@deepseek-ai/dsh-subprocess-local` + +`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes creation identities with zero-time process-handle waits (pid-reuse fencing plus terminated-object detection), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only. + +`LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend. + +### Shell dialect in `@deepseek-ai/dsh-terminal-bash` + +One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred). + +### `@deepseek-ai/dsh-tool-pwsh-persistent` + +A new package mirroring `tool-bash-persistent`: same `Config` (`backendType` default `shell`, `timeoutMs`, `maxOutputChars`, `description`), same owner-scoped shell registry and serialized per-owner queue, same timeout/abort/exit/reset paths. The tool name is `pwsh`; it never co-mounts with the one-shot `tool-pwsh` because the preset rows are mutually exclusive per platform. + +Commands run through a wrapper that resets `$LASTEXITCODE` (assignable, verified), invokes the body via `Invoke-Expression` in a backtick-escaped double-quoted string (`quoteForPwsh`: backtick, quote, `$`, CRLF, and ESC escapes, so no raw control characters ride the input line and the wrapper survives ConstrainedLanguage), and reports the exact native exit code, `1` for a terminating PowerShell error, or `0` for success. PSReadLine echoes the submitted wrapper back into the stream — there is no `stty -echo` equivalent — so the extraction strips the wrapper source from captured output; the echo can never fabricate completion because the status regex needs digits immediately after the END nonce and the echo continues with quote characters. The prompt function installs the tool's own prompt (`__DSH_PERSISTENT_PWSH_PROMPT__ `) over the backend bootstrap value, the same two-layer structure as bash. + +### Composition + +The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`terminal-bash` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`terminal-bash` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh. + +### Testing + +The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result. + +## Alternatives considered + +- **A separate `pty-pwsh-local` backend package.** Rejected: the local session, sanitizer, readiness tiers, and sandbox fence are shared machinery; duplicating the 500-line session for argv/env/startup differences trades one config field for a package of copy-paste, unlike the bash group's thin parallel executors. +- **tasklist or wmic polling for the process tree.** Rejected: `inspectForeground` runs on every readiness poll (~50 ms), so a spawned probe per tick is untenable, and wmic is removed from current Windows releases. koffi + Toolhelp32 is in-process and cheap. +- **A native helper or `GenerateConsoleCtrlEvent` for SIGINT.** Rejected: writing `\x03` to ConPTY input interrupts running commands (verified) with zero new code. The semantic difference — at a prompt, `\x03` cancels the pending line instead of signalling a process — is documented rather than engineered around. +- **Base64 body encoding for the wrapper.** Rejected: decoding needs `[Convert]`/`[System.Text.Encoding]` calls whose ConstrainedLanguage status is unproven, while backtick-escaped double-quoted strings use only language-level constructs and were verified end-to-end. +- **Tolerating the echo without stripping the wrapper.** Rejected: in complete and prompt-settled paths the echo is naturally excluded, but timeout and lost-START fallbacks would leak the wrapper source (including marker nonces) into model-visible text. +- **Resurrecting a BEL model-notification channel.** Rejected: the current implementation consumes no marker payload and delivers no BEL events; the design aligns with the current implementation and keeps the deferred item deferred. +- **Windows PowerShell 5.1 as a first-class target.** Rejected: pwsh 7 (including the Store install) is the target; `resolvePwshPath` keeps 5.1 as the last-resort executable fallback without promising full persistent-shell behavior on it. + +## Consequences + +**Windows became a first-class persistent-shell host.** The persistent pwsh stack runs and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. + +**Windows coverage keeps master's exemption structure.** subprocess-local and terminal-bash sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`. + +**Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier. + +**Windows teardown and signalling differ from POSIX.** taskkill without `/F` does not terminate console processes (the TERM tier is a grace wait before `/F`), SIGINT is console-wide Ctrl-C, SIGTSTP/SIGHUP are unavailable, and externally taskkilled shells may not fire node-pty's exit notification — the handle settles from verified absence instead. + +**Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks. + +**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; commands then settle through the printable prompt and silence tier, while non-ASCII output may follow the host code page. A model redefinition of the `prompt` function likewise degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md new file mode 100644 index 0000000000..95048a0241 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -0,0 +1,65 @@ +# Agent Note: Windows 上基于 terminal seam 的持久化 pwsh + +Status: implemented + +[English](2026-08-11-pwsh-persistent-pty.md) | 中文 + +## 问题 + +harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-terminal-bash` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。 + +这个缺口排除了状态驻留在终端里的 Windows 工作流:单步调试、在 Python 或 Node REPL 中探索、中断前台命令后回到原 shell —— 正是持久 bash pty 在 POSIX 上服务的同一类工作。 + +两个基础已经存在。PTY 服务本身(`ctx.terminals` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。 + +## 决定 + +模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`terminal-bash` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。 + +### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座 + +`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 创建身份与进程句柄零时等待结合起来(同时防止 PID 复用并识别已终止的进程对象),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。 + +`LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。 + +### `@deepseek-ai/dsh-terminal-bash` 的 shell 方言 + +一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。 + +### `@deepseek-ai/dsh-tool-pwsh-persistent` + +新包镜像 `tool-bash-persistent`:同样的 `Config`(`backendType` 默认 `shell`、`timeoutMs`、`maxOutputChars`、`description`)、同样的 owner 作用域 shell 注册表与每 owner 串行队列、同样的超时/中止/退出/重置路径。工具名是 `pwsh`;它与一次性 `tool-pwsh` 永不共挂,因为预设行按平台互斥。 + +命令经包装器执行:先重置 `$LASTEXITCODE`(可赋值,已实测),通过 `Invoke-Expression` 在反引号转义的双引号字符串中执行 body(`quoteForPwsh`:反引号、引号、`$`、CRLF 与 ESC 转义,输入行上不携带裸控制字符,包装器可在 ConstrainedLanguage 下存活),报告精确原生退出码、PowerShell 终止性错误的 `1` 或成功的 `0`。PSReadLine 会把提交的包装器回显进流——没有 `stty -echo` 的对应物——因此提取会从捕获输出中剥离包装器原文;回显无法伪造完成,因为状态正则要求 END nonce 后紧跟数字,而回显继续是引号字符。prompt 函数安装工具自有提示符(`__DSH_PERSISTENT_PWSH_PROMPT__ `)覆盖 backend 引导值,与 bash 的双层结构相同。 + +### 组合 + +minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`terminal-bash` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `terminal-bash` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。 + +### 测试 + +Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。 + +## 备选方案 + +- **独立的 `pty-pwsh-local` backend 包。** 拒绝:本地 session、sanitizer、就绪档位和沙箱栅栏是共享机制;为一个 config 字段复制 500 行 session 换来的是一包复制粘贴,与 bash 组并置薄 executor 的情形不同。 +- **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。 +- **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。 +- **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。 +- **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。 +- **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。 +- **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。 + +## 后果 + +**Windows 成为一等公民的持久 shell 宿主。** 持久 pwsh 栈在 windows-native 车道上运行并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 + +**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 terminal-bash 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。 + +**Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。 + +**Windows 的拆卸与信号不同于 POSIX。** 不带 `/F` 的 taskkill 无法终止控制台进程(TERM 档是 `/F` 升级前的宽限等待)、SIGINT 是控制台级 Ctrl-C、SIGTSTP/SIGHUP 不可用,且被外部 taskkill 的 shell 可能不触发 node-pty 的退出通知——句柄改从验证的消失状态结算。 + +**输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。 + +**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml new file mode 100644 index 0000000000..b97fb2d1e8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-client-build-environment.md +2026-08-18-client-build-environment.md: 45ed6c8bc68e0f08157fb56a91ae4f6165e6e431 +2026-08-18-client-build-environment.zh.md: bb9633721401f66b443a65253dcbc0241f45d328 diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md new file mode 100644 index 0000000000..45ed6c8bc6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md @@ -0,0 +1,37 @@ +# Agent Note: Build-time public environment variables for client business code + +Status: implemented + +English | [中文](2026-08-18-client-build-environment.zh.md) + +## Problem + +Browser business packages need deployment builds to select static behavior, but the Web client has two artifact paths that do not contain one another: Vite builds the static shell, while the shared tsdown preset builds dynamically loaded plugins. Replacing an environment expression in only one path would give the same business expression different results depending on its package type. + +Browsers have no Node `process`, and embedding the build process's complete environment object would expose values unrelated to the frontend. Runtime configuration also does not accurately represent a build variant because this choice must remain fixed after an artifact is published. + +## Decision + +`DSH_CLIENT_*` is the build-time namespace for values that may be exposed to browser business code. Business code may use a static property read such as `process.env.DSH_CLIENT_NAME` to select behavior. Values come only from the build process environment, not from Vite `.env*` files. Set values are inlined as strings, and unset values evaluate to `undefined`. + +The Vite config and the shared tsdown preset for dynamic client bundles use one define generator. The generator creates exact substitutions only for `DSH_CLIENT_*` and reduces all remaining `process.env` reads to an empty object. The browser receives no global `process`, dynamic-key lookup, or environment enumeration capability. + +The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, paths, and other Host- or CI-only values must not use it. + +The root build wrapper supplies one exact public environment to both bundlers. It derives `DSH_CLIENT_COMMIT_HASH` as the seven-character prefix of the source Git HEAD for every complete build; an explicit value supports build environments without repository metadata. `pnpm run build` otherwise inherits the caller's `DSH_CLIENT_*` values, while `pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax and sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record. + +## Alternatives considered + +**Replace values only in Vite.** A dynamic plugin's `lib/client.js` is loaded as an independent script and never enters Vite's module graph, so the expression would remain in a browser that has no `process`. + +**Expose every `DSH_*` value.** Host, test, and CI variables already use that prefix and may contain credentials or local paths. The narrower `DSH_CLIENT_*` prefix makes exposure intent auditable. + +**Provide a complete `process.env` object in the browser.** This would permit build-environment enumeration and turn a Node compatibility shim into a runtime API. Exact static substitutions are sufficient for build choices. + +**Standardize on `import.meta.env`.** Dynamic plugins are emitted as independent CommonJS factories and cannot retain `import.meta`. Business code would still need two interfaces depending on the artifact path. + +## Consequences + +The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its short source revision as public display metadata. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption. + +Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism. diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md new file mode 100644 index 0000000000..bb96337214 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Client 业务代码使用构建期公开环境变量 + +Status: implemented + +[English](2026-08-18-client-build-environment.md) | 中文 + +## Problem + +浏览器业务包需要按部署构建选择静态行为,但 Web client 有两条互不包含的产物路径:Vite 构建静态壳,共享 tsdown preset 构建运行时加载的动态插件。只在一条路径替换环境变量会使相同业务表达式因所在包类型不同而产生不同结果。 + +浏览器没有 Node `process`,而把构建进程的完整环境对象放入产物会泄露与前端无关的值。运行时配置也不能准确表达构建变体,因为产物发布后不应再改变这类选择。 + +## Decision + +`DSH_CLIENT_*` 是可公开给浏览器业务代码的构建期命名空间。业务代码可用静态点访问 `process.env.DSH_CLIENT_NAME` 选择行为;值只取自构建进程环境,不读取 Vite `.env*` 文件。设置的值在构建时内联为字符串,未设置的值为 `undefined`。 + +Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define 生成器。生成器只为 `DSH_CLIENT_*` 创建精确替换,并把其余 `process.env` 读取收敛到空对象;浏览器不获得全局 `process`、动态键读取或环境枚举能力。 + +`DSH_CLIENT_*` 的名称本身表示公开性。凭据、路径和其他仅供 Host 或 CI 使用的值不得使用该前缀。 + +根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都会把源码 Git HEAD 的七位前缀派生为 `DSH_CLIENT_COMMIT_HASH`;没有仓库元数据的构建环境可显式提供该值。除此之外,`pnpm run build` 继承调用方的 `DSH_CLIENT_*` 值,`pnpm run build:official` 则不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile,并设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。 + +## Alternatives considered + +**只在 Vite 中替换。** 动态插件的 `lib/client.js` 作为独立脚本由浏览器加载,不进入 Vite 模块图,表达式会残留到无 `process` 的浏览器。 + +**公开全部 `DSH_*`。** 仓库中的 Host、测试和 CI 变量使用该前缀,其中可能包含凭据或本地路径;更窄的 `DSH_CLIENT_*` 让公开意图可审计。 + +**在浏览器提供完整 `process.env` 对象。** 这会允许枚举构建环境并把 Node 兼容垫片变成运行时 API;静态精确替换足以承载构建选择。 + +**统一改用 `import.meta.env`。** 动态插件输出为独立 CJS factory,不能保留 `import.meta`;业务代码仍会因产物路径不同而使用两套接口。 + +## Consequences + +Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的短源码 revision。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败。 + +任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml new file mode 100644 index 0000000000..e1aaf99127 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md +2026-08-18-sqlite-physical-chunk-row-compression.md: e46adf26ab4ce0a495f3509977ab0835631c16a9 +2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 8e3978c1319545fd09dc668fbd46434a03eb8def diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md new file mode 100644 index 0000000000..e46adf26ab --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md @@ -0,0 +1,81 @@ +# Agent Note: SQLite physical chunk-row compression + +Status: implemented + +English | [中文](2026-08-18-sqlite-physical-chunk-row-compression.zh.md) + +## Problem + +The scalar [`session-persistence-sqlite`](../../../../packages/session/session-persistence-sqlite/README.md) layout stores one physical row per logical `SessionEvent`. Provider streams produce token-sized `assistant/chunk` events with repeated turn, step, block, type, and envelope fields, so transaction batching reduces commits without reducing row count or repeated JSON payload. The logical stream cannot be coalesced because chunk boundaries, sequence numbers, timestamps, replay, partial output, UI fidelity, and `sourceEventSeqs` remain observable. + +A physical row that represents several events affects append contiguity, crash repair, suffix seeks, schema ownership, revisions, and stale writers. Durable decoding must also be fixed by the schema version; a configurable codec set could make one schema version unreadable under a different Cordis composition. + +## Decision + +`@deepseek-ai/dsh-session-persistence-sqlite` uses the packed schema-17 implementation. It is the only SQLite persistence package and provider; the predecessor scalar layout and the temporary versioned sibling are not retained. SQLite remains an opt-in switch, while shipped default compositions continue to use JSONL. Both backends implement the same `SessionPersistence` service through `PersistenceCoordinator`, so physical packing changes neither live event delivery nor the logical session API. + +Schema 17 keeps ordinary ROWID tables and the composite `events(session_id, seq)` primary-key index. Scalar rows represent one logical event. Packed rows use the storage tags `text-chunks`, `reasoning-chunks`, and `tool-call-chunks`; the SQL `seq` and `time` columns hold the first logical member, and `data` holds the packed payload. Packed rows set `ignorable=0` as a physical discriminator and leave `source_event_seqs` and `surface_op` as `NULL`; scalar rows use `ignorable=1` only for logical ignorable events and `NULL` otherwise. A future ignorable logical event may therefore reuse a storage-tag name without being decoded as a packed row. The tags are storage vocabulary, not `SessionEventMap` members. + +SQLite owns chunk encoding and validation inside the schema-17 package. Exact-field whitelisting means unknown fields, surface metadata, incompatible chunk identity, sequence gaps, and unsafe timestamps remain scalar rather than losing information. One packed row represents at most 1,024 events and 1 MiB of uncompressed UTF-8 `data`; the encoder partitions longer runs, and the decoder rejects rows outside those format limits. + +The `data` column accepts `TEXT` or `BLOB`. Serialized values below 4 KiB remain text. At or above the threshold, the writer uses Zstandard level 3 and retains the frame only when it is smaller than the text; the reader decompresses the blob before strict UTF-8 decoding and JSON parsing. The fixed moderate level and threshold limit frame overhead and synchronous CPU work while capturing the repeated payloads that dominate retained bytes. + +`source_event_seqs` remains the complete ordered list of earlier events cited by a surface node, including every streamed chunk behind an assembled assistant message. Schema 17 stores the first sequence as an unsigned varint and every subsequent signed difference as a ZigZag varint. This preserves arbitrary order and every sequence while exploiting the overwhelmingly consecutive lists produced by streaming. An empty list is an empty non-null blob, distinct from absent provenance. + +### Transactional append packing + +Each append acquires `BEGIN IMMEDIATE`, rechecks schema ownership, selects the bounded physical span that may cover the last stored sequence, and derives the next logical sequence from that decoded tail. A mismatch rejects a stale writer before mutation. The codec packs only the new durable batch. Its inserts, lazy session materialization, and one revision increment commit or roll back together. + +Normal append never deletes or replaces an earlier event row. Fixed write-behind windows normally collect high-frequency deltas into useful runs, while sparse or explicitly flushed batches may remain scalar. This makes physical event writes proportional to newly durable batches and prevents a stable retained-row count from hiding repeated replacement of a growing JSON value. + +### Reads and repair + +Full reads decode each physical row as one all-or-nothing logical span and validate contiguous logical sequences. A reverse pass identifies the last valid `turn/end` without retaining a second decoded copy of the full physical scan; the forward pass decodes one row at a time into the required logical result. A malformed row or gap before that committed boundary is corruption; a malformed final physical row becomes the opaque repair marker at that row's base sequence. Recovery re-reads and validates that marker while holding the write lock, then deletes the whole physical row and any later rows before binding synthetic closers as scalar events. A stale repair cannot delete a newer writer's valid suffix. + +`readFrom(id, fromSeq)` examines packed predecessors only within the maximum schema-17 row span, then reads from the earliest candidate that may contain `fromSeq`. The decoder filters reconstructed members below `fromSeq`, so a suffix may begin inside a packed row without parsing an unrelated earlier scalar row. Reading from that candidate also exposes an overlapping scalar row to contiguity validation instead of letting it hide the packed member. Packed data exceeding the uncompressed format byte limit rejects before JSON parsing. + +### Schema ownership + +A pristine database initializes at schema 17. Older physical schemas, foreign application identities, non-pristine unversioned databases, and incompatible schema objects reject; the pre-release package supplies no migration. Every connection disables trusted schemas and memory-mapped I/O before inspecting durable schema, then reads both settings back. After selecting and verifying the journal mode, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. Package code loads every statement and fixed pragma from closed-name `.sql` resources and binds runtime values as parameters. + +### Physical-write regression + +The repository regression guard writes 1,000 streamed deltas in 40-event durable batches. After every committed batch it compares every retained physical field, requires cumulative inserts to equal the final row count, and rejects changed or removed rows. It also checks the exact 31-row bound, the largest persisted record against the schema byte limit, and an idle interval with no WAL extent change. These checks prove bounded row structure and catch coarse write amplification; they do not establish device traffic because WAL frames can be overwritten in place and checkpoints also write the main database. Incident-class validation separately samples process physical bytes around active and idle periods and stresses synchronized multi-process access. Lock tests hold `BEGIN IMMEDIATE` in another process and verify bounded waiting and successful continuation. + +## Alternatives considered + +**Coalesce logical chunk events.** Rejected because it changes sequence references, replay, partial output, and live delivery. Physical records provide the storage reduction while restoring the authoritative log exactly. + +**Run a periodic or post-commit compactor.** Rejected because it adds another writer lifecycle, races append and repair, changes revisions without a logical append, and adds disposal work. + +**Merge each new batch into the prior packed tail.** Rejected because a stable database and row count can hide repeated delete-and-insert churn. Paced-stream measurement found higher process and WAL writes than the predecessor scalar layout even when the retained database was smaller. Batch-local packing gives up timing-independent row convergence to bound physical writes. + +**Use `synchronous=NORMAL` with WAL.** Rejected because it permits a recent committed transaction to roll back after an operating-system crash or power loss. `append()` resolves only after its batch is durable, so the provider explicitly retains SQLite's `FULL` durability level across builds. + +**Remove ROWID from `events`.** Rejected because the composite text/integer primary key then becomes the table B-tree key and is repeated through internal pages. On the 105-session comparison corpus, selective Zstandard with ordinary ROWID used 107.02 MB; the otherwise equivalent `WITHOUT ROWID` database used 126.75 MB. + +**Set a larger SQLite page size.** Rejected because the retained-size change was negligible: 4 KiB pages used 107.08 MB and 32 KiB pages used 106.89 MB in the layout reconstruction. The larger page also increases WAL-frame and cache granularity. The provider therefore issues no `page_size` pragma. + +**Compress every payload.** Rejected because small independent Zstandard frames add headers and synchronous CPU work while losing the cross-record dictionary opportunity of a whole-file stream. On the 105-session comparison corpus, a threshold sweep produced 75.01 MB at 4 KiB, versus 93.87 MB at 16 KiB and 60.92 MB at 1 KiB. The writer fixes level 3 rather than inheriting a library default, matching the moderate level used by [Codex cold-rollout compression](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs) while retaining independent row access. + +The final frozen comparison used 105 sessions, 2,507,860 logical events, 512-event durable batches, three independent builds per backend, and three read passes per build. SQLite used 75.01 MB, wrote in 8.58 s, read complete sessions at 3.95/21.58 ms p50/p95, read 50-event tails at 0.253/0.378 ms, and forked every session in 13.10 s. Zstandard JSONL used 30.65 MB and measured 28.21 s, 4.49/23.36 ms, 10.58/80.90 ms, and 14.48 s. The predecessor scalar SQLite layout used 709.57 MB and measured 10.64 s, 9.02/69.16 ms, 0.189/0.293 ms, and 19.30 s. The packed layout is 89.4% smaller than the predecessor, writes 19.4% faster, improves complete-read p50/p95 by 56.2%/68.8%, and reduces 2,507,860 physical event rows to 65,810. Scalar tail-50 and list micro-latency are lower, but the packed provider remains materially faster than JSONL on those paths and wins the dominant size, write, full-read, and fork costs. The 4 KiB threshold is the accepted balance rather than a strict dominance claim. + +**Store packed payloads under the logical `assistant/chunk` type.** Rejected because payload heuristics make malformed rows ambiguous and couple physical decoding to future logical payload fields. Explicit tags fail loudly. + +**Store `SessionHeader` fields in an extensible metadata blob.** Rejected for schema 17 because `agentPreset` is a typed core resume invariant shared by JSONL and SQLite, not provider extension metadata. Persisting validated core fields directly keeps both backends aligned; an untyped catch-all would add another compatibility mechanism without a current producer. Revisit this only with a core-owned, namespaced `SessionHeader` extension protocol implemented by every backend. + +**Expose compression rules through configuration or a live registry.** Rejected because same-version databases must be readable independently of runtime topology. The codec is modular source code, but the durable rule set is fixed by schema version. + +**Migrate older schemas in place.** Rejected under the pre-release policy. Changing strict column types requires rebuilding the event table, which turns the first append into an unbounded historical rewrite and temporarily duplicates storage. A new database keeps activation explicit and failure predictable. + +**Store forked history as a parent reference.** Deferred because it changes independent-session persistence rather than physical row encoding. Codex uses referenced history and excludes referenced or pointer-bearing rollouts from cold compression, but this provider would first need explicit parent retention, deletion, repair, export, and cross-backend semantics. Copying remains the bounded local choice until the session service owns those rules. + +**Keep the packed implementation as a versioned sibling.** Rejected because the pre-release repository has no compatibility promise for the scalar format, while two SQLite package names duplicate configuration, documentation, tests, and ownership. Historical benchmark artifacts retain the comparison without exposing a rollback provider. + +## Consequences + +The canonical SQLite provider preserves every logical persistence, replay, revision, crash-recovery, and model-facing behavior. High-frequency batches use fewer rows and fewer measured process disk-written bytes than the predecessor in paced-stream validation; idle samples add no measured writes. Packing ratio depends on durable batch boundaries, but previously committed rows are immutable outside explicit crash repair. + +The cost is no migration from older pre-release SQLite schemas and timing-dependent physical row count. SQLite and Zstandard remain synchronous: each connection uses the configured `busyTimeoutMs` for a competing lock and blocks its JavaScript thread during that wait, while large row encoding and decoding also run on that thread. A cold open yields after an immediate `SQLITE_BUSY` journal-mode transition and starts no further attempt after an open-relative retry cutoff; an in-progress synchronous call may finish later. External SQL tooling must use the provider decoder rather than assuming every physical `events.type` is a logical event type or every payload column is text. + +The [JSONL packed-row decision](2026-07-26-packed-chunk-rows-by-default.md), [bounded persistence batching](2026-08-08-bounded-session-persistence-write-batching.md), and original [session-persistence decision](2026-06-14-session-persistence.md) remain active: they respectively own the JSONL format, write scheduling, and backend-neutral service semantics. diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md new file mode 100644 index 0000000000..8e3978c131 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md @@ -0,0 +1,81 @@ +# Agent Note: SQLite 物理分片行压缩 + +Status: implemented + +[English](2026-08-18-sqlite-physical-chunk-row-compression.md) | 中文 + +## 问题 + +标量 [`session-persistence-sqlite`](../../../../packages/session/session-persistence-sqlite/README.md) 后端为每个逻辑 `SessionEvent` 存储一个物理行。提供方流会生成 token 大小的 `assistant/chunk` 事件,并重复轮次、步骤、块、类型和 envelope 字段,因此事务批处理可以减少提交次数,却不能减少行数或重复 JSON payload。逻辑流不能合并,因为分片边界、序列号、时间戳、回放、部分输出、UI 保真度和 `sourceEventSeqs` 仍然可观察。 + +一个表示多个事件的物理行会影响追加连续性、崩溃修复、后缀定位、schema 所有权、revision 和陈旧写入方。持久解码规则还必须由包版本固定;可配置 codec 集可能导致同一 schema 版本在不同 Cordis 组合下无法读取。 + +## 决策 + +`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 17 实现。它是唯一的 SQLite 持久化包和提供方;仓库不保留此前的标量布局与临时版本化同级包。SQLite 仍是可选开关,随产品交付的默认组合继续使用 JSONL。两个后端都通过 `PersistenceCoordinator` 实现同一 `SessionPersistence` 服务,因此物理打包既不改变实时事件投递,也不改变逻辑会话 API。 + +Schema 17 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行表示一个逻辑事件。打包行使用存储标签 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks`;SQL 的 `seq` 和 `time` 列保存第一个逻辑成员,`data` 保存打包 payload。打包行把 `ignorable=0` 用作物理判别值,并让 `source_event_seqs` 与 `surface_op` 保持 `NULL`;标量行仅在逻辑事件可忽略时使用 `ignorable=1`,否则使用 `NULL`。因此,未来的可忽略逻辑事件即使复用了某个存储标签名称,也不会被解码为打包行。这些标签属于存储词汇,而不是 `SessionEventMap` 成员。 + +SQLite 在 schema 17 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、surface 元数据、不兼容的分片身份、序列缺口和不安全时间戳仍保持标量表示,不会丢失信息。一个打包行最多表示 1,024 个事件和 1 MiB 未压缩 UTF-8 `data`;编码器会分割更长的连续段,解码器则拒绝超出这些格式上限的行。 + +`data` 列接受 `TEXT` 或 `BLOB`。序列化值小于 4 KiB 时保持为文本。达到或超过该阈值时,写入方使用 Zstandard level 3,并且只在 frame 小于原文本时保留该 frame;读取方会先解压,再进行严格 UTF-8 解码和 JSON 解析。固定的适中级别与阈值限制 frame 开销与同步 CPU 工作,同时覆盖占据大部分保留字节的重复 payload。 + +`source_event_seqs` 是 surface 节点引用的早期事件的完整有序列表,包括组装后的 assistant 消息背后的每个流式分片。Schema 17 把第一个序列存为无符号 varint,把后续每个有符号差值存为 ZigZag varint。这样既能保留任意顺序和每个序列,又能利用流式处理所产生的绝大多数连续列表。空列表表示为空的非 `NULL` blob,与不存在来源区分开来。 + +### 事务化追加打包 + +每次追加会获取 `BEGIN IMMEDIATE`、重新检查 schema 所有权、选择可能覆盖最后存储序列的有界物理范围,并根据解码后的尾部推导下一逻辑序列。若不匹配,系统会在变更前拒绝陈旧写入方。Codec 只打包新的持久批次;其插入、会话惰性物化和一次 revision 递增会一起提交或回滚。 + +普通追加绝不删除或替换既有事件行。固定写后缓冲窗口通常会把高频 delta 收集成有效连续段,而稀疏或显式 flush 的批次可能保持标量形式。这样,物理事件写入量与新增持久批次成正比,稳定的保留行数无法再掩盖对不断增长 JSON 值的反复替换。 + +### 读取与修复 + +完整读取把每个物理行解码为全有或全无的逻辑范围,并验证逻辑序列连续。反向扫描会定位最后一个有效 `turn/end`,但不会保留完整物理扫描的第二份解码副本;正向扫描则逐行解码并写入必需的逻辑结果。在该已提交边界之前出现的畸形行或缺口属于损坏;畸形最终物理行则以该行的起始序列作为不透明修复标记。恢复会在持有写锁时重新读取并验证该 marker,再删除整个物理行及其后所有行,然后把合成 closers 绑定为标量事件。陈旧修复无法删除较新写入方的有效后缀。 + +`readFrom(id, fromSeq)` 只检查 schema 17 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。 + +### Schema 所有权 + +全新数据库初始化为 schema 17。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。 + +### 物理写入回归 + +仓库回归守卫以 40 个事件为持久批次写入 1,000 个流式 delta。它会在每个批次提交后比较所有保留物理字段,要求累计插入数等于最终行数,并拒绝发生变化或被移除的行。它还会检查精确的 31 行上限、最大持久记录不超过 schema 字节上限,并观察空闲区间内 WAL 范围不再变化。这些检查证明行结构有界并捕获粗粒度写放大;它们不能证明设备写流量,因为 WAL 帧可在原位覆写,检查点还会写入主数据库。事故级验证另行采样活动期和空闲期前后的进程物理写入字节,并对同步多进程访问进行压力测试。锁测试在另一个进程中持有 `BEGIN IMMEDIATE`,验证有界等待及之后成功继续。 + +## 考虑过的替代方案 + +**合并逻辑分片事件。** 不予采用,因为它会改变序列引用、回放、部分输出和实时投递。物理记录可以在准确恢复权威日志的同时获得存储缩减。 + +**运行周期性或提交后压缩器。** 不予采用,因为它会增加另一个写入方生命周期,与追加和修复竞争,在没有逻辑追加的情况下改变 revision,并增加资源释放工作。 + +**把每个新批次合并进已有打包尾部。** 不予采用,因为稳定的数据库与行数可能掩盖反复删除和插入产生的写入流量。节奏化流测量表明,即使保留数据库更小,该方案写入的进程字节与 WAL 字节仍高于此前的标量布局。逐批打包放弃与时序无关的行收敛,以换取有界物理写入。 + +**在 WAL 模式下使用 `synchronous=NORMAL`。** 不予采用,因为操作系统崩溃或断电后,最近提交的事务可能回滚。`append()` 只会在批次持久化后返回,因此提供方会在不同 SQLite 构建中显式保留 `FULL` 持久性级别。 + +**从 `events` 移除 ROWID。** 不予采用,因为复合文本/整数主键随后会成为表 B-tree 的键,并在内部页中重复。在 105 个会话的对比语料上,使用普通 ROWID 的选择性 Zstandard 数据库为 107.02 MB;其余条件相同的 `WITHOUT ROWID` 数据库为 126.75 MB。 + +**设置更大的 SQLite page size。** 不予采用,因为保留体积变化可以忽略:在独立的 page-size 布局重建中,4 KiB page 使用 107.08 MB,32 KiB page 使用 106.89 MB。更大的 page 还会增大 WAL frame 和 cache 粒度。因此提供方不设置 `page_size` pragma。 + +**压缩每个 payload。** 不予采用,因为小型独立 Zstandard frame 会增加 header 和同步 CPU 工作,也无法利用整文件流的跨记录字典。在 105 个会话的对比语料上,阈值扫描结果为:4 KiB 生成 75.01 MB,16 KiB 为 93.87 MB,1 KiB 为 60.92 MB。写入方固定使用 level 3,而不是继承库默认值;这与 [Codex 冷 rollout 压缩](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs)所用的适中级别一致,同时保留独立行访问。 + +最终冻结对比包含 105 个会话、2,507,860 个逻辑事件,以 512 个事件为持久批次;每个后端独立构建三次,每次构建执行三轮读取。SQLite 使用 75.01 MB,写入耗时 8.58 秒,完整读取 p50/p95 为 3.95/21.58 毫秒,读取最后 50 个事件为 0.253/0.378 毫秒,对所有会话执行 fork 为 13.10 秒。Zstandard JSONL 使用 30.65 MB,对应指标为 28.21 秒、4.49/23.36 毫秒、10.58/80.90 毫秒和 14.48 秒。此前的标量 SQLite 布局使用 709.57 MB,对应指标为 10.64 秒、9.02/69.16 毫秒、0.189/0.293 毫秒和 19.30 秒。打包布局比此前布局小 89.4%,写入快 19.4%,完整读取 p50/p95 改善 56.2%/68.8%,并把 2,507,860 个物理事件行减少到 65,810 行。标量布局的最后 50 个事件读取与 list 微延迟更低,但打包提供方在这些路径上仍明显快于 JSONL,并改善主要的空间、写入、完整读取和 fork 成本。4 KiB 阈值是接受的平衡点,而不是严格支配所有指标的结论。 + +**把打包 payload 存在逻辑 `assistant/chunk` 类型下。** 不予采用,因为 payload 启发式判断会使畸形行产生歧义,并把物理解码耦合到未来逻辑 payload 字段。显式标签会明确失败。 + +**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 17 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。 + +**通过配置或实时注册表暴露压缩规则。** 不予采用,因为同一版本数据库必须能独立于运行时拓扑被读取。Codec 在源码层保持模块化,但持久规则集由 schema 版本固定。 + +**原地迁移旧 schema。** 预发布策略不采用此方案。改变 strict 列类型需要重建事件表,这会把第一次追加变成无界的历史改写,并暂时复制存储。使用新数据库可让启用行为明确、失败方式可预测。 + +**把 fork 历史存为父级引用。** 延期处理,因为它改变的是独立会话持久化语义,而不是物理行编码。Codex 使用引用历史,并避免对被引用或带指针的 rollout 做冷压缩;但该提供方首先需要明确父级保留、删除、修复、导出和跨后端语义。在会话服务拥有这些规则之前,复制仍是有界的本地选择。 + +**把打包实现保留为版本化同级包。** 不予采用,因为预发布仓库不承诺兼容此前的标量格式,而两个 SQLite 包名会重复配置、文档、测试和所有权。历史 benchmark 产物保留对比,无需暴露回滚提供方。 + +## 后果 + +标准 SQLite 提供方保留每一项逻辑持久化、回放、revision、崩溃恢复和模型可见行为。在节奏流验证中,高频批次使用的行数和测得的进程磁盘写入字节少于此前布局;空闲样本没有新增测得写入。打包率取决于持久批次边界,但除显式崩溃修复外,已经提交的行保持不可变。 + +代价是不迁移旧的预发布 SQLite schema,以及取决于时序的物理行数。SQLite 与 Zstandard 都是同步操作:每个连接以配置的 `busyTimeoutMs` 等待竞争锁,该等待期间会阻塞其 JavaScript 线程,大型行的编码与解码也在该线程上执行。冷打开会在 journal-mode 切换立即返回 `SQLITE_BUSY` 后让出执行,并在从打开时计算的重试截止点后不再发起新尝试;正在执行的同步调用可能更晚才完成。外部 SQL 工具必须使用提供方解码器,而不能假定每个物理 `events.type` 都是逻辑事件类型或每个 payload 列都是文本。 + +[JSONL 打包行决策](2026-07-26-packed-chunk-rows-by-default.md)、[有界持久化批处理](2026-08-08-bounded-session-persistence-write-batching.md)和原始[会话持久化决策](2026-06-14-session-persistence.md)继续保持 active:它们分别负责 JSONL 格式、写入调度以及后端无关的服务语义。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml new file mode 100644 index 0000000000..d84f437915 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md new file mode 100644 index 0000000000..33b7cd84b9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md new file mode 100644 index 0000000000..b130ab1e9c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md @@ -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` 中又发现更多,本次未触碰,需要单独的改动处理。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.i18n.yaml new file mode 100644 index 0000000000..15e78356f5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md +2026-08-18-rail-search-outside-click-self-dismissal.md: 9893b3a2456b9a592e1feb107d21404e043dee78 +2026-08-18-rail-search-outside-click-self-dismissal.zh.md: 91e343b8843dc02ca9c1be2b145e79beb84e17b7 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md new file mode 100644 index 0000000000..9893b3a245 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md @@ -0,0 +1,27 @@ +# Agent Note: Rail search keeps its expansion when the opening click reaches document + +Status: implemented + +English | [中文](2026-08-18-rail-search-outside-click-self-dismissal.zh.md) + +## Problem + +The collapsed sidebar's rail search button arms the rail gesture (`searchOnExpand`), expands the search affordance (`searchExpanded`), and requests sidebar expansion — designed to land the user in a focused search input once the column slides open. In a real browser the gesture never completed: the sidebar expanded but the search box stayed closed and unfocused. + +The initiating click destroys its own effect. React dispatches the rail button's handler mid-bubble; the state flip renders the wide header and mounts the WorkspaceBrowser's outside-click dismissal listener on `document` during that same dispatch. The click then keeps bubbling and reaches `document` with the now-unmounted rail button as its target — outside `searchRoot` — so the freshly mounted listener immediately collapses the search it was opening. The package test missed this because `fireEvent.click` on the button does not re-bubble through listeners mounted during dispatch the way a real browser event does. + +## Decision + +The outside-click dismissal listener does not mount while the rail gesture is in flight: its effect returns early while `searchOnExpand` is set, and `searchOnExpand` already ends exactly when the gesture settles (focus lands in the input after the column slide). After settle, outside clicks dismiss the search as before. A regression test replays the real-browser order — rail click, wide flip, then the same click arriving at `document` — and requires the search to stay expanded through it and to dismiss on the next genuine outside click. + +## Alternatives considered + +**Stop propagation on the rail button's click.** Suppressing bubbling at the initiator couples the rail button to a listener it cannot see, and every other expansion path — a future keyboard shortcut, another rail entry — would reintroduce the bug. The listener owns dismissal, so the listener carries the guard. + +**Defer listener attachment by a frame or timeout.** A raw delay encodes the symptom (the click arrives "too early") instead of the cause (a gesture is in flight). `searchOnExpand` is already the explicit in-flight state with the correct end point; a frame boundary is neither. + +**Dismiss on `pointerdown` instead of `click`.** The initiating gesture's `pointerdown` precedes the listener mount, so it cannot self-dismiss. Rejected because it changes dismissal semantics for every interaction — a drag or a press-and-slide-away would dismiss where a completed click today does not — to fix a problem scoped to one gesture. + +## Consequences + +The rail search gesture works end to end in the assembled application, pinned by an `apps/web` real-browser scenario: a real click travels through the collapsed rail, the wide flip, and the document-level bubble, and the search stays expanded with focus landing in the input. During the in-flight window (~300 ms column slide) an outside click does not dismiss the search; that window ends the moment focus lands. The package-level regression test additionally pins the guard's timing at the unit level. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md new file mode 100644 index 0000000000..91e343b884 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 轨道搜索在展开点击到达 document 时保持展开 + +Status: implemented + +[English](2026-08-18-rail-search-outside-click-self-dismissal.md) | 中文 + +## 问题 + +收起侧边栏的轨道搜索按钮会置位轨道手势标志(`searchOnExpand`)、展开搜索控件(`searchExpanded`)并请求侧边栏展开——设计意图是列滑开后让用户直接落在已聚焦的搜索输入框里。但在真实浏览器中这个手势从未完成:侧边栏展开了,搜索框却保持关闭且未聚焦。 + +发起手势的那次点击摧毁了它自己的效果。React 在冒泡中途派发轨道按钮的处理器;状态翻转渲染出宽态头部,并在同一次派发期间把 WorkspaceBrowser 的"点击外部收起搜索"监听器挂到 `document` 上。随后这次点击继续冒泡到达 `document`,其 target 是已卸载的轨道按钮——位于 `searchRoot` 之外——于是刚挂上的监听器立刻收起了它正要打开的搜索。包级测试没有抓到这个问题,因为 `fireEvent.click` 在按钮上触发时,不会像真实浏览器事件那样继续冒泡穿过派发期间新挂载的监听器。 + +## 决策 + +轨道手势进行期间不挂载"点击外部收起"监听器:其 effect 在 `searchOnExpand` 置位期间提前返回,而 `searchOnExpand` 本就精确终止于手势落定之时(列滑动结束、焦点落入输入框)。落定之后,外部点击照旧收起搜索。一个回归测试重放真实浏览器的顺序——轨道点击、宽态翻转、同一次点击到达 `document`——要求搜索在此过程中保持展开,并在下一次真正的外部点击时收起。 + +## 备选方案 + +**在轨道按钮的点击上阻止冒泡。** 在发起方抑制冒泡会让轨道按钮耦合到一个它看不见的监听器,而且其他每条展开路径——未来的键盘快捷键、另一个轨道入口——都会重新引入此缺陷。收起由监听器负责,守卫就应由监听器承载。 + +**将监听器挂载延迟一帧或一个定时器。** 裸延迟编码的是症状(点击来得"太早")而非成因(手势正在进行)。`searchOnExpand` 已经是带有正确终点的显式进行中状态;帧边界两者都不是。 + +**改在 `pointerdown` 上收起而非 `click`。** 发起手势的 `pointerdown` 先于监听器挂载,因而不会自我收起。被否决是因为它改变了所有交互的收起语义——拖拽或按下后滑走会触发收起,而如今完成的点击才会——只为修复一个局限于单个手势的问题。 + +## 影响 + +轨道搜索手势在组装后的应用中端到端可用,由 `apps/web` 的真实浏览器场景钉住:真实点击穿过收起轨道、宽态翻转与 document 级冒泡,搜索保持展开且焦点落入输入框。在手势进行窗口内(约 300 ms 列滑动)外部点击不会收起搜索;该窗口在焦点落定的瞬间结束。包级回归测试另外钉住了单元层面的守卫时序。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml index f797c08086..892a732be0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md -2026-08-18-request-image-payload-bound.md: df10df39d18c7da4660b566e8f0b6a5a60ff8dc1 -2026-08-18-request-image-payload-bound.zh.md: 070f2d194f1459f3f2728fdf9d5d2db2c3a24385 +2026-08-18-request-image-payload-bound.md: 0ec4594888db6157fb8cfd3e7bdb231b842d53c1 +2026-08-18-request-image-payload-bound.zh.md: 17b3fbb09be1aff0036c1cec389d8dd75966e010 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md index df10df39d1..0ec4594888 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md @@ -10,7 +10,7 @@ Every image in session history is base64-inlined into every model request by the ## Decision -The pi-ai provider profile carries `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, per route, changeable from cordis.yml and the `llm-pi-ai` settings section). At request conversion, `toPiContext` sums the base64 length of every image in history (derived from `ImageAttachmentRef.bytes` without reading data) and, while the sum exceeds the bound, replaces the oldest images with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Offload locations use message and nested block indexes rather than object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. `classifyPiAiError` classifies 413 and specific request-body-cap wording as `INVALID_REQUEST` (resending the same body cannot succeed). Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB request-image default therefore retains four such images and reserves the rest of a 32MiB request for system prompts, history, tools, and JSON. Deployments behind stricter gateways lower the value per route. +The pi-ai provider profile and direct DeepSeek adapter carry `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, changeable from cordis.yml and settings). The provider-neutral `offloadRequestImages` conversion sums the base64 length of every image in history from `ImageAttachmentRef.bytes` without reading data and, while the sum exceeds the bound, replaces the oldest image occurrences with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Occurrence-order replacement does not depend on object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. Both adapters classify 413 as `INVALID_REQUEST`; pi-ai also recognizes specific request-body-cap wording. Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB default therefore retains four such images and leaves headroom under the direct API's 30MiB request limit, while deployments behind stricter gateways lower the value per route. ## Offload is conversion, not history @@ -26,6 +26,7 @@ The placeholder is model-visible but not logged as a session event. It stays wit ## Related - [Per-side image dimension admission limit](2026-08-17-image-dimension-admission-limit.md) — the admission-layer companion fix; together they close the two observed session-poisoning failures (400 dimension, 413 body size). +- [Direct DeepSeek vision input](../feature/2026-08-19-direct-deepseek-vision-input.md) — applies this provider-neutral conversion to the official multimodal route. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md index 070f2d194f..17b3fbb09b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md @@ -10,7 +10,7 @@ pi-ai 适配器把会话历史中的每张图片 base64 内联进每一个模型 ## Decision -pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,按路由生效,可从 cordis.yml 与 `llm-pi-ai` settings 段修改)。请求转换时,`toPiContext` 由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。offload 位置用消息与嵌套块的索引表示,不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。`classifyPiAiError` 把 413 与明确的请求体上限措辞归类为 `INVALID_REQUEST`(原样重发不可能成功)。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 请求图片默认上限因此可保留四张这样的图片,并在 32MiB 请求内为系统提示词、历史、工具与 JSON 保留其余容量。网关更严格的部署按路由调低该值。 +pi-ai provider profile 与直接 DeepSeek 适配器都提供 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,可从 cordis.yml 与 settings 修改)。提供方无关的 `offloadRequestImages` 转换由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片出现位置开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。按出现顺序替换不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。两个适配器都把 413 归类为 `INVALID_REQUEST`;pi-ai 还会识别明确的请求体上限措辞。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 默认上限因此可保留四张这样的图片,并在直接 API 的 30MiB 请求上限下留出余量;网关更严格的部署则按路由调低该值。 ## offload 是转换而非历史 @@ -26,6 +26,7 @@ pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUE ## Related - [图片单边尺寸准入上限](2026-08-17-image-dimension-admission-limit.md),准入层的配套修复;两者合起来封住已观测到的两类会话毒化故障(400 尺寸、413 请求体)。 +- [直接 DeepSeek 视觉输入](../feature/2026-08-19-direct-deepseek-vision-input.md)把这项提供方无关转换应用于官方多模态路由。 ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml new file mode 100644 index 0000000000..711034b02c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md +2026-08-18-tool-row-file-open-failure.md: e36552395b992e688fad35b3163b92c9f6189e43 +2026-08-18-tool-row-file-open-failure.zh.md: 72b6026d9eb44c74f13c987996e652008f35a78e diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md new file mode 100644 index 0000000000..e36552395b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md @@ -0,0 +1,33 @@ +# Agent Note: Tool-row file-open failures stay visible + +Status: implemented + +English | [中文](2026-08-18-tool-row-file-open-failure.zh.md) + +## Problem + +Tool-row path clicks already call `host.openPath` through the chat view's injected `openFile`. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try. + +The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.md) still owns the link gesture and the Host handoff. This note owns only the refusal. + +## Decision + +The inject returns the `workspaces.openPath` promise. The chat view wraps that opener: a rejection opens an in-page Modal with the thrown text (or the unknown-open fallback when that text is empty) and a Retry that repeats the same path; Cancel, Escape, the close control, and a mask click dismiss it. A later settlement after dismiss is ignored, so a cancelled in-flight refusal cannot reopen the dialog. + +The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. The produced-files folder action opens `.`, and that refusal uses the folder title and unknown-open copy. + +The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path open failed: ` onto the wire error; the dialog does not unwrap that prefix. + +## Alternatives considered + +- **Per-row inline error.** The Host call is conversation-owned and several entries share one opener; a row-local banner would duplicate the same refusal next to every click target. +- **Toast without retry.** The product ask is the reason *and* a retry entry. The workspace folder-adoption dialog already pairs those two. +- **Chat-store remount persistence.** A failed open is transient view state. The chat store survives view remounts, so a leftover dialog would return after a tab switch that cannot usefully retry the original gesture. + +## Consequences + +A silent Host refusal is no longer a success from the reader's seat. Headless or remote deployments that click a path now see why the desktop handoff did not happen. The view holds one extra request-generation counter so dismiss and retry stay race-safe. + +## Testing + +Package specs cover inject rejection, the dialog copy (Error, non-Error, empty, workspace folder), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row, pins the assembled dialog in `file-open-failure.expected.md`, and asserts the English reason plus a second call with the same payload. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md new file mode 100644 index 0000000000..72b6026d9e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Tool-row file-open failures stay visible + +Status: implemented + +[English](2026-08-18-tool-row-file-open-failure.md) | 中文 + +## 问题 + +工具行路径点击已经通过聊天视图注入的 `openFile` 调用 `host.openPath`。inject 吞掉了每一次 Host 或操作系统拒绝,因此缺少桌面打开器、远程或非回环载体、或 Host 无法交接的路径,都会让该行看起来像成功。读者看不到原因,也无法再试一次。 + +[用系统应用打开文件的决策](../feature/2026-07-28-tool-call-file-open-in-os.md) 仍然拥有链接手势和 Host 交接。本 Agent Note 只拥有拒绝路径。 + +## 决策 + +inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用未知打开回退文案),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。 + +对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。产物文件的文件夹操作打开 `.`,该拒绝使用文件夹标题和未知打开回退文案。 + +Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。 + +## 考虑过的替代方案 + +- **按行内联错误。** Host 调用由会话拥有,多个入口共用一个打开器;行内横幅会在每个点击目标旁重复同一拒绝。 +- **没有重试的 toast。** 产品要求同时给出原因和重试入口。工作区文件夹采纳对话框已经把这两者配对。 +- **写入 chat store 并跨 remount 保留。** 打开失败是瞬时视图状态。chat store 会在视图 remount 后存活,于是残留对话框会在无法有效重试原手势的页签切换之后回来。 + +## 后果 + +从读者一侧看,静默的 Host 拒绝不再等同于成功。无头或远程部署点击路径时,能看到桌面交接为何没有发生。视图多持有一个请求世代计数器,使关闭与重试在竞态下仍然安全。 + +## 测试 + +包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,用 `file-open-failure.expected.md` 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index d2c9ec8ace..3f27b525e7 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 56fb4542bef2f6ad43f6b3a2c94772d54d198534 -2026-07-16-persistent-pty-sessions.zh.md: 87c7d7c5f41c91e1c57b33e876c13ee6b35f82cc +2026-07-16-persistent-pty-sessions.md: 3ca35cdd59f38570be478fddf4213335665556a5 +2026-07-16-persistent-pty-sessions.zh.md: ae31481560b446b894abd215ba4434f7a504a3ea diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 56fb4542be..3ca35cdd59 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -134,7 +134,7 @@ The package ships concise tool guidance explaining persistent state, owner isola - Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited. - Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol. - Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work. -- Windows/ConPTY support requires a backend with Windows-native process ownership and signaling semantics. +- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../architecture/2026-08-11-pwsh-persistent-pty.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 87c7d7c5f4..ae31481560 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -134,7 +134,7 @@ plugins: - 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。 - harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。 - 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。 -- Windows/ConPTY 支持需要具备 Windows 原生进程所有权与信号语义的后端。 +- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../architecture/2026-08-11-pwsh-persistent-pty.md)。 ## 备选方案 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index f4d510ce7e..c59b8b4837 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md -2026-07-21-cross-session-references.md: f49c75a9d4e3304b44d12a3df85430ffb494b8a9 -2026-07-21-cross-session-references.zh.md: 81a1970497bf175948a70bb1982f336f19d4d19e +2026-07-21-cross-session-references.md: 48a3241871f0b65270aaf74e42c9d02c45eaf027 +2026-07-21-cross-session-references.zh.md: ef723115f0de4312c0f4b1d215303b4a6a31be47 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index f49c75a9d4..48a3241871 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -6,33 +6,35 @@ English | [中文](2026-07-21-cross-session-references.zh.md) ## Problem -TUI users need to bring relevant work from another conversation into one new message without resuming, forking, or granting the source transcript authority over the current session. The harness already exposes exact session enumeration and raw event inspection, but every host independently parsing logs would duplicate compaction folding, filtering by cited source-event seqs, size limits, error behavior, and persistence. Encoding host markup directly into the agent message contract would also bind the core loop to one UI syntax. +Web users need to bring relevant work from another conversation into one new message without resuming, forking, or granting the source transcript authority over the current session. The harness already exposes exact session enumeration and raw event inspection, but every host independently parsing logs would duplicate compaction folding, provenance filtering, size limits, error behavior, and persistence. Encoding host markup directly into the agent message contract would also bind the core loop to one UI syntax. ## Decision -`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferenceResolver`. Hosts normalize their protocol into `SessionReferenceInput[]` and call `prepare()` before delivery. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log. +`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferenceResolver`. Its outer `agent/pre-step` listener parses canonical mentions in accepted direct user messages and calls `prepare()` without adding reference behavior to a host gateway. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log. -`dsh-session:` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)`; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text. +`dsh-session:` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. Web receives that URI inside the Host-produced `@[label](uri)` mention and keeps it behind an atomic session chip; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text. The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live-preferred corpus observation, folds it with the session package's canonical surface algorithm, and returns a detached header, capture seq, and current nodes. FTS is not a dependency: discovery matches id, cwd, or the latest folded title, while message bodies remain outside the candidate layer. Non-empty queries batch title observations across the visible corpus with bounded persisted-log concurrency and cancellation; a dedicated title index can replace that discovery path without changing reference identity or preparation. ## Snapshot and projection -Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `followup()` or `steer()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session. +Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partial context: any read, cancellation, validation, or budget error ends the turn before the accepted messages enter model-visible history. Cancellation races in-flight discovery and exact reads, so the listener settles promptly even when a persistence backend cannot interrupt its pending operation. A queued message captures each source when it reaches `agent/pre-step`; later source messages, compaction, deletion, or persistence replacement cannot change the context recorded in the target session. -Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compaction`. That marker is part of the compaction capability contract rather than a backend package name. When a source prompt already contains baked prefix context, projection reads only its model-hidden display content, so referencing that target later does not recursively propagate an earlier snapshot. Projection excludes shadowed pre-compaction nodes, tools and results, reasoning, injected context, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery. +Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compaction`. That marker is part of the compaction capability contract rather than a backend package name. Reference snapshots remain separate sourced `user/message` events, so projection excludes them as injected context and never recursively propagates an earlier snapshot. Projection also excludes shadowed pre-compaction nodes, tools and results, reasoning, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery. -One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately before the direct `user/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope. +One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately after the direct `user/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope. ## Message ownership -TUI owns the snapshot/direct-message transaction without extending the generic inbox record. While the agent is idle, it installs a one-shot outer `agent/pre-step` listener before `followup()`; an enter decision receives the snapshot as another message, while rejection or an earlier ordinary discard releases the listener and writes neither message. While the agent is running, TUI calls `inject(snapshot)` then `steer(prompt)`, placing both in the next-step inbox for the same later claim. A rejecting or failed pre-step leaves that claimed pair removed; messages inserted after the claim remain pending. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this generic delivery boundary. +The service's outer `agent/pre-step` listener calls downstream listeners first and processes only an `enter` decision. It parses each accepted direct user message, preserves that message's id while replacing canonical mentions with readable labels, and inserts the frozen snapshot immediately after that message. Queue edits and queue-to-steer relocation need no reference-specific state because the final claimed messages are the input to preparation. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this context ordering. -Reference preparation is not a new steering protocol and does not create a turn by itself. Idle delivery uses `followup()` and pre-step entry; running delivery uses the shared next-step inbox while retaining snapshot order. +Reference preparation is not a new delivery protocol and does not create a turn by itself. A preparation failure terminates the already accepted turn through the agent loop's existing plugin-failure path. ## Host adapters -TUI combines session candidates with the existing `@` file provider. Candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the readable direct content as the user message, and renders session-reference source metadata as a compact source list instead of exposing the complete JSON in the terminal. +The unified Web `@` source combines session candidates with Host-backed file discovery. Session candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the request's cancellation signal, and session id, cwd, and mention labels escape external control characters while the canonical URI retains the original id. + +Web exposes file and session discovery through generated Remote methods on their owning services, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. Ordinary `session.prompt` delivery carries that mention without a reference-specific API Proxy route. Replay associates the separate session-reference context with the direct message immediately before it and renders a compact source summary instead of exposing the snapshot JSON. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. @@ -45,16 +47,16 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b - **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only. - **Put mention syntax in agent delivery methods** — rejected because it would make the core protocol parse one host's presentation syntax and prevent typed non-text hosts from sharing the semantic layer. - **Implement references separately in each host** — rejected because projection, security warning, retention, and persistence would drift across hosts. -- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. A domain-specific admission wrapper and the existing next-step inbox preserve the required pairing without enlarging every direct prompt. -- **Bake the prefix host-side before `followup()`** — rejected because `agent/pre-step` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets TUI hide background bytes from the direct user bubble. +- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. The domain listener can prepare the final claimed message without enlarging every direct prompt. +- **Bake the prefix host-side before `followup()`** — rejected because `agent/pre-step` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets Web hide background bytes from the direct user bubble. - **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history. - **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity. -- **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state. +- **Reread the source after the model step enters** — rejected because target replay would depend on external mutable state instead of the logged snapshot. ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, terminal-control escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, prompt blocking, admission-time staging, send/steer placement, title isolation, missing capability, and compact TUI replay. One keyless terminal snapshot types a title-only substring against an opaque session id and pins the rendered candidate. Another keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains a sourced snapshot message followed by the readable current prompt, without either shadowed string. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, pre-step parsing and insertion, downstream rejection, Chat-projected following-recall association, title isolation, and the generated Remote discovery faces. A keyless Web snapshot pins the assembled reference selection path. ## Consequences -The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard TUI demo bundle mounts it explicitly and exposes its count and per-source byte limits in its config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant. +The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard CLI composition mounts it explicitly for Web and exposes its count and per-source byte limits in config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant. diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index 81a1970497..ef723115f0 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -6,33 +6,35 @@ Status: implemented ## 问题 -TUI 用户需要把另一场对话中的相关工作带入一条新消息,但不恢复、不 fork,也不让源 transcript(文本记录)对当前会话拥有权威性。harness 已经提供准确的会话枚举与原始事件检查,但若每个宿主都独立解析日志,就会重复实现压缩(compaction)折叠、按被引用来源事件 seq 过滤、大小限制、错误行为和持久化。把宿主标记直接编码进 agent(智能体)消息约定,还会让核心循环绑定某一种 UI 语法。 +Web 用户需要把另一场对话中的相关工作带入一条新消息,但不恢复、不 fork,也不让源 transcript(文本记录)对当前会话拥有权威性。harness 已经提供准确的会话枚举与原始事件检查,但若每个宿主都独立解析日志,就会重复实现压缩(compaction)折叠、来源过滤、大小限制、错误行为和持久化。把宿主标记直接编码进 agent(智能体)消息约定,还会让核心循环绑定某一种 UI 语法。 ## 决策 -`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferenceResolver` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,并在交付前调用 `prepare()`。该服务返回分离的可读内容和一份可选的、带标识且冻结的 `UserMessage` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。 +`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferenceResolver` 上的单一上下文消费服务。它的外层 `agent/pre-step` 监听器会解析已接受直接用户消息中的规范 mention,并调用 `prepare()`,宿主网关无需添加引用行为。该服务返回分离的可读内容和一份可选的、带标识且冻结的 `UserMessage` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。 -`dsh-session:` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。 +`dsh-session:` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。Web 接收由 Host 生成、包含该 URI 的 `@[label](uri)` 提及标记,并把它封装为原子 session chip;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。 该服务使用 `ctx.sessionQuery.readSurface(sessionId)`:它优先从实时会话加载一次语料观察结果,使用会话包的规范表层算法执行折叠,并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是依赖项:候选发现会匹配 id、cwd 或最新折叠后的标题,而消息主体不进入候选层。非空查询会对可见语料中的标题观察结果执行批处理,以有界并发读取持久化日志,并支持取消;专用标题索引可以替换这条发现路径,而无需改变引用标识或准备过程。 ## 快照与投影 -准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `followup()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。 +准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分上下文:任何读取、取消、校验或预算错误都会在已接受消息进入面向模型的历史之前结束该轮次。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,监听器也能及时结束等待。queued 消息到达 `agent/pre-step` 时会捕获每个源;此后源会话新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中记录的上下文。 -投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compaction` 导出的规范来源标记的检查点用户消息。该标记属于压缩能力约定的一部分,而非某个后端包名称。当源提示词已包含合并写入的前缀上下文时,投影只读取其模型不可见的显示内容,因此后续引用该目标不会递归传播先前的快照。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、注入的上下文、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 +投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compaction` 导出的规范来源标记的检查点用户消息。该标记属于压缩能力约定的一部分,而非某个后端包名称。引用快照始终是独立且带来源的 `user/message` 事件,因此投影会把它们作为注入上下文排除,绝不递归传播早先的快照。投影还会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 -系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message` 之前。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。 +系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message` 之后。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。 ## 消息所有权 -TUI 负责快照/直接消息事务,不扩展通用收件箱记录。agent 空闲时,它会在调用 `followup()` 前安装一次性的外层 `agent/pre-step` 监听器;enter 决策会把快照作为另一条消息接收,而 reject 或更早的普通丢弃会释放监听器,并且不写入任何消息。agent 运行时,TUI 会依次调用 `inject(snapshot)` 和 `steer(prompt)`,把两者放入 next-step inbox,等待后续同一次领取。pre-step reject 或失败会使这对已领取消息维持已移除状态;领取后插入的消息继续等待。这一通用交付边界由[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定。 +该服务的外层 `agent/pre-step` 监听器会先调用下游监听器,并且只处理 `enter` 决策。它会解析每条已接受的直接用户消息,在把规范 mention 替换为可读标签时保留消息 id,并把冻结快照插入到该消息紧后。最终领取的消息是准备过程的输入,因此队列编辑和从 queue 移动到 steer 不需要引用专用状态。[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定了这一上下文顺序。 -引用准备过程不是新的 steering 协议,本身也不会创建轮次。空闲交付使用 `followup()` 和 pre-step 进入决策;运行期间的交付使用共享 next-step inbox,并保持快照顺序。 +引用准备过程不是新的投递协议,本身也不会创建轮次。准备失败会通过 agent loop 的现有插件失败路径终止已经接受的轮次。 ## 宿主适配器 -TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把可读的直接内容渲染为用户消息,并把会话引用来源元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 +统一的 Web `@` source 把会话候选与 Host 支持的文件发现组合在一起。会话候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。查询遵循请求的取消信号;session id、cwd 和提及标签中的外部控制字符会被转义,但规范 URI 仍保留原始 id。 + +Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范 mention 支撑的原子 chip。普通 `session.prompt` 投递会携带该 mention,无需引用专用 API Proxy 路由。回放会把独立的 session-reference 上下文与紧邻其前的直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 @@ -45,16 +47,16 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询 - **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。 - **把提及标记语法放入 agent 投递方法**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。 - **在每个宿主中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。 -- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step inbox 可以保持所需配对,而无需扩大每条直接提示词。 -- **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/pre-step` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 TUI 从直接用户气泡中隐藏背景字节。 +- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域监听器可以准备最终领取的消息,无需扩大每条直接提示词。 +- **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/pre-step` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 Web 从直接用户气泡中隐藏背景字节。 - **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。 - **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。 -- **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。 +- **模型步骤进入后重新读取源会话**:不予采纳,因为目标回放会依赖可变的外部状态,而不是已记录的快照。 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、终端控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、提示词阻止、准入期间的暂存、send/steer 放置方式、标题隔离、能力缺失和压缩场景下的 TUI 回放。一个无密钥终端快照会在会话 id 不透明的情况下输入一个只与标题匹配的子串,并固定渲染出的候选项。另一个无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含一条带来源的快照消息,后面跟随可读的当前提示词,并且不包含任一被遮蔽的字符串。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时的取消、逐源独立字节保留、冻结消息所有权、pre-step 解析和插入、下游拒绝、Chat 投影的后继召回关联、标题隔离,以及生成的 Remote 发现接口。一个无密钥 Web 快照会固定组装后的引用选择路径。 ## 后果 -新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 TUI 演示组合包会显式挂载它,并在自身配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。 +新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 CLI 组合会为 Web 显式挂载它,并在配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 842f2c9cd3..73b81c1686 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 6a12380992c3205d6bb3f5701f093b3335fae2f9 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a8e94154e435a5359e7928d3b11eba7d1aee92f7 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 83293fbe9bd4e8a7b4422dbbe8495cf9f48bcdb5 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5c524da9cbed7b330c3823812c8a71aed7aa74b0 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 6a12380992..83293fbe9b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -8,7 +8,7 @@ English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments Before this change, the Web composer accepted only text: `InputBar` received a string draft, `ConversationController.send()` created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history. -This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. +This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../archived/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. @@ -124,7 +124,7 @@ Model catalog entries gain optional merge-extensible input modality declarations The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast. -The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. +Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each durable image reference including references nested inside tool results, and emit native image content only for models that declare image input. The direct route accepts configured image-capable catalog entries but does not advertise a vision model until its endpoint is ready; its Flash, Pro, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`. Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. @@ -152,7 +152,7 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc | `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. | | `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. | | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | -| `packages/llm/llm-deepseek` | Reject image content explicitly. | +| `packages/llm/llm-deepseek` | Resolve declared official vision input and reject images for text-only models. | | `packages/compaction/compaction-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. | | `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, shared batch admission, limits and routed-model preflight, persist-before-event ordering, session-authorized reads, and default profile composition. | | `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. | @@ -165,7 +165,7 @@ The attachment packages form the interface/implementation side of one capability ### Implementation -The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage. +The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI and direct DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index a8e94154e4..5c524da9cb 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -8,7 +8,7 @@ Status: implemented 在此变更之前,Web 输入区仅接受文本:`InputBar` 接收字符串草稿,`ConversationController.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。 -这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 +这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../archived/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 [Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 @@ -124,7 +124,7 @@ Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都 宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。直接路由接受已配置且支持图片的 catalog 配置项,但在视觉模型端点就绪前不会公布视觉模型;其 Flash、Pro、未声明图片能力的自定义模型和未列出原样传递 id 仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。任何适配器都不得将保留的图片展平或跳过;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 @@ -152,7 +152,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme | `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 | | `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 | | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | -| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | +| `packages/llm/llm-deepseek` | 解析已声明的官方视觉输入,并拒绝纯文本模型的图片。 | | `packages/compaction/compaction-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 | | `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、共享批量准入、限制和路由模型前置检查、先持久化再追加事件的顺序、会话授权读取,以及默认 profile 组合。 | | `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 | @@ -165,7 +165,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 实现 -已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。 +已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 与直接 DeepSeek 输入转换、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml new file mode 100644 index 0000000000..f045d1ac7c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +2026-07-27-web-file-and-session-references.md: 51447d0c22c2e5beb4eae03e7f955239525fc16d +2026-07-27-web-file-and-session-references.zh.md: f2f87aabaa818fe5d104b0ee1488658c792f59b9 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md new file mode 100644 index 0000000000..51447d0c22 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -0,0 +1,49 @@ +# Agent Note: Web file and session references + +Status: implemented + +English | [中文](2026-07-27-web-file-and-session-references.zh.md) + +## Problem + +The Web composer had a reusable slash/reference trigger pipeline, but its `@` source was inert subagent-label text. Web needed Host-backed workspace-path discovery and structured cross-session snapshots without scanning the Host filesystem in the browser or binding session identity to a display label. + +## Decision + +Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Remote discovery calls concurrently and deterministically orders files before sessions with locale-registered labels; non-selectable file and session section headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. The source suppresses its raw group title through loading and settled states because those section headings own the visible grouping. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other. + +The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the generated Remote namespaces and shared grammar. A file pick is an atomic composer reference with a file glyph and filename; its serialized form remains path-only prompt text. A directory stays editable path text with a folder glyph and retriggers completion below its trailing slash. + +A session pick is a structured composer reference. Its visible form uses a chat-bubble glyph and business-color session title without a capsule, while its clipboard and model form is the canonical `@[label](dsh-session:…)` mention produced by the Host. The complete `@label` display text remains in the transparent textarea, and the same-size backdrop colors that range and replaces its leading marker with the domain glyph. Native glyph metrics therefore determine width, wrapping, selection, and caret placement without truncation. The occurrence range retains reference identity for serialization; Backspace or Delete at its boundary removes it whole, and editing inside it turns the remaining characters into ordinary text. Ordinary `session.prompt` delivery carries the canonical mention unchanged. The session-reference service parses accepted direct user messages at `agent/pre-step`, captures every source, replaces the canonical mention with readable text while preserving the direct message id, and inserts the frozen snapshot immediately after that message. The recalled-context row uses the same chat glyph while other context keeps the document glyph. The API Proxy contains no reference-specific route, dependency, or error code. + +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Its session-store mirror persists each occurrence's canonical clipboard projection, so remounting without the occurrence table retains a parseable reference instead of a display-only label. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The chat renders the durable direct-message-then-recall order and associates exact session labels only from the immediately following sourced recall, which preserves multi-word titles and keeps consecutive references independent. It decorates recognized file and session mentions as icon-and-text references, treats unquoted `@path` tokens including extensionless basenames as files, leaves sentence punctuation outside the reference range, and keeps snapshot JSON behind the collapsed recall row. + +## Reference transaction + +```text +type @ → parallel file/session Remote calls → pick folder text or atomic file/session reference + → serialize draft → ordinary session.prompt enqueue + → agent/pre-step parses mentions → capture sources → readable prompt + context +``` + +File lookup is advisory and cancellable; selection itself performs no read. Session preparation is all-or-nothing for one accepted model step. A queued message captures each source when the message is claimed, so queue edits and queue-to-steer relocation use the same path without gateway coordination. + +## Alternatives considered + +**Implement file discovery and grammar inside the Web client.** Rejected because browser-side code cannot safely access the Host workspace, while duplicating grammar, ranking, bounds, and invalidation would drift from the Host provider. + +**Scan files through ordinary filesystem-tool RPCs.** Rejected because recursive fuzzy discovery is editor latency work, not a model-facing exact filesystem operation, and would couple the menu to tool policy and provider round trips. + +**Eagerly attach selected file contents.** Rejected because selection would spend context before relevance is known and bypass the logged, auditable `read` call/result sequence. + +**Represent sessions as plain `@label` text.** Rejected because labels are neither stable nor unique and cannot identify the source snapshot. Canonical Host-produced mentions preserve opaque session identity while keeping a readable display. + +**Clear the composer before prompt admission settles.** Rejected because a transport or admission failure would lose the only editable copy of the request and visually claim acceptance that never occurred. + +## Verification + +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, source-title suppression through pending and ready states, grouped headings that do not alter option indexes, file/directory continuation, structured file and session references, complete inline labels, domain glyphs, disabled-layer ownership, canonical draft persistence across remount, adjacent-reference and adjacent-text projection, extensionless file and sentence-punctuation rendering, codec round-trip, generated Remote type inference, direct-before-recall pre-step preparation, downstream rejection, and following-recall association for multi-word and consecutive labels. The keyless assembled Web snapshot renders the available reference sections without the raw source title, selects a file, then selects a session reference through the real client composition, and replays a multi-word session label in direct-before-recall order. + +## Consequences + +Web now uses the shared `@file` discovery seam and structured session-reference identity, while Host services remain the authority for filesystem and session access. File and session discovery are unary Remote contracts on the owning services, so generated client types replace handwritten RPC interfaces and browser bundles remain free of Node APIs. Candidate lookup failures remain quiet menu degradation. Reference preparation failures occur after prompt acceptance and end the agent turn. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md new file mode 100644 index 0000000000..f2f87aabaa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Web 文件与会话引用 + +Status: implemented + +[English](2026-07-27-web-file-and-session-references.md) | 中文 + +## 问题 + +Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 `@` source 只是不会产生实际作用的 subagent 标签文本。Web 需要由宿主提供工作区路径发现和结构化跨会话快照,同时避免在浏览器中扫描宿主文件系统或把会话身份绑定到显示标签。 + +## 决策 + +Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项 Remote 发现调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的标签;不可选择的文件与会话分组标题会区分两个连续的候选分组,且不会进入键盘选择索引。该 source 在加载和已结算状态下都会隐藏原始组标题,因为可见分组由这些分组标题拥有。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。 + +文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费生成的 Remote 命名空间与共享语法。选择文件会创建带文件图标与文件名的原子输入框引用,其序列化形式仍只是路径提示词文本。目录保持为带文件夹图标的可编辑路径文本,并在尾部斜杠后重新触发补全。 + +选择会话会创建一个结构化输入框引用。可见形式使用聊天气泡图标与业务色会话标题,不使用胶囊容器;剪贴板和模型形式则是宿主生成的规范 `@[label](dsh-session:…)` mention。完整的 `@label` 展示文本会保留在透明 textarea 中,同尺寸 backdrop 会为这段范围着色,并把开头的 marker 替换为对应领域图标。因此宽度、换行、选择区与光标位置都由原生字形度量决定,不会截断。occurrence 范围会保留引用身份以供序列化;在边界按 Backspace 或 Delete 会整段删除引用,在范围内部编辑则会把剩余字符转为普通文本。普通 `session.prompt` 投递会原样携带规范 mention。session-reference 服务会在 `agent/pre-step` 解析已接受的直接用户消息,捕获每个源,在保留直接消息 id 的同时把规范 mention 替换为可读文本,并把冻结快照插入到该消息紧后。召回上下文行使用同一个聊天图标,其他上下文保留文档图标。API Proxy 不包含引用专用路由、依赖或错误码。 + +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。它写入会话 store 的镜像会持久化每个 occurrence 的规范剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,仍会保留可解析的引用,而不是仅供显示的标签。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。聊天界面按照持久的直接消息后接召回行顺序渲染,并且只从紧随其后的带来源召回中关联准确的会话标签,因此既能保留多词标题,也能让连续引用彼此独立。它会把识别到的文件与会话 mention 装饰成图标加文字的引用,把包括无扩展名 basename 在内的未加引号 `@path` token 视为文件,将句末标点留在引用范围之外,并把快照 JSON 保留在默认收起的召回行中。 + +## 引用事务 + +```text +type @ → parallel file/session Remote calls → pick folder text or atomic file/session reference + → serialize draft → ordinary session.prompt enqueue + → agent/pre-step parses mentions → capture sources → readable prompt + context +``` + +文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备针对一个已接受的模型步骤保持全有或全无。queued 消息被领取时会捕获每个源,因此队列编辑和从 queue 移动到 steer 使用同一路径,无需网关协调。 + +## 备选方案 + +**在 Web 客户端内部实现文件发现与语法。** 不予采纳,因为浏览器侧代码无法安全访问宿主工作区,而且重复的语法、排序、边界和失效处理会与宿主提供方产生偏差。 + +**通过普通文件系统工具 RPC 扫描文件。** 不予采纳,因为递归模糊发现属于编辑器低延迟工作,而不是面向模型的精确文件系统操作;该方案还会把菜单与工具策略及提供方往返绑定。 + +**选择文件时立即附加其内容。** 不予采纳,因为该方案会在尚未确定相关性时消耗上下文,并绕过可从日志重建、可审计的 `read` 调用/结果序列。 + +**用普通 `@label` 文本表示会话。** 不予采纳,因为标签既不稳定也不唯一,无法标识源快照。宿主生成的规范提及标记既能保留不透明会话身份,也能保持显示内容易读。 + +**提示词准入结算前清空输入框。** 不予采纳,因为传输或准入失败会丢失请求唯一可编辑的副本,并在视觉上错误表示一个从未成功的接受操作。 + +## 验证 + +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、在 pending 与 ready 状态下隐藏 source 标题、不改变候选项索引的分组标题、文件/目录继续补全、结构化文件与会话引用、完整行内标签、领域图标、禁用状态下的层级归属、跨重新挂载的规范草稿持久化、相邻引用及相邻文本条件下的引用投影、无扩展名文件与句末标点渲染、codec 无损往返、生成的 Remote 类型推断、pre-step 中直接消息先于召回的准备、下游拒绝,以及多词与连续标签的后继召回关联。无密钥的装配 Web 快照会在不显示原始 source 标题的情况下渲染可用的引用分组,通过真实客户端组合依次选择文件和会话引用,并按直接消息先于召回的顺序回放多词会话标签。 + +## 后果 + +Web 现在使用共享的 `@file` 发现 seam 和结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。文件和会话发现都是所属服务上的一元 Remote 契约,因此生成的客户端类型会替代手写 RPC 接口,浏览器 bundle 中也不包含 Node API。候选查询失败仍会让菜单静默降级。引用准备失败发生在提示词已接受之后,并会结束 agent 轮次。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 398f01b94a..480efde65e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: cdf2b12e69b359b8ab00d4b107aa78a21691ac4e -2026-07-27-web-subagent-conversations.zh.md: 697bfeef86e2ef456aed6ee6ad8eae1173c96a6f +2026-07-27-web-subagent-conversations.md: d20e55429d04d4308ae26d001575e664058c7dea +2026-07-27-web-subagent-conversations.zh.md: 67c9b5224abea6c0710303ae56e315ba34c7ac09 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index cdf2b12e69..d20e55429d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -85,7 +85,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence **Put the adapter in the webserver.** Rejected because catalog and continuation are channel-independent client capabilities; the webserver only carries validated messages. -**Create a new UI package.** Rejected because `ui-subagent` already owns Web subagent references and is the coherent owner for catalog and addressed-child presentation. +**Put Host-backed file and session references in this package.** Rejected because catalog and addressed-child presentation depend on subagent lineage, while combined reference discovery is a separate Host capability consumed by [`ui-reference`](../../../../packages/client/ui-reference/README.md). **Auto-resume an absent parent.** Rejected because continuation requires the exact live direct parent. Child navigation must not mutate the parent lifecycle. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 697bfeef86..67c9b5224a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -85,7 +85,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 **将适配器放入 webserver。** 不予采纳,因为目录与继续执行是通道无关的客户端能力;webserver 只承载已校验的消息。 -**新建 UI 包。** 不予采纳,因为 `ui-subagent` 已经负责 Web subagent 引用,也是目录与已寻址 child 呈现的统一 owner。 +**把由 Host 支撑的文件与会话引用放进本包。** 不予采纳,因为目录与已寻址 child 呈现依赖 subagent 谱系,而组合引用发现是独立的 Host 功能,由 [`ui-reference`](../../../../packages/client/ui-reference/README.md) 消费。 **自动恢复缺失的 parent。** 不予采纳,因为继续执行要求确切的存活直接 parent。child 导航不得改变 parent 生命周期。 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 5e378099c9..393f7c17b9 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: d78cf4da2c1861a66a0cefb24dba785c7cb1279d -2026-07-28-tool-call-file-open-in-os.zh.md: c13f6e60c0c70a3036f50c678c7b348c91f874c3 +2026-07-28-tool-call-file-open-in-os.md: 08fc51cc3a5d2fb43b67dc158fd1ee789fafdb68 +2026-07-28-tool-call-file-open-in-os.zh.md: 59079533b502d234bafb0b53c123e5895e105833 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index d78cf4da2c..08fc51cc3a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -23,9 +23,9 @@ File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `fil ## Consequences -Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. +Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)). ## Risks -- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat row stays silent while the host returns an internal error. +- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat view shows that Host error and offers retry. - Relative paths without a session cwd are forwarded verbatim and may fail on the host. diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index c13f6e60c0..59079533b5 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -23,9 +23,9 @@ Status: implemented ## 后果 -点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。 +点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.md))。 ## 风险 -- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天行保持静默,宿主返回内部错误。 +- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天视图展示该 Host 错误并提供重试。 - 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index e6317060a4..3593c3ceb1 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-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 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index a8f500c7fb..9b47fcf49d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index ca35c38617..fcb1aac71b 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -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 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml index 3639c8da6b..b7f4e2e031 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md -2026-08-08-pi-ai-per-model-reasoning-declarations.md: b6264feeb724e3693078fa3fc3e3fc16ed01aacb -2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 1b30f7e0c42974c777a535e133a47caa217e2e5e +2026-08-08-pi-ai-per-model-reasoning-declarations.md: 0e8d5c3ca4017e89332f6b22e4eb0a06062918e6 +2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: c4c060b21c8b7ee5b11b5e98c8d7da0ba6c032fc diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md index b6264feeb7..0e8d5c3ca4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md @@ -14,7 +14,7 @@ Two adjacent gaps compounded this. pi-ai decides the reasoning *wire dialect* (` `PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, no Off is offered and an explicit Off request is refused (an effortless request still goes out bare, leaving the provider its default); declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. -`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). +`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. `thinkingFormat` is pinned to pi-ai's union through a `Record` drift gate, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). Which fields `compat` carries, which protocols take each of them, and how an unreadable key is refused are owned by [[2026-08-18-pi-ai-wire-compat-surface]]; the two-level resolution order above is what that surface generalizes. `modelOverrides` reshapes individual catalog models without replacing the served set: key = catalog model id, value = a `models` entry minus `id`, materialized by handing the override to the existing entry path so capacities, efforts, compat, and request-default semantics stay identical. Unlike Pi's own config layer, which ignores unknown ids, every override that lands nowhere is refused — beside a `models` list, on a hand-declared route, naming an unknown model, or smuggling an `id` in the value (the schema passes unknown keys through, and a smuggled id would quietly rename the model). diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md index 1b30f7e0c4..c4c060b21c 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md @@ -14,7 +14,7 @@ Status: implemented `PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,选择器不提供 Off,显式请求 Off 会被拒绝(不点名档位的请求仍会不带参数地发出,提供方保留自己的默认行为);声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 -`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。 +`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。`thinkingFormat` 经 `Record` 漂移门禁钉在 pi-ai 的联合类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。`compat` 承载哪些字段、每个字段由哪些协议接受、以及无法读取的键如何被拒绝,归 [[2026-08-18-pi-ai-wire-compat-surface]] 所有;上面这条两级解析顺序正是该面所推广的东西。 `modelOverrides` 就地重塑单个 catalog 模型而不替换所服务的集合:键 = catalog 模型 id,值 = 去掉 `id` 的 `models` 条目,物化时把覆盖交给既有的条目路径,因此容量、档位、compat 与请求默认值语义完全一致。与忽略未知 id 的 Pi 自有配置层不同,凡是落不到任何地方的覆盖都会被拒绝——与 `models` 列表并存、写在手工声明的路由上、点名未知模型,或在值里夹带 `id`(schema 会放行未知键,被夹带的 id 会悄悄把模型改名)。 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml index fcad94d796..1020c5a243 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md -2026-08-11-workspace-sidebar-order-and-folding.md: 3a88a61ca25550f1ad803a79e171ae2a7b8d4820 -2026-08-11-workspace-sidebar-order-and-folding.zh.md: e3e710bb9f38bcefcc9eeb50983c866ec5bc2619 +2026-08-11-workspace-sidebar-order-and-folding.md: d683d782454bb9fe1fad1fdc1d1a5fc3184a697b +2026-08-11-workspace-sidebar-order-and-folding.zh.md: 99e1991cfbb7b1540235ab0190defb31ad0ed6d7 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md index 3a88a61ca2..d683d78245 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md @@ -28,7 +28,7 @@ The combined view menu offers **Manual** and **Last updated** in grouped and fla Workspace hit testing uses the complete rendered group section, including visible Session rows. One insertion boundary is shared by the preceding group's lower half and the following group's upper half, and the indicator is an absolutely positioned line with a joined right-facing chevron that does not affect layout. A tree-body overlay draws the first boundary at the same negative offset outside the scrolling clip, so the leading chevron remains visible without moving the list. During a Workspace or Session drag, document-level `dragover` and `drop` handlers accept the native operation; if release occurs outside the Workspace list, `dragend` commits the last valid marker. -Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses a query that is empty after trimming but retains a non-empty query. Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances. +Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses a query that is empty after trimming but retains a non-empty query; while the rail search gesture is still in flight the outside-click listener stays unmounted ([rail-search self-dismissal](../bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md)). Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md index e3e710bb9f..99e1991cfb 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md @@ -28,7 +28,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条带有相连右向尖角且不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界,因此左侧尖角保持可见,列表位置也不会改变。Workspace 或 Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。 -搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。 +搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留;轨道搜索手势仍在进行期间,外部点击监听器保持未挂载([轨道搜索自我收起](../bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md))。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.i18n.yaml new file mode 100644 index 0000000000..42d81db81d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.md +2026-08-12-open-ready-web-ui.md: 075082aff6c0b811de3c6750637cb823fe83be61 +2026-08-12-open-ready-web-ui.zh.md: de73ab0c8574ff924229b54c2aedf41899fedee8 diff --git a/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.md b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.md new file mode 100644 index 0000000000..075082aff6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.md @@ -0,0 +1,35 @@ +# Agent Note: `dsh web` opens its ready page + +Status: implemented + +English | [中文](2026-08-12-open-ready-web-ui.zh.md) + +## Problem + +`dsh web` bound the HTTP server and printed its canonical local URL, but left the user to copy that URL into a browser even though the root README described the command as opening the Web UI. A browser handoff also cannot run at the server's bind callback alone: the API routes, browser plugin roster, and static fallback may still be mounting, so the first page request could observe an incomplete application that the process is about to reject. + +## Decision + +The Web app's command provider resolves `openBrowser: true` for an ordinary invocation and `false` for `--no-open`. The bundle passes that value into its `web-runtime` row; deployments may still replace the complete row config explicitly. The runtime samples inherited `SSH_CONNECTION` and `SSH_TTY` once during activation and suppresses browser handoff when either is non-empty, because the process then serves remote host loopback while the SSH client or editor owns the user's local forwarded address. + +The Web runtime treats URL printing and browser opening as separate actions at one readiness point. It waits for the complete Loader tree to settle and confirms that `webServer` is still live, then prints the configured URL line and, outside SSH, prints `dsh web: opening the default browser; pass --no-open to disable` immediately before handing the canonical loopback URL to the operating system's default browser. An SSH launch keeps the host URL line so the operator can identify the remote port, but cannot derive or open the forwarding owner's local address. A deployment that explicitly binds all interfaces still opens loopback locally while the printed LAN URL remains informational; the CLI rejects `--host 0.0.0.0`. `openBrowser` and `printUrl` can be disabled independently. + +The handoff uses the maintained `open` package for macOS, Windows, Linux, containers, and WSL. A short-lived Node helper invokes that package with the canonical scrubbed child environment, so Harness credentials and `DSH_*` state do not reach the operating-system launcher or a newly started browser. `BROWSER` is a launch-only command selector: app boot rejects it in a discovered `.env`, while only an inherited value can reach a compatible opener path that honors the variable. On Windows the helper waits for the short-lived PowerShell launcher to exit because `open` resolves when that process spawns, before it has handed the URL to the shell; other platforms stop after the opener accepts spawn. The runtime never waits for the browser to exit. The parent reads helper stderr so a failure writes one English diagnostic with the specific reason and manual URL to stderr without disposing the already-ready server; a later browser exit is outside the handoff result. + +Unit coverage pins command defaults, `--no-open`, SSH suppression, readiness ordering, teardown and failure suppression, helper outcomes, stderr reason propagation, the Windows launcher lifetime, the scrubbed helper environment, the inherited-only `BROWSER` rule, the pre-handoff opt-out status, and the reason-bearing non-fatal diagnostic. A real Loader composition binds an OS-assigned port, serves the actual static fallback, replaces only the operating-system handoff, and requests the handed-off URL immediately to prove it is already reachable. Assembled keyless snapshots run the built `dsh web` command locally, with a failing opener, with VS Code plus SSH markers, and from a project that declares `BROWSER`: the local case verifies that the handed-off page is the printed, reachable page containing the boot manifest while credential and Harness-state variables are absent from the opener; the failure case verifies the stderr reason and manual URL after readiness; the remote case verifies that the host URL remains visible without a browser launch; the file-layer command case fails before readiness or handoff. Repository browser and packaging tests pass `--no-open` because they own their browser or run unattended. + +## Alternatives considered + +**Open from the CLI launcher** — rejected because the launcher deliberately knows only profile selection and cannot derive the OS-assigned port or the app-owned Loader settlement point without reversing the app-owned command-line decision. + +**Open from `dsh-host-webserver` when its socket binds** — rejected because that package is a generic route carrier with no shell or frontend knowledge, and socket readiness precedes application readiness. + +**Infer whether to open from TTY, CI, editor, display, container, or WSL variables** — rejected because those signals do not establish a host/browser split and misclassify detached terminals and desktop launches. Non-empty `SSH_CONNECTION` or `SSH_TTY` is narrower evidence: it identifies a remote host whose loopback URL is not the forwarding owner's local URL. The default plus explicit `--no-open` remains stable for non-SSH launches. + +**Require Enter before opening the browser** — rejected for the local default because it turns ordinary server startup into a second stdin-owned interaction and excludes desktop or supervised launches with no usable terminal. `--no-open` remains the explicit opt-out for a caller that owns the browser or wants a server only. + +**Hand-roll platform commands** — rejected because URL opening has distinct macOS, Windows, Linux, container, and WSL behavior. The maintained dependency owns those platform branches while this package retains only readiness and failure semantics. + +## Consequences + +An ordinary local `dsh web` invocation announces the automatic handoff and its `--no-open` opt-out, then opens one ready page without making the generic HTTP carrier desktop-aware or exposing its ambient credentials to the desktop launcher. An SSH invocation prints the remote host URL but leaves opening the forwarded local address to the SSH client or editor. A discovered `.env` that sets `BROWSER` fails launch instead of selecting an executable; a platform opener that honors the variable can read it only when the operator exports it in the launching shell. Other unattended consumers must pass `--no-open`; a handoff failure writes its reason and manual URL to stderr while preserving the usable server. The Web app gains the locked `open` dependency, the shared subprocess environment scrubber, and the opener's transitive platform helpers; it does not own, wait for, or terminate the browser after the operating-system handoff succeeds. diff --git a/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.zh.md b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.zh.md new file mode 100644 index 0000000000..de73ab0c85 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-open-ready-web-ui.zh.md @@ -0,0 +1,35 @@ +# Agent Note: `dsh web` 打开已就绪页面 + +Status: implemented + +[English](2026-08-12-open-ready-web-ui.md) | 中文 + +## Problem + +`dsh web` 会绑定 HTTP 服务器并打印规范本地 URL,但仍要求用户把 URL 复制到浏览器,尽管根 README 已把该命令描述为会打开 Web UI。浏览器交接也不能只以服务器绑定回调为时机:API 路由、浏览器插件名录和静态回退可能仍在挂载,第一次页面请求可能看到一个尚未完整且即将被进程判定为启动失败的应用。 + +## Decision + +Web 应用的命令提供方为普通调用解析出 `openBrowser: true`,为 `--no-open` 解析出 `false`。组合包把该值传给自己的 `web-runtime` 行;部署仍可显式替换该行的完整配置。运行时在激活期间对继承的 `SSH_CONNECTION` 与 `SSH_TTY` 采样一次,只要其中一项非空就会跳过浏览器交接,因为此时进程提供的是远端宿主机 loopback,而用户的本地转发地址由 SSH 客户端或编辑器持有。 + +Web 运行时把 URL 打印与浏览器打开作为同一就绪点上的两个独立动作。它等待完整 Loader 配置树结算,并确认 `webServer` 仍在线,然后打印已配置的 URL 行;非 SSH 环境下还会在把规范 loopback URL 交给操作系统默认浏览器之前立即打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`。SSH 启动会保留宿主机 URL 行,以便操作者识别远端端口,但进程无法推导或打开转发持有方的本地地址。部署显式绑定所有网络接口时,本机仍打开 loopback,打印出的 LAN URL 只用于告知;CLI 会拒绝 `--host 0.0.0.0`。`openBrowser` 与 `printUrl` 可以分别关闭。 + +交接使用维护中的 `open` 包处理 macOS、Windows、Linux、容器和 WSL。一个短生命周期 Node helper 使用规范的脱敏子进程环境调用该包,因此 Harness 凭据和 `DSH_*` 状态不会进入操作系统启动器或新启动的浏览器。`BROWSER` 是只能来自启动环境的命令选择器:应用启动过程会拒绝被发现的 `.env` 中的该变量,只有继承值才能抵达会读取该变量的兼容 opener 路径。在 Windows 上,helper 会等待短生命周期 PowerShell launcher 退出,因为 `open` 会在该进程 spawn 时、尚未把 URL 交给 shell 之前返回;其他平台则在 opener 接受 spawn 后结束。运行时绝不等待浏览器退出。父进程会读取 helper stderr,因此失败时只向 stderr 写入一条包含具体原因和手动访问 URL 的英文诊断,不会 dispose 已就绪的服务器;浏览器之后退出不属于本次交接结果。 + +单元覆盖钉住命令默认值、`--no-open`、SSH 抑制、就绪顺序、资源释放与失败抑制、helper 结果、stderr 原因传播、Windows launcher 生命周期、helper 的脱敏环境、`BROWSER` 仅可继承的规则、交接前 opt-out 提示以及包含原因的非致命诊断。真实 Loader 组合会绑定由操作系统分配的端口、提供实际静态回退,只替换操作系统交接,并立即请求被交接的 URL,以证明页面此时已可访问。无密钥的整体快照会分别在本机环境、opener 失败环境、带 VS Code 与 SSH 标记的环境,以及声明了 `BROWSER` 的项目中运行构建后的 `dsh web` 命令:本机用例验证被交接的页面就是打印出的、已可访问且包含启动清单的页面,同时 opener 中不存在凭据与 Harness 状态变量;失败用例验证就绪后的 stderr 原因和手动 URL;远端用例验证宿主机 URL 仍可见,但不会启动浏览器;文件层命令用例则在就绪或交接前失败。仓库内浏览器与打包测试会传入 `--no-open`,因为它们自行持有浏览器或在无人值守环境运行。 + +## Alternatives considered + +**从 CLI 启动器打开** — 否决,因为启动器刻意只了解 profile 选择,无法取得操作系统分配的端口或应用自有的 Loader 结算点;让它了解这些事实会推翻应用自有命令行决策。 + +**在 `dsh-host-webserver` 绑定 socket 时打开** — 否决,因为该包是不了解 shell 与前端的通用路由载体,而且 socket 就绪早于应用就绪。 + +**根据 TTY、CI、编辑器、显示、容器或 WSL 环境变量推断是否打开** — 否决,因为这些信号不能证明宿主机与浏览器分离,并会误判分离终端和桌面启动。非空的 `SSH_CONNECTION` 或 `SSH_TTY` 是更窄的证据:它表明远端宿主机 loopback URL 并不是转发持有方的本地 URL。非 SSH 启动仍保持默认打开并提供显式 `--no-open`。 + +**打开浏览器前要求按下 Enter** — 不作为本机默认行为,因为它会把普通服务器启动变成由 stdin 持有的第二次交互,并排除没有可用终端的桌面启动或受监督启动。调用方自行持有浏览器或只需要服务器时,仍通过 `--no-open` 显式退出。 + +**手写各平台命令** — 否决,因为 URL 打开在 macOS、Windows、Linux、容器和 WSL 上各有不同。维护中的依赖持有这些平台分支,本包只保留就绪与失败语义。 + +## Consequences + +普通的本机 `dsh web` 调用会先公告自动交接及其 `--no-open` 退出方式,再打开一个已就绪的页面,同时不会让通用 HTTP 载体感知桌面环境,也不会向桌面启动器暴露环境凭据。SSH 调用会打印远端宿主机 URL,但由 SSH 客户端或编辑器负责打开转发后的本地地址。被发现的 `.env` 如果设置 `BROWSER`,启动就会失败,而不是选择一个可执行文件;会读取该变量的平台 opener 只有在操作者从启动 shell 中 export 时才能取得它。其他无人值守消费方必须传入 `--no-open`;交接失败时会向 stderr 写入原因与手动访问 URL,同时保留可用服务器。Web 应用新增锁定的 `open` 依赖、共享子进程环境脱敏器及 opener 的传递平台辅助包;操作系统交接成功后,本应用不持有、不等待也不终止浏览器。 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 940ca44aae..ac0970f3be 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-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 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index df1f0d9939..8788fba349 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 982b4409e0..6f25493015 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -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,仍然保持合法。 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml new file mode 100644 index 0000000000..9a9f1614fd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md +2026-08-18-pi-ai-wire-compat-surface.md: 3da2db1ebdf67bcfaf8c872491356b0ef7d0ca89 +2026-08-18-pi-ai-wire-compat-surface.zh.md: ff9870f5863fb96ee026dfab1b96b4e1f3e6e238 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md new file mode 100644 index 0000000000..3da2db1ebd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md @@ -0,0 +1,52 @@ +# Agent Note: pi-ai Wire-Compatibility Surface in llm-pi-ai + +Status: implemented + +English | [中文](2026-08-18-pi-ai-wire-compat-surface.zh.md) + +## Problem + +pi-ai shapes every request from the provider id and the baseURL — which role carries the system prompt, which field caps output, whether `store` and `stream_options` go out, whether tool definitions carry `strict`. For an endpoint its detection does not recognize, the answer is "this is OpenAI itself": `detectCompat` returns `supportsDeveloperRole: true`, `maxTokensField: "max_completion_tokens"`, `supportsStore: true`. A hand-declared route is by construction an endpoint pi-ai does not ship, so every such route received OpenAI's own request shape. + +The adapter offered two of pi-ai's thirty compat fields ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] scoped them to "the switches pi-ai's reasoning dispatch reads"), and `supportsDeveloperRole` fell inside that scope while being absent from it: its send site is `model.reasoning && compat.supportsDeveloperRole`. A hand-declared model declaring `reasoningEfforts` therefore sent its system prompt as `role: "developer"`, which most OpenAI-compatible gateways reject, and no configuration could say otherwise — the gateway could not be connected at all. + +Writing the field anyway was worse than unsupported. schemastery passes unknown keys through, and resolution read only two names, so `compat: {supportsDeveloperRole: false}` validated, persisted, and was then dropped: the operator saw an accepted write and an unchanged failure. `maxTokensField` carried the same defect over a wider blast radius, since it shapes every request rather than only a reasoning model's. + +## Decision + +One drift gate per pi-ai compat type — keyed `Record` — classifies every upstream field as `offer` or `withhold`. Thirty distinct fields, twenty offered. The line is what a private URL can imply: a deployment must be able to state what nothing can infer from an unrecognized endpoint, while a field pi-ai's installed catalog sets for a named vendor stays withheld, because a route reaching for `openRouterRouting` or `deferredToolsMode` is a catalog route that should be named as such and inherit the value. + +`PiAiCompatProfile` stays an explicit interface with per-field JSDoc — it is what a configuration surface renders and what `docs/config-catalog.md` pastes — and a type-level `AssertNever` over the symmetric difference proves it names exactly the offered set. The schemastery schema is declared `z`, and `exactOptionalPropertyTypes` is what makes that annotation load-bearing in both directions, so the four faces lock together: an upstream field added, a gate entry missing, an interface field forgotten, or a schema key omitted each fails compilation. Field *types* are derived from upstream rather than restated, and a second proof pins the profile assignable to the upstream compat types, so a widened value union cannot silently narrow what configuration accepts — the cast to `ModelCompat` at materialization would otherwise hide it. + +Protocol applicability is per field, and grouping follows the compat *type* rather than the protocol name: pi-ai gives `openai-responses`, `azure-openai-responses`, and `openai-codex-responses` one `OpenAIResponsesCompat`, so a switch settable on one is settable on all three. Keying by protocol name alone refused two shipped catalog routes the fields their own models declare. The protocol set is derived from `Model.compat`'s own conditional, so a release that gives a further protocol a compat type fails the gate list by name. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it. `chatTemplateKwargs` is offered, which is what makes the two `chat-template` thinking formats nameable; nothing cross-checks that pairing, because the format in force may come from the catalog entry or from pi-ai's detection, neither of which resolution can read. + +Three kinds of `compat` key are refused where they are written rather than dropped: one no protocol declares, one a gate withholds, and one written with no value. The check runs over every key before any protocol resolves, so a misspelling fails even on a route whose models never reach the protocol that would have taken it. It reads raw keys deliberately: a withheld or undeclared name is absent from the schema, so schemastery cannot have materialized it and a person wrote it. The valueless case is the one that has to fail rather than be ignored — schemastery passes a YAML bare key through as null, and carrying it forward writes null over the installed catalog's value, leaving pi-ai's `??` reaching for its baseURL detection with the catalog layer skipped entirely. Fields carrying a value are then filtered separately, because schemastery materializes an absent dict as `{}` and `chatTemplateKwargs` is present on every parsed profile whether or not anyone wrote one. + +## Where a refusal lands + +Every check runs in `resolveProfiles`, which no request path re-enters: the adapter memoizes by raw-snapshot identity and `apply` resolves once eagerly. A refusal therefore reaches `settings.mutate` as `settings-rejected` before persistence, a `cordis.yml` `config:` block as a failed plugin mount, and a stored section as a failed `settings.register` at startup. + +An external edit to the settings file is the one path that cannot report: the provider watcher calls `publish()`, which catches a failing section, logs `settings: keeping last good "%s"`, and leaves the namespace serving its previous value. That is the settings seam's behavior for every schema and validator failure, not something this surface introduces, and closing it belongs to that seam rather than here. What changes for compat is the failure model, not the reporting: a key that formerly stayed inert forever now stops the next start. + +## Alternatives considered + +**Add `supportsDeveloperRole` alone.** It fixes the reported gateway and leaves `maxTokensField` — which shapes every request, not only a reasoning model's — breaking a whole class of endpoints, with the next upstream addition free to lag silently again. + +**Offer every upstream field.** pi-ai's own custom-provider documentation converges on a far smaller set, its flagship example naming six, and the remainder are vendor-bound switches its catalog already sets. Exposing `zaiToolStream` or `vercelGatewayRouting` on a hand-declared route offers a knob whose correct use is to not be a hand-declared route. + +**Key `compat` by protocol** (`compat: {openai-completions: {…}}`). A hand-declared route has exactly one `api`, so the nesting states what the route already said, and it breaks every profile written against the flat shape for nothing. + +**Accept an opaque passthrough dict.** The schema is also the shape a configuration surface renders and the declaration `verify-config-catalog` cross-checks, both of which an unstructured dict defeats; it would also let a responses-only field land on a completions model, which per-field applicability exists to refuse. + +**Warn instead of refusing an unknown key.** That is the posture that hid this defect for the life of the surface: an accepted write and an unchanged failure teaches the operator that the switch does not work, not that the name is wrong. + +**Suggest a near spelling on an unknown key.** No repository utility computes edit distance, and adding a dependency or hand-rolling one under the per-file coverage gate is disproportionate for a diagnostic. Naming the offered fields answers the same question deterministically: the vocabulary check runs before any protocol resolves, so it names the whole offered set, while the per-protocol refusal narrows to what that protocol takes. + +## Consequences + +- An OpenAI-compatible gateway that rejects the `developer` role, `max_completion_tokens`, `store`, `stream_options`, or `strict` is now configuration rather than an unreachable provider, and the same holds for an Anthropic-compatible gateway rejecting `temperature` or tool `cache_control`. +- A pi-ai upgrade that adds a compat field fails the build until someone classifies it, which is how `chatTemplateKwargs` and the `chat-template` formats stopped being a standing exception. +- Unknown compat keys join every other configuration error's failure model. The improvement over the previous silent drop is bounded by the settings seam: an external file edit still keeps its last good value and warns, so the operator's signal is a restart rather than the write. +- **Deferred, not closed:** a route that repoints `api` and configures no compat at all keeps the installed entry's `compat` through the model literal's `...base` spread, in the *other* protocol's shape. Fields several compat types share (`supportsLongCacheRetention`, `sendSessionAffinityHeaders`) therefore cross protocols. It predates this surface — the early return it rides existed before — and is left for its own change. +- **Deferred, not closed:** `publish()` reports a rejected stored section only through `ctx.logger.warn`, with no user-visible channel. It affects every settings namespace and is owned by `dsh-settings`. +- [[2026-08-08-pi-ai-per-model-reasoning-declarations]] is partially superseded: its compat-scope statements are restated here, while its `reasoningEfforts` shape, the alternatives that shape beat, and `modelOverrides` remain the current authority. diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md new file mode 100644 index 0000000000..ff9870f586 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md @@ -0,0 +1,52 @@ +# Agent Note: pi-ai Wire-Compatibility Surface in llm-pi-ai + +Status: implemented + +[English](2026-08-18-pi-ai-wire-compat-surface.md) | 中文 + +## Problem + +pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提示词由哪个角色承载、输出上限写在哪个字段、是否发出 `store` 与 `stream_options`、工具定义是否携带 `strict`。对于其检测无法识别的端点,答案是「这就是 OpenAI 本身」:`detectCompat` 返回 `supportsDeveloperRole: true`、`maxTokensField: "max_completion_tokens"`、`supportsStore: true`。而手工声明的路由按其构造就是 pi-ai 未随附的端点,于是每一条这样的路由都收到了 OpenAI 自己的请求形状。 + +适配器只开放了 pi-ai 三十个 compat 字段中的两个([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 把它们限定为「pi-ai 推理分派读取的那些开关」),而 `supportsDeveloperRole` 恰恰落在该作用域之内却不在其中:它的发送点是 `model.reasoning && compat.supportsDeveloperRole`。因此一个声明了 `reasoningEfforts` 的手工声明模型会把系统提示词以 `role: "developer"` 发出——多数 OpenAI 兼容网关会拒绝该角色——而没有任何配置能够更正,该网关根本接不进来。 + +硬写这个字段比不支持更糟。schemastery 会放行未知键,而解析只读取两个名字,于是 `compat: {supportsDeveloperRole: false}` 通过校验、落盘,随后被丢弃:运维看到的是一次被接受的写入和一个毫无变化的故障。`maxTokensField` 带着同一缺陷、却有更大的波及面,因为它塑造每一个请求,而不只是推理模型的请求。 + +## Decision + +每个 pi-ai compat 类型一张漂移门禁——以 `Record` 为键——把每一个上游字段分类为 `offer` 或 `withhold`。去重后三十个字段,开放二十个。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。 + +`PiAiCompatProfile` 保持为带逐字段 JSDoc 的显式 interface——它是配置界面所渲染、也是 `docs/config-catalog.md` 所粘贴的东西——并由一个作用在对称差上的类型级 `AssertNever` 证明它恰好命名了开放集。schemastery schema 声明为 `z`,而使这条标注在两个方向上都真正吃劲的是 `exactOptionalPropertyTypes`,于是四个面互锁:上游新增字段、门禁漏一条、interface 忘记一个字段、schema 少一个键,都会在编译期失败。字段的**类型**派生自上游而非重述,另有一条证明把 profile 钉为可赋值给上游 compat 类型,因此被拓宽的值并集不会悄悄收窄配置所接受的范围——否则物化处对 `ModelCompat` 的强转会把它洗掉。 + +协议适用性逐字段判断,且归组依据是 compat **类型**而非协议名:pi-ai 让 `openai-responses`、`azure-openai-responses` 与 `openai-codex-responses` 共用同一个 `OpenAIResponsesCompat`,因此可设在其中之一的开关,三者皆可设。仅按协议名归组曾使两条随附的 catalog 路由拿不到其自身模型所声明的字段。协议集派生自 `Model.compat` 自身的条件类型,因此某个版本若给别的协议加上 compat 类型,门禁列表会以点名的方式失败。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。`chatTemplateKwargs` 予以开放,这正是两个 `chat-template` 思考格式得以命名的前提;两者的配对不做交叉校验,因为实际生效的格式可能来自 catalog 条目或 pi-ai 的检测,而解析读不到那两层。 + +三类 `compat` 键在其被写下之处遭到拒绝而非丢弃:没有任何协议声明的键、被门禁扣留的键,以及完全没有写值的键。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。无值那一类是必须失败而不能忽略的:schemastery 会把 YAML 裸键放行为 null,照单收下就会用 null 写覆盖已安装 catalog 的值,随后 pi-ai 的 `??` 转而去够它的 baseURL 检测,catalog 这一层被整个跳过。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,`chatTemplateKwargs` 都出现在每一个解析过的 profile 上。 + +## Where a refusal lands + +所有检查都在 `resolveProfiles` 中运行,而请求路径不会重新进入它:适配器按原始快照的标识 memoize,且 `apply` 会主动预先解析一次。因此一次拒绝会以 `settings-rejected` 的形式在落盘之前抵达 `settings.mutate`,以插件挂载失败的形式抵达 `cordis.yml` 的 `config:` 块,以 `settings.register` 启动失败的形式抵达已存的 section。 + +对 settings 文件的外部编辑是唯一无法报告的路径:提供方监听器调用 `publish()`,它捕获失败的 section、记录 `settings: keeping last good "%s"`,并让该 namespace 继续服务其先前的值。这是 settings seam 对每一种 schema 与校验器失败的既有行为,并非本次开放引入,弥合它属于那个 seam 而不属于此处。对 compat 而言改变的是失败模型而非报告方式:一个从前永远静默无效的键,如今会拦下下一次启动。 + +## Alternatives considered + +**只补 `supportsDeveloperRole`。** 它修好了报告中的那个网关,却放任 `maxTokensField`——它塑造每一个请求,而不只是推理模型的请求——继续拖垮一整类端点,而且下一个上游新增字段依然可以静默落后。 + +**开放全部上游字段。** pi-ai 自己的 custom-provider 文档收敛到一个小得多的集合,其旗舰示例只点名六个,其余都是其 catalog 已经设定好的厂商绑定开关。在手工声明路由上暴露 `zaiToolStream` 或 `vercelGatewayRouting`,等于提供一个「正确用法是别做手工声明路由」的旋钮。 + +**把 `compat` 按协议分层**(`compat: {openai-completions: {…}}`)。手工声明路由恰好只有一个 `api`,因此这层嵌套只是复述路由已经说过的事,还白白破坏了所有按扁平形状写下的 profile。 + +**接受一个不透明的透传 dict。** 该 schema 同时是配置界面渲染的形状、也是 `verify-config-catalog` 交叉校验的声明,无结构的 dict 会同时击溃两者;它还会让 responses 独有的字段落到 completions 模型上,而逐字段适用性正是为拒绝这种情况而存在。 + +**未知键只告警不拒绝。** 这恰恰是让本缺陷伴随该面存活至今的姿态:一次被接受的写入加一个毫无变化的故障,教给运维的是「这个开关没用」,而不是「这个名字写错了」。 + +**为未知键给出近似拼写建议。** 仓库中没有计算编辑距离的工具,在逐文件覆盖率门禁之下为一条诊断引入依赖或手搓一个都不成比例。点名开放字段能确定地回答同一个问题:词汇检查跑在任何协议解析之前,因此它列出整个开放集,而按协议的拒绝则收窄到该协议实际接受的字段。 + +## Consequences + +- 拒绝 `developer` 角色、`max_completion_tokens`、`store`、`stream_options` 或 `strict` 的 OpenAI 兼容网关,如今属于配置问题而非无法接入的提供方;拒绝 `temperature` 或工具 `cache_control` 的 Anthropic 兼容网关同理。 +- pi-ai 升级新增 compat 字段会使构建失败,直到有人为它做出分类——`chatTemplateKwargs` 与那两个 `chat-template` 格式正是因此不再是一项长期例外。 +- 未知 compat 键并入了其余所有配置错误的失败模型。相对此前静默丢弃的改善程度受 settings seam 限制:外部文件编辑仍会保留其上一个有效值并告警,因此运维拿到的信号是一次重启,而不是那次写入。 +- **搁置而非解决:** 改指 `api` 且完全未配置 compat 的路由,会经模型字面量的 `...base` 展开保留已安装条目的 `compat`,且形状属于**另一个**协议。多个 compat 类型共有的字段(`supportsLongCacheRetention`、`sendSessionAffinityHeaders`)因而会跨协议串味。它早于本面存在——其所依附的提前返回本就在那里——留给独立的一次改动处理。 +- **搁置而非解决:** `publish()` 对被拒绝的已存 section 只通过 `ctx.logger.warn` 报告,没有面向用户的通道。它影响每一个 settings namespace,归属 `dsh-settings`。 +- [[2026-08-08-pi-ai-per-model-reasoning-declarations]] 被部分取代:其 compat 作用域的陈述在此重述,而其 `reasoningEfforts` 形状、该形状所击败的备选方案以及 `modelOverrides` 仍是当前权威。 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml new file mode 100644 index 0000000000..df56f6bc20 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-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 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md new file mode 100644 index 0000000000..50d8e918f2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md @@ -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: ; stage: ; category: ; HTTP status: ; exit code: ; 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. diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md new file mode 100644 index 0000000000..7dc5a73637 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md @@ -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: ; stage: ; category: ; HTTP status: ; exit code: ; 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 分类器、身份验证或配置分类体系、进度流或人工交互路径。 diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml new file mode 100644 index 0000000000..0c4a921c34 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md +2026-08-18-web-home-path-tilde.md: b148833bab09eadce4c9c1a362dd99d04eba5977 +2026-08-18-web-home-path-tilde.zh.md: 9d15cd6dca1128927389d5731dff6bf831cffe76 diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md new file mode 100644 index 0000000000..b148833bab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md @@ -0,0 +1,37 @@ +# Agent Note: Web UI abbreviates POSIX home paths as `~` + +Status: implemented + +English | [中文](2026-08-18-web-home-path-tilde.zh.md) + +## Problem + +Workspace hover cards and Tool call summaries showed full POSIX home paths. Those strings are long, repeat the same prefix on every row, and make the sidebar and transcript harder to scan. Windows paths must stay verbatim because `~` is not a Windows filesystem convention. + +## Decision + +`host.describe` reports the host account `home` as a required field. Client and Host ship together, so the field is required rather than optional. ApiProxy fills it from `homedir()` at describe time. + +`abbreviateHomePath` in `dsh-client-runtime` is the display-only helper. It returns `~` or `~/…` when the path is the POSIX home or a descendant, and leaves the path unchanged when `home` is missing, empty, or `/`, when either value is a Windows drive or UNC path, or when the match is only a prefix (`/Users/u` does not claim `/Users/u2`). Tool summaries run workspace-relative shortening first, then this helper, so a path inside the session cwd stays short. `filePath`, Host open, and Workspace hover copy keep the authored filesystem path. + +`ui-tool` and `ui-workspace` inject `connection.hostDescription` at their own slot registrations. ChatView does not grow a Host-description hook. The field is required on `ConnectionHandle`; test fakes supply a source whose snapshot may be undefined before connect. + +The fixture Host home is `/home/fixture`. A second fixture Workspace at `/home/fixture/Documents/project` lets assembled replay hover `~/Documents/project` without moving the existing `/tmp/fixture` account. TerminalBlock's own prompt-label collapse is unchanged. + +## Alternatives considered + +**Guess `/Users` or `/home` without the real home.** Rejected because a shared prefix is not an account home, and `/Users/shared` or `/home/src` would abbreviate incorrectly. + +**Abbreviate Windows `%USERPROFILE%` as `~` as well.** Rejected because the acceptance rule keeps Windows paths verbatim, and `~` is not how Explorer or `cmd` spell those paths. + +**Put the helper in `dsh-home-paths`.** Rejected because that package expands configuration tildes on Node; this helper is a browser display rewrite and must not pull Node `os` into client bundles. + +**Thread `home` from ChatView owner props.** Rejected because it enlarges the conversation inject face and every ChatView test harness for a display fact only Tool and Workspace cards consume. + +## Consequences + +POSIX home-rooted Workspace hover paths and leftover Tool path summaries display as `~`. Copy and open still use the full path. Windows drive and UNC paths never become `~`. A Host that reports `/` as home does not turn the whole filesystem into `~`. Before the first describe, or while reconnecting, the source snapshot is undefined and paths stay unabbreviated. + +## Testing + +Package tests cover `abbreviateHomePath`, `toolRowModel` / `readCardModel` home abbreviation, Workspace hover display versus copy, and `host.describe` schema plus live `homedir()`. Assembled replay `apps/web/tests/home-path-tilde.snapshot.ts` hovers the fixture home-descendant Workspace. Product-GUI PRs still record a real-browser GIF of the hover card. diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md new file mode 100644 index 0000000000..9d15cd6dca --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Web UI abbreviates POSIX home paths as `~` + +Status: implemented + +[English](2026-08-18-web-home-path-tilde.md) | 中文 + +## Problem + +Workspace 悬停卡片和 Tool 调用摘要会显示完整的 POSIX 家目录路径。这些字符串很长,每行重复同一前缀,侧边栏和对话记录更难扫读。Windows 路径必须保持原样,因为 `~` 不是 Windows 文件系统约定。 + +## Decision + +`host.describe` 把宿主账户的 `home` 作为必填字段上报。Client 与 Host 一同发布,因此该字段是必填而不是可选。ApiProxy 在 describe 时用 `homedir()` 填入。 + +`dsh-client-runtime` 中的 `abbreviateHomePath` 是仅用于展示的辅助函数。当路径是 POSIX 家目录或其后代时返回 `~` 或 `~/…`;`home` 缺失、为空或为 `/`,任一侧是 Windows 盘符或 UNC 路径,或只是前缀命中(`/Users/u` 不能收走 `/Users/u2`)时,路径保持不变。Tool 摘要先做工作区相对缩短,再调用该辅助函数,因此会话 cwd 内的路径仍然更短。`filePath`、Host 打开以及 Workspace 悬停复制仍使用作者给出的文件系统路径。 + +`ui-tool` 与 `ui-workspace` 在各自的 slot 注册上注入 `connection.hostDescription`。ChatView 不增加 Host 描述钩子。该字段在 `ConnectionHandle` 上是必填的;测试假对象提供一个来源,其快照在连接完成前可以为 undefined。 + +fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位于 `/home/fixture/Documents/project`,组装回放可以悬停出 `~/Documents/project`,而不必移动现有的 `/tmp/fixture` 账户。TerminalBlock 自有的提示符标签折叠保持不变。 + +## Alternatives considered + +**在没有真实 home 的情况下猜测 `/Users` 或 `/home`。** 否决,因为共享前缀不是账户家目录,`/Users/shared` 或 `/home/src` 会被错误缩写。 + +**同样把 Windows `%USERPROFILE%` 缩写成 `~`。** 否决,因为验收规则要求 Windows 路径保持原样,而且 Explorer 与 `cmd` 并不这样拼写这些路径。 + +**把辅助函数放进 `dsh-home-paths`。** 否决,因为该包在 Node 上展开配置里的波浪号;本辅助函数是浏览器展示改写,不能把 Node `os` 拉进 client 包。 + +**从 ChatView owner props 向下传递 `home`。** 否决,因为它会扩大 conversation 注入面和每一份 ChatView 测试夹具,而只有 Tool 与 Workspace 卡片消费这个展示事实。 + +## Consequences + +POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。首次 describe 之前或重连期间,来源快照为 undefined,路径保持未缩写。 + +## Testing + +包测试覆盖 `abbreviateHomePath`、`toolRowModel`/`readCardModel` 的家目录缩写、Workspace 悬停展示与复制,以及 `host.describe` schema 与实时 `homedir()`。组装回放 `apps/web/tests/home-path-tilde.snapshot.ts` 悬停 fixture 中位于家目录下的 Workspace。面向产品 GUI 的 PR 仍需录制悬停卡片的真实浏览器 GIF。 diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml new file mode 100644 index 0000000000..d46a50b6d3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md +2026-08-19-direct-deepseek-vision-input.md: 5c2f2ea3ff87b58906ce76caf9f611e07f3e2d04 +2026-08-19-direct-deepseek-vision-input.zh.md: 3aea6b6a4f27f7116c1cb935b1719dfbcf7ed8fc diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md new file mode 100644 index 0000000000..5c2f2ea3ff --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md @@ -0,0 +1,34 @@ +# Agent Note: Direct DeepSeek vision input + +Status: implemented + +English | [中文](2026-08-19-direct-deepseek-vision-input.zh.md) + +## Problem + +DeepSeek vision deployments use the chat-completions image protocol, but the direct `deepseek-official` adapter declares every catalog and pass-through model text-only and rejects every `ImageBlock`. The durable attachment path therefore works only through configurable pi-ai routes, and a deployment cannot pass user uploads or image-bearing tool results through the direct provider. + +## Decision + +The direct adapter lets a configured model opt in with `inputModalities: [text, image]`; validation rejects empty, unknown, or duplicate modalities. Flash, Pro, unlisted ids, and configured models that omit `inputModalities` remain explicitly text-only. The shipped catalog does not advertise `deepseek-v4-flash-vision-exp` until its model endpoint is ready, so the model selector cannot offer an unavailable route; deployment and snapshot catalogs can enable their exact vision model independently. + +The adapter resolves `ctx.attachments` per image request, reads each retained durable reference with the request signal, and serializes verified bytes as ordered OpenAI-compatible `image_url` data URLs. Text-only user messages retain string content. Tool results retain string-only `tool` messages; image-only results use `(see attached image)`, and consecutive retained tool-result images follow in one `user` message beginning `Attached image(s) from tool result:`. System and assistant history images fail with `UNSUPPORTED_CONTENT` before attachment or network I/O. + +The direct adapter and pi-ai conversion share the deterministic [request-level image payload bound](../bug-fix/2026-08-18-request-image-payload-bound.md). Both default to 20 MiB of accumulated base64 payload, replace oldest image occurrences with the same fixed placeholder, and never read omitted attachments. Direct HTTP 413 responses are `INVALID_REQUEST`; attachment failures retain their stable attachment code rather than becoming `TRANSPORT`. + +Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist only while preparing one provider request, so no session event, persistence format, API schema, or SDK projection changes. The route accepts PNG, JPEG, WebP, and GIF already admitted by the attachment service. External image URLs, the Files API, and image output remain unsupported. + +## Alternatives considered + +- **Use only the pi-ai DeepSeek provider.** Its generic multimodal path proves the content conversion, but it does not make the direct official route truthful or usable with the official model id. +- **Declare the whole provider image-capable.** This would let Flash, Pro, and unknown pass-through ids accept durable images that their exact wire model cannot promise to consume. Capability remains exact-model metadata. +- **Send images inside `tool` message content.** The documented compatible form keeps tool content a string. A following user message avoids relying on an undocumented multimodal tool-role form while preserving call-result order. +- **Add external URLs or Files uploads.** Both require new canonical input, authorization, lifetime, cleanup, and replay decisions. Transient base64 uses the existing durable attachment contract without expanding those concerns. + +## Verification + +Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. + +## Consequences + +Configured DeepSeek vision routes can consume durable user and tool-result images without changing session durability or response streaming. Repeated history still expands request bodies, but deterministic oldest-first offload bounds the dominant payload and leaves headroom below the official 30 MiB request-body limit. Image token pricing remains provider-owned because the official image token formula is not available. diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md new file mode 100644 index 0000000000..3aea6b6a4f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 直接 DeepSeek 视觉输入 + +Status: implemented + +[English](2026-08-19-direct-deepseek-vision-input.md) | 中文 + +## Problem + +DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek-official` 适配器把所有 catalog 与原样传递模型都声明为仅文本,并拒绝每一个 `ImageBlock`。因此,持久附件路径只能经可配置 pi-ai 路由工作,部署方无法通过直接提供方传递用户上传或包含图片的工具结果。 + +## Decision + +直接适配器允许已配置模型通过 `inputModalities: [text, image]` 选择加入;校验会拒绝空列表、未知模态或重复模态。Flash、Pro、未列出 id,以及省略 `inputModalities` 的已配置模型仍明确仅支持文本。在模型端点就绪前,随附目录不会公布 `deepseek-v4-flash-vision-exp`,因此模型选择器不会提供不可用路由;部署与 snapshot 目录可以独立启用其确切视觉模型。 + +适配器会对每个图片请求解析 `ctx.attachments`,用请求 signal 读取每个保留的持久引用,并将校验后的字节按顺序序列化为 OpenAI 兼容的 `image_url` data URL。纯文本 user 消息保留字符串内容。工具结果保留仅字符串的 `tool` 消息;仅含图片的结果使用 `(see attached image)`,连续工具结果中保留的图片随后合并进一条以 `Attached image(s) from tool result:` 开头的 `user` 消息。System 与 assistant 历史图片会在附件或网络 I/O 前以 `UNSUPPORTED_CONTENT` 失败。 + +直接适配器与 pi-ai 转换共享确定性的[请求级图片载荷上限](../bug-fix/2026-08-18-request-image-payload-bound.md)。两者都以 20 MiB 累计 base64 payload 为默认值,用相同固定占位文本替换最旧的图片出现位置,并且绝不读取被省略的附件。直接 HTTP 413 响应归类为 `INVALID_REQUEST`;附件失败会保留其稳定附件 code,不会变成 `TRANSPORT`。 + +规范消息继续只存储 `ImageAttachmentRef`。Data URL 只在准备单次提供方请求时存在,因此无需修改会话事件、持久化格式、API schema 或 SDK 投影。路由接受已经由附件服务准入的 PNG、JPEG、WebP 和 GIF。不支持外部图片 URL、Files API 和图片输出。 + +## Alternatives considered + +- **只使用 pi-ai DeepSeek 提供方。** 其通用多模态路径验证了内容转换,但无法让直接官方路由如实公布能力,也无法让它配合官方模型 id 使用。 +- **把整个提供方声明为支持图片。** 这样会让 Flash、Pro 和未知的原样传递 id 接受持久图片,但其确切协议模型无法承诺消费这些图片。能力仍属于确切模型元数据。 +- **在 `tool` 消息内容中发送图片。** 已记录的兼容形式要求工具内容保持字符串。随后发送 user 消息可避免依赖未记录的多模态 tool role 形式,同时保留调用结果顺序。 +- **增加外部 URL 或 Files 上传。** 两者都需要新的规范输入、授权、生命周期、清理和重放决策。瞬态 base64 可以复用现有持久附件约定,不扩展这些问题。 + +## Verification + +包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。 + +## Consequences + +已配置的 DeepSeek 视觉路由可以消费持久 user 与工具结果图片,而无需改变会话持久性或响应流。重复历史仍会扩张请求正文,但确定性的最旧优先 offload 会限制主导 payload,并在官方 30 MiB 请求正文上限下保留余量。由于官方图片 token 公式尚不可用,图片 token 定价仍由提供方掌握。 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 4c325327de..f0213f0864 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: cfe6fd1028d03056e5ac6da7f014db2ac6db8fab -2026-07-21-serial-cross-platform-ci-reference.zh.md: 8ac7e087c3d015c0ed6bdf71feed9806bd6fb997 +2026-07-21-serial-cross-platform-ci-reference.md: d1ab9590df1252c9c91e7ec53dc1559e221d8f68 +2026-07-21-serial-cross-platform-ci-reference.zh.md: c9ffbac42858a19cca7c5fef6fd8f583030c195d diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index cfe6fd1028..d1ab9590df 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). There is no standard-hosted `serial / linux` definition; the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. @@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), and is absent from the required aggregate under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), and is absent from the required aggregate under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 8ac7e087c3..c9ffbac428 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux`、`serial / macos` 和 `serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。不存在标准托管的 `serial / linux` 定义;标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 @@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.md)),依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)不参与必需聚合流程;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.md)),依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)不参与必需聚合流程。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 3bc9415b40..8ac0ed6dbe 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: b3310988decb2916ac895aaf154dbc106c51ed48 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 2d408173a657c77add750a53eaee4ecb9177919c +2026-07-22-evidence-based-larger-hosted-runners.md: db08a4eb9812a2cb13499718cbd3ceb023960c06 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 1b9b622e929a693d165d23ce256ba4d3c85a0460 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index b3310988de..db08a4eb98 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -14,7 +14,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests run the three primary Linux jobs on the 16-core Ubuntu 24.04 pool and the independent native Windows signal on the 16-core Windows 2025 pool. The required Wine signal remains on standard hosted Linux. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. -The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. +The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete-aggregate evidence available on `master` through the self-hosted standby pools (no Linux hosted serial reference remains). `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. The former gate-level and coarse primary shard jobs are absent from the workflow. Their workflow-facing static, lint, coverage, snapshot, and scenario selectors are also absent, so an unused diagnostic path cannot preserve a second CI architecture. Instrumented coverage may use [process-local partitions inside its existing job](2026-08-18-in-job-partitioned-coverage.md); that coordinator neither selects workflow jobs nor transfers reports between runners. @@ -50,9 +50,9 @@ Inner and outer worker limits are separate controls. An exact-head 32-worker ESL The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection. -Complete serial Linux, macOS, and Windows references run only when `master` moves. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. +The self-hosted serial Linux and Windows standby references and the disabled `serial-macos` job exist. Pull requests use the enterprise required path plus standard-hosted compatibility jobs, while other larger-runner sizes run only by manual dispatch. -An additional serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER_LINUX` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records. +The self-hosted serial Linux reference runs on the in-house self-hosted pool (`vm-backup` label: a 64-core VM with six always-on systemd-managed runner instances) on every `master` push. It is a hot-standby drill, not a required check: each run re-proves that the persistent VM can execute the complete unsharded aggregate. The actual switch is pre-wired: the three required Linux jobs resolve their pool through the writer-manageable `DSH_CI_FAILOVER_LINUX` repository variable, so an outage response is setting one variable and re-running — no merge, which would be deadlocked behind the failing checks themselves ([runbook](2026-07-26-ci-failover-runbook.md)). The standby lane is push-triggered, so it always executes the base branch's workflow definition. Under failover, however, `pull_request` jobs do reach these runners with the PR merge ref's own workflow definition — the trust boundary is repository membership (the repository is private with forking disabled, and the selectors exclude Dependabot), as the [failover runbook](2026-07-26-ci-failover-runbook.md) records. ## Alternatives considered @@ -70,7 +70,7 @@ An additional serial Linux reference runs on the in-house self-hosted pool (`vm- **Publish the static job's build to post-build consumers.** A run-scoped artifact preserves one exact build, but the workflow can only consume it by waiting for the entire static job and then requesting another runner. The [independent consumer build](2026-07-30-independent-ci-consumer-build.md) assigns the single Linux build to its actual consumers instead. -**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility and serial references preserve portable evidence without making that slower topology the ordinary primary path. +**Keep the complete required path on standard GitHub-hosted capacity.** This avoids repository-external runner configuration, but exact-head standard-runner runs remain materially slower and can spend longer queued behind shared capacity. Standard-hosted compatibility jobs preserve portable evidence, while the self-hosted serial standby preserves complete-aggregate evidence, without making that slower topology the ordinary primary path. **Keep blocking and observational native Windows checks in separate jobs.** This would preserve their distinction at the workflow level but pay Windows setup twice. `run-gates` preserves the same blocking versus observational result inside one job. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 2d408173a6..1b9b622e92 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -14,7 +14,7 @@ Status: implemented 企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求在 16 核 Ubuntu 24.04 池上运行 3 个 Linux 主作业,并在 16 核 Windows 2025 池上运行独立的原生 Windows 信号。必需的 Wine 信号仍位于标准托管 Linux。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 -必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性约定,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 +必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性约定,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上通过自托管热备池持续提供完整聚合流程证据(不存在托管的 Linux 串行参考)。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 原有的门禁级和粗粒度主流程分片 job 已从工作流中移除。面向工作流的静态、lint、覆盖率、快照和场景选择器也已移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。插桩覆盖率可以在[既有 job 内使用进程本地分区](2026-08-18-in-job-partitioned-coverage.md);该协调器既不选择工作流 job,也不在 runner 之间传输报告。 @@ -50,9 +50,9 @@ Windows 仓库工作在超过 16 核后收益很小。原生通道把阻断性 进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于覆盖率结果能否保持确定性,而非标称核心数。 -只有在 `master` 移动时,才运行完整的 Linux、macOS 和 Windows 串行参考。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 +自托管的 Linux 与 Windows 串行热备参考,以及被禁用的 `serial-macos` 任务仍然存在。拉取请求使用企业级运行器必需路径和标准托管兼容性作业,其他大型运行器规格仅通过手动触发运行。 -另有一条串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写入权限持有者可管理的仓库变量 `DSH_CI_FAILOVER_LINUX` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查阻塞,形成死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基础分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 +自托管的串行 Linux 参考在每次 `master` 推送时运行于公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 6 个常驻的 systemd 管理运行器实例)。它是热备演练而非必需检查:每次运行都重新证明这台持久化虚拟机能够执行完整的未分片聚合流程。实际切换机制已预先布线:三个必需 Linux 作业通过写入权限持有者可管理的仓库变量 `DSH_CI_FAILOVER_LINUX` 解析运行器池,因此故障响应就是设置一个变量并重跑——无需合并(合并本身会被正在失败的检查阻塞,形成死锁)([切换手册](2026-07-26-ci-failover-runbook.md))。该热备通道由 push 触发,执行的始终是基础分支自身的工作流定义。但需要注意:故障切换期间,`pull_request` 作业确实会带着 PR merge 引用自带的工作流定义到达这些运行器——信任边界是仓库成员资格(仓库为私有且禁用 fork,选择器排除 Dependabot),详见[故障切换手册](2026-07-26-ci-failover-runbook.md)的记录。 ## 曾考虑的替代方案 @@ -70,7 +70,7 @@ Windows 仓库工作在超过 16 核后收益很小。原生通道把阻断性 **将静态作业的构建发布给构建后消费方。** 仅供本次运行使用的产物能保留同一份构建结果,但工作流要消费它,只能先等待整个静态作业完成,再请求另一台运行器。[消费方独立构建](2026-07-30-independent-ci-consumer-build.md)则转而让实际消费方负责唯一一次 Linux 构建。 -**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业和串行参考流程保留可移植证据,无需让这套较慢的拓扑成为普通主路径。 +**将完整必需路径保留在 GitHub 标准托管容量上。** 此方案可以避免依赖仓库外部的运行器配置,但标准运行器上的分支头精确运行仍明显更慢,也可能因共享容量而排队更久。标准托管兼容性作业保留可移植证据,自托管串行热备则保留完整聚合流程证据,无需让这套较慢的拓扑成为普通主路径。 **把阻断性与观测性原生 Windows 检查放在不同 job。** 此方案会在工作流层面保留二者的区别,却要承担两次 Windows 设置开销。`run-gates` 在一个 job 内保留了相同的阻断与观测结果。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index 91879fb4d5..5b9f89e609 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md -2026-07-23-portable-required-pull-request-ci.md: 418f996383ebc08e78cb5b061bfde4b90dd89495 -2026-07-23-portable-required-pull-request-ci.zh.md: ada3cc52e3f6436269969a3ff93b7a2616353337 +2026-07-23-portable-required-pull-request-ci.md: 740ef20d6b7a1edc1a010f37bcbcddab2981e8ad +2026-07-23-portable-required-pull-request-ci.zh.md: 5b24af59fb13e32a642e15c79aee3910601ad45b diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 418f996383..740ef20d6b 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -16,7 +16,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei The three Linux primary jobs, Node compatibility, Python SDK unit suite, Python runtime validation, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. -The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. +The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent completeness check, now provided by the self-hosted `vm-backup`/`dsh-win-ci` standby lanes on `master`; the only hosted serial reference is the disabled `serial-macos`. The manual larger-runner suites retain size comparisons without expanding the ordinary required matrix. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index ada3cc52e3..5b24af59fb 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -16,7 +16,7 @@ Status: implemented 三项 Linux 主作业、Node 兼容性、Python SDK 单元测试套件、Python 运行时验证和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。 -当前主拓扑及其测量结果以[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)为准。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 +当前主拓扑及其测量结果以[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)为准。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的完整性检查,现由 `master` 上公司自有 `vm-backup`/`dsh-win-ci` 自托管热备通道提供;仅存的托管串行参考是禁用的 `serial-macos`。手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 392c4f54f2..c75e36f054 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 499141ca6a3703a12d10c195b732441d49419599 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 0b1e280db6e4e04cc4a210203e97026174fc02f6 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 31d1ebf009a6e044722081985546e87b9d4ae0e2 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 84445abfdd42671f2f5c9de8403ed7891f9cc292 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index 499141ca6a..31d1ebf009 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -13,7 +13,7 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i `pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes: - **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. -- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. +- **Restore-only caching** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path. No master job produces these hosted caches, so these restores hit matching archived entries until they evict. The enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. - **Cache-less or persistent** (no store-cache action): the independent native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive. ## Alternatives considered @@ -31,4 +31,4 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i - The generated-project e2e runs the root-pinned Yarn 4 CLI instead of inheriting or silently skipping the runner image's Yarn version. - The cache-key format changed once for converted lanes; one cold run repopulated it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. - `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry. -- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need cache pairing or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs and serial-linux resolve and share that stable path and exact key. +- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need hosted cache restores or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs resolve that stable path and exact key. diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index 0b1e280db6..84445abfdd 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -13,7 +13,7 @@ Status: implemented `pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业策略,保留三种有意采用的形态: - **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 -- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 +- **只恢复不上传**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径。没有任何 master 作业生产这些 hosted 缓存,这些恢复步骤只能命中仍有归档的旧条目,直至其被逐出;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。 - **无缓存或持久化**(不使用 store 缓存 action):独立的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 @@ -31,4 +31,4 @@ Status: implemented - generated-project e2e 运行根目录锁定的 Yarn 4 CLI,既不再沿用 runner 镜像中的 Yarn 版本,也不会因此悄然跳过。 - 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 的各个矩阵任务共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 - `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是利用上一条缓存记录预填充。 -- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要缓存配对或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业与 serial-linux 会解析并共享这一稳定路径及精确键。 +- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要 hosted 缓存恢复或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业会解析这一稳定路径及精确键。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index d5bc176adc..419e81704b 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: afe3c937a733ed50073ada5c9c6f40c6c93c6641 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 57a4d3ae5fba7c10211587e36ce9e41e7e985d7e +2026-07-24-web-gui-browser-e2e-lane.md: a477a887e679d455376e69ef0c0fae58dc8dde69 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 1f9504df6d49061a5b4759e65dcbbeeb8d9c7402 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index afe3c937a7..a477a887e6 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -46,7 +46,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e ### CI stance -The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The hosted and self-hosted default-branch Linux serial jobs run the same gate; the hosted job produces the browser cache consumed by pull requests, while the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. +The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The self-hosted default-branch Linux serial standby runs the same gate; there is no hosted Linux serial producer of the browser cache consumed by pull requests, and the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices. High-cardinality performance diagnostics use the separate opt-in `apps/web/tests/**/*.perf.ts` inventory selected only by `vitest.web.perf.config.ts`. The isolated `complex-history.perf.ts` cases reuse the real scaffold: the workspace case seeds 1,000 compact sessions plus one 500-turn history containing 500 tool calls, exhausts and remounts that history in Chat, and reports Chromium main-thread, DOM, listener, heap, paging, search, and Trajectory measurements. Two continuation cases seed the same long history but compare the default 24-turn Chat window with all 500 turns expanded before each continues eight identical turns through the real composer, agent loop, SSE wire, tools, and persistence; two turns execute a real `bash` call and assert its durable result, while the final turn fills an 8,232-character mixed-language prompt and replays 120 paced text deltas. A separate soak case starts from a blank session, drives 100 consecutive real composer turns with a `bash` call and result every tenth turn, forces GC every ten turns, and reports ten-turn latency windows plus retained browser state. It then submits a 101st text-only turn with a trusted browser click and measures browser-clock send-to-transcript-DOM and send-to-post-paint latency, excluding the composer's draft mirrors, separately from full-turn completion. Per-turn diagnostics cover composer fill, click-to-user-echo, click-to-first-chunk, completion, browser mutations, persisted chunks, and tool events; the synthetic replay model has enough context capacity to keep fixture cardinality stable instead of consuming scripted calls through compaction. Structural assertions pin the intended load, stream, and tool shapes, but timing remains threshold-free because machine speed is not a correctness contract. The required `vitest.web.config.ts` inventory remains limited to `*.e2e.ts` and `*.snapshot.ts`, so neither `test:web:built` nor its CI gate collects performance cases. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 57a4d3ae5f..1f9504df6d 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -46,7 +46,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### CI 立场 -根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。托管和自托管的默认分支 Linux 串行任务运行同一门禁;托管任务生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 +根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。自托管的默认分支 Linux 串行热备运行同一门禁;不存在托管 Linux 串行生产者生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。 高基数性能诊断使用单独按需启用的 `apps/web/tests/**/*.perf.ts` 清单,并且只由 `vitest.web.perf.config.ts` 选中。`complex-history.perf.ts` 的隔离用例复用真实 scaffold:工作区用例播种 1,000 个紧凑会话以及一份包含 500 次工具调用的 500 轮次历史,在 Chat 中穷尽并重新挂载该历史,并报告 Chromium 主线程、DOM、监听器、堆内存、分页、搜索和 Trajectory 测量结果。两个续聊用例播种同一份长历史,但比较默认的 24 轮次 Chat 窗口与展开全部 500 轮次的状态,然后各自通过真实输入框、agent loop、SSE wire、工具和持久化继续进行 8 个相同轮次;其中两轮执行真实 `bash` 调用并断言其持久化结果,最后一轮则填入一条包含 8,232 个字符的混合语言提示词,并回放 120 个带节奏的文本增量。一个单独的 soak 用例从空白会话开始,通过真实输入框连续驱动 100 轮,每第 10 轮执行一次 `bash` 调用并产生结果,每 10 轮强制执行一次 GC,并报告每 10 轮的延迟窗口及保留的浏览器状态。随后它通过受信任的浏览器点击提交第 101 个纯文本轮次,并使用浏览器时钟分别测量发送到 transcript DOM 和发送到绘制后的延迟,排除输入框的草稿镜像,并与完整轮次完成时间分开。逐轮诊断涵盖输入框填入、点击到用户消息回显、点击到首个分片、完成、浏览器变更、持久化分片和工具事件;合成回放模型拥有足够的上下文容量,可使 fixture 基数保持稳定,而不会因压缩(compaction)消耗脚本化调用。结构性断言钉住预期的负载、流和工具形状,但时间仍不设阈值,因为机器速度不属于正确性约定。必需的 `vitest.web.config.ts` 清单仍仅限 `*.e2e.ts` 和 `*.snapshot.ts`,因此 `test:web:built` 及其 CI 门禁都不会收集性能用例。 diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml index 8557998d06..c0fd339c04 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md -2026-07-30-web-browser-snapshot-ci-gate.md: 72a7e33d0e84105f7680429443df41661ced288a -2026-07-30-web-browser-snapshot-ci-gate.zh.md: 161f99ab98984ca1d938f11c5e3de5176ca4da66 +2026-07-30-web-browser-snapshot-ci-gate.md: abb197f2a8e3488e2ae1edc7c2e77cbdcf919556 +2026-07-30-web-browser-snapshot-ci-gate.zh.md: b665dc90c4bdba969b3cd9a561a3f18f388e27dd diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md index 72a7e33d0e..abb197f2a8 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md @@ -12,13 +12,13 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. When `DSH_WEB_SNAPSHOT_WORKERS` is configured, `scripts/run-gates.ts` registers `test:web:ci` as the `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing. -The consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. The hosted default-branch Linux serial job runs the suite and produces the operating-system-and-lockfile-keyed browser cache; pull requests restore it without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. The self-hosted standby runs the same comparison without hosted cache actions. +The consumer job owns the [single Linux build](../process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. Pull requests restore the operating-system-and-lockfile-keyed browser cache without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. No master job produces these hosted caches, so restores hit archived entries until they evict. The self-hosted standby runs the same comparison without hosted cache actions. Local `pnpm run test:web` continues to build first and then run the full browser suite serially; `test:web:built` is the serial entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written. CI's `scripts/run-web-snapshots.ts` first runs `hmr-live.e2e.ts` and `cordis-tool-round.e2e.ts` as separate serial Vitest invocations. The HMR scenario mutates built workspace state, while the Cordis scenario owns a lifecycle-sensitive approval and steering sequence whose turn grouping is made deterministic by waiting for the initial turn to settle before approval. After both pass, one six-worker Vitest pool runs every remaining file. Every child inherits stdio, and the enclosing gate streams that output through `run-gates`. -For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The hosted and self-hosted default-branch Linux serial aggregates also include the comparison, while the macOS and Windows serial jobs remain browser-free. A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. +For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The self-hosted default-branch Linux serial standby also includes the comparison, while the macOS and Windows serial jobs remain browser-free (there is no hosted Linux serial aggregate). A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name. Completed local replays measured the six-worker browser command at about 65–71 seconds. A twelve-worker comparison completed in about 50 seconds, so halving the browser worker budget adds about 15–20 seconds rather than doubling wall time. The gate scheduler starts browser snapshots as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule. diff --git a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md index 161f99ab98..b665dc90c4 100644 --- a/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md +++ b/.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md @@ -12,13 +12,13 @@ Status: implemented Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。配置 `DSH_WEB_SNAPSHOT_WORKERS` 后,`scripts/run-gates.ts` 把 `test:web:ci` 登记为 `ci-consumers` 门禁,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的预期输出与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。 -消费方 job 在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。托管的默认分支 Linux 串行 job 运行该套件,并生成以操作系统和锁文件为键的浏览器缓存;PR 恢复该缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。自托管热备运行相同的比较,但不执行托管缓存操作。 +消费方 job 在[消费方独立构建](../process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。PR 恢复以操作系统和锁文件为键的浏览器缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。没有任何 master 作业生成这些 hosted 缓存,因此恢复只能命中仍有归档的旧条目,直至其被逐出。自托管热备运行相同的比较,但不执行托管缓存操作。 本地 `pnpm run test:web` 仍先构建,再串行运行完整浏览器套件;`test:web:built` 是已有构建产物的串行执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。 CI 的 `scripts/run-web-snapshots.ts` 先用相互独立的 Vitest 调用串行运行 `hmr-live.e2e.ts` 与 `cordis-tool-round.e2e.ts`。HMR 场景会修改已构建工作区状态;Cordis 场景则拥有一条对生命周期时序敏感的批准与 steering(中途引导)序列,它通过在批准前等待初始轮次结束来确定轮次分组。两者通过后,其余全部文件进入同一个 6-worker Vitest 池。所有子进程都继承 stdio,外围门禁再通过 `run-gates` 流式传递输出。 -对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。托管和自托管的默认分支 Linux 串行聚合作业也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。 +对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。自托管的默认分支 Linux 串行热备也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器(不存在托管的 Linux 串行聚合)。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。 完整本地 replay 中,6-worker 浏览器命令耗时约 65–71 秒。12-worker 对比约为 50 秒,因此把浏览器 worker 预算减半只增加约 15–20 秒,而不是让墙钟时间翻倍。门禁调度器会在 `built-package-invariants` 成功后立即启动浏览器快照,并发运行彼此独立的门禁,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 8c6569fa07..ba17869f29 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -219,6 +219,8 @@ jobs: } - name: Build single-exe + env: + DSH_BUILD_CLIENT_PROFILE: official run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} - name: Resolve platform outputs @@ -261,17 +263,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 +302,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 +311,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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cab0cf791..741a6c4d5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,11 @@ env: jobs: - # TODO(hosted-serial-ci): Re-enable the three hosted serial reference jobs before release. - # The self-hosted standby remains active on every master push. + # TODO(hosted-serial-ci): Re-enable the one remaining disabled hosted serial + # reference job (serial-macos) before release. The self-hosted standby lane + # below remains active on every master push. Re-enabling serial-macos does not + # restore a Linux hosted-cache producer: decide whether to add a master seeder + # or remove the restore-only steps if cold starts become a concern. # Three enterprise jobs isolate coverage, static analysis, and the # build-backed consumer tail. The consumer job owns the only Linux build so @@ -211,7 +214,11 @@ jobs: store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) echo "path=$store_path" >> "$GITHUB_OUTPUT" - # Skipped under failover — see the coverage lane's identical rationale. + # Pull requests restore the pnpm store and Playwright caches without paying + # compression and upload on the required path. No master job saves these + # hosted cache keys, so each restore-keys fallback hits the matching archived + # entry until it evicts, after which the store is cold. Skipped under failover + # — the self-hosted VM's persistent store is already warm. - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: @@ -220,8 +227,7 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - # Pull requests restore the cache normally produced by serial-linux on - # master; they do not pay compression and upload on the required path. + # Skipped under failover: the VM's persistent browser cache is already warm. - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: @@ -293,6 +299,8 @@ jobs: run: pnpm install --frozen-lockfile - name: Run compatibility smokes + env: + DSH_BUILD_CLIENT_PROFILE: official run: pnpm run check:node-compat python-sdk: @@ -389,7 +397,17 @@ jobs: - name: Install Wine run: | if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then - sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + # The restored archive is the full --download-only closure of + # `wine` for this runner image, so installing the .debs directly + # with dpkg needs no repository access. apt-get would instead + # re-download the same 100+ MB closure from the mirror, which has + # stalled the job past its budget on a degraded runner network. + # If the archive cannot satisfy the closure, fall back to the apt + # network install. + if ! sudo DEBIAN_FRONTEND=noninteractive dpkg -i "$HOME"/wine-debs/*.deb; then + sudo DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true + sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + fi else sudo apt-get update sudo apt-get install -y --no-install-recommends --download-only wine @@ -492,76 +510,6 @@ jobs: shell: pwsh run: pnpm run check:ci:windows-complete - # The hosted reference jobs below are temporarily disabled; the self-hosted - # standby remains active. Each enabled host executes the complete, unsharded - # primary Node aggregate with one gate worker, giving reviewers a simple - # cross-platform oracle for completeness and timing. - serial-linux: - if: false - name: serial / linux - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 2 - - - uses: pnpm/action-setup@v4 - with: - dest: ${{ runner.temp }}/setup-pnpm - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Configure pnpm store path - id: pnpm-store - run: | - store_root="$HOME/.local/share/pnpm/store" - echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" - store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) - echo "path=$store_path" >> "$GITHUB_OUTPUT" - - # Master refreshes the pnpm store cache that pull requests restore without saving. - # The store cache stays a hand-rolled actions/cache step rather than - # setup-node's `cache: pnpm`: the enterprise pull-request jobs above - # restore exactly this key and path, and setup-node's built-in cache - # uses its own key format — converting this producer would silently - # starve their documented restore-only optimization. - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - # Master produces the hosted Chromium cache restored by pull requests. - - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install (immutable) - run: pnpm install --frozen-lockfile - - - name: Install Playwright Chromium and system dependencies - run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium - - - name: Prepare bubblewrap (unrestrict userns) - run: bash scripts/prepare-ci-bubblewrap.sh - - - name: Run complete unsharded primary Node CI serially - env: - DSH_ARCHIVE_BASE_REF: ${{ github.event.before }} - DSH_COVERAGE_MAX_WORKERS: '1' - DSH_E2E_MAX_WORKERS: '1' - DSH_GATE_CONCURRENCY: '1' - DSH_OXLINT_THREADS: '1' - DSH_PUBLINT_CONCURRENCY: '1' - DSH_SNAPSHOT_MAX_CONCURRENCY: '1' - run: pnpm run check:ci:linux-primary - # Hot-standby drill for the in-house self-hosted pool: every master move # re-runs the complete unsharded aggregate on the persistent 64-core VM, # continuously proving that environment can take over a required lane if @@ -580,8 +528,8 @@ jobs: name: serial / linux (self-hosted standby) runs-on: [self-hosted, linux, x64, vm-backup] steps: - # Full history + DSH_ARCHIVE_BASE_REF below: same frozen-archive - # comparison as serial-linux. Depth 2 would miss github.event.before + # DSH_ARCHIVE_BASE_REF below compares the frozen-archive gate against + # github.event.before, so full history is required: depth 2 would miss it # on multi-commit or force pushes; full fetch is cheap here because # checkout resolves against the VM's local mirror. - uses: actions/checkout@v6 @@ -621,6 +569,8 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci:linux-primary + # The one remaining disabled hosted serial reference job; see + # TODO(hosted-serial-ci) above. serial-macos: if: false name: serial / macos diff --git a/.github/workflows/e2b-e2e.yml b/.github/workflows/e2b-e2e.yml index abbd0482ed..0442b0e093 100644 --- a/.github/workflows/e2b-e2e.yml +++ b/.github/workflows/e2b-e2e.yml @@ -46,7 +46,7 @@ jobs: # The Loader smoke runs package exports under plain Node in lib mode. - name: Build (lib for the E2B Loader smoke) - run: pnpm run build + run: pnpm run build:official - name: E2B tests (live sandbox) env: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d72e7bfee4..3cd2f515ab 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -76,15 +76,11 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile - # The with-key escalation e2e self-skips without a usable runner. Install - # bwrap so trusted CI exercises it; the userns knob is best-effort and the - # test's functional probe decides. - - name: Install bubblewrap (unrestrict userns) - run: | - sudo apt-get update -q - sudo apt-get install -yq bubblewrap - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \ - || echo "apparmor userns knob absent — the functional probe decides" + # The with-key escalation e2e self-skips without a usable runner. Prepare + # bwrap so trusted CI exercises it; the functional probe remains the + # authority on whether the runner can use the sandbox. + - name: Prepare bubblewrap (unrestrict userns) + run: bash scripts/prepare-ci-bubblewrap.sh # Guard against a false green: the e2e suites self-skip when the key is # absent, so a missing/misconfigured secret would otherwise pass as @@ -108,7 +104,7 @@ jobs: # the built artifact under plain Node, resolving plugins through real package # exports — the shape a real consumer runs. That requires a prior build. - name: Build (lib for the e2e example bins) - run: pnpm run build + run: pnpm run build:official # Real-API end-to-end tests only. The keyless gates (lint/typecheck/ # coverage/snapshot/etc.) already run in ci.yml on every push/PR. diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 4a2f05b10b..f5b9c63c4b 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e20c215fd..08296468bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,7 +75,7 @@ jobs: run: pnpm run release:verify --family dsh - name: Build - run: pnpm run build + run: pnpm run build:official - name: Pack release tarballs run: pnpm run release:pack --family dsh --out dist/npm diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 192cbbd524..d13e4f7c50 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -117,7 +117,7 @@ jobs: # together so registry state cannot mask source/package drift. - name: Build packages for the pack rehearsal if: matrix.runner == 'landlock' - run: pnpm run build + run: pnpm run build:official - name: Packed-distribution e2e (pack → install → confine) if: matrix.runner == 'landlock' diff --git a/.gitignore b/.gitignore index 3d0fd8e322..70c355e391 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ python/**/__pycache__/ python/**/.pytest_cache/ apps/web/dist/ .artifacts/ +.dsh-build/ .playwright-mcp/ .orig .worktrees/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 87c4e8dccb..cf5a093f69 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/BRAND-GUIDELINES.md b/BRAND-GUIDELINES.md new file mode 100644 index 0000000000..96ecc5d922 --- /dev/null +++ b/BRAND-GUIDELINES.md @@ -0,0 +1,21 @@ +# DeepSeek Harness 品牌素材使用规范 + +为了维护 DeepSeek Harness 生态的长期健康发展,避免用户混淆,方便用户对相关资源进行检索和识别,我们特别制定本规范,希望大家共同遵守: + +- 在项目的描述性文字中,您可以使用“DeepSeek Harness”真实、准确地说明您的项目与 DeepSeek Harness 的关系,例如“基于 DeepSeek Harness 构建”或“兼容 DeepSeek Harness”等。这类说明符合许可证的要求,也有助于用户理解项目的定位。 +- 如果您希望项目名称能体现与 DeepSeek Harness 生态的关联,我们建议使用缩写的 **“DSH”** 标识来命名,这样既清晰又便于社区内的交流。 +- 在项目命名时,请避免直接使用完整的 **“DeepSeek Harness”** 商标。**“DeepSeek Harness”** 是深度求索公司的注册商标,未经授权用于项目名,容易引发用户的误解和混淆,从而影响整个生态的清晰度。同时,也可能涉及商标侵权行为。 +- 此外,请您避免在宣传或展示时,以容易引起误解的方式使用官方品牌素材,以免让用户产生官方背书、合作或授权等不实印象。 + +我们相信,一个清晰、有序的社区环境,能让每一位开发者的努力都更容易被看见和认可。对于少数不符合上述规范的情况,我们可能会联系相关方进行适当的调整,以维护生态整体的秩序。感谢大家的理解与支持,让我们一起构建一个更友好、更可持续发展的开源社区。 + +# DeepSeek Harness Brand Asset Usage Guidelines + +To maintain the long\-term healthy development of the DeepSeek Harness ecosystem, avoid user confusion, and facilitate the retrieval and identification of related resources, we have established these specifications and hope that everyone will adhere to them: + +- In your project's descriptive text, you may use "DeepSeek Harness" to truthfully and accurately describe your project's relationship with DeepSeek Harness, for example, "built on DeepSeek Harness" or "compatible with DeepSeek Harness\." Such descriptions comply with license requirements and help users understand your project's positioning\. +- If you wish your project name to reflect its association with the DeepSeek Harness ecosystem, we recommend using the abbreviated "DSH" designation for naming, which is both clear and facilitates communication within the community\. +- When naming your project, please avoid using the full "DeepSeek Harness" trademark directly\. "DeepSeek Harness" is a registered trademark of DeepSeek\. Unauthorized use in project names can easily lead to user misunderstanding and confusion, thereby affecting the clarity of the entire ecosystem\. It may also involve trademark infringement\. +- Additionally, please avoid using official brand materials in your promotions or presentations in a way that could cause misunderstanding, so as not to give users the false impression of official endorsement, cooperation, or authorization\. + +We believe that a clear and orderly community environment will make every developer's efforts more visible and more readily recognized\. For the few cases that do not comply with the above specifications, we may contact the relevant parties to make appropriate adjustments in order to maintain the overall order of the ecosystem\. Thank you for your understanding and support—let us work together to build a more friendly and sustainable open\-source community\. \ No newline at end of file diff --git a/README.i18n.yaml b/README.i18n.yaml index 8daa789977..403f2deb4e 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: 8a4bd01332a23ce4144c661784bc549e0ba72d21 -README.zh.md: c507bf884bd426feead6a96adbdb5c136456e3b5 +README.md: 9ccd27b8934449bd0d2311317dc38aee5a5c0cdc +README.zh.md: 7eb9ef1a62afbcf95b235e5b90fa7529869af59d diff --git a/README.md b/README.md index 8a4bd01332..9ccd27b893 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Install `Node.js`, then run: npx @deepseek-ai/dsh web ``` -The command starts the Web UI, served at `http://127.0.0.1:3080` by default. See [Web UI guide](docs/user/guide/index.md). +The command starts the Web UI at `http://127.0.0.1:3080` by default and opens it in the default browser for a local launch. An SSH launch only prints the host URL because the SSH client or editor owns the local forwarded address. Pass `--no-open` to run the server without opening a browser. See [Web UI guide](docs/user/guide/index.md). ### Run from source @@ -34,6 +34,8 @@ pnpm run build pnpm dsh web ``` +`pnpm run build` prepares the repository artifacts. `pnpm dsh web` uses those built artifacts without rebuilding. + ## Community and support - Feel free to submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions). diff --git a/README.zh.md b/README.zh.md index c507bf884b..7eb9ef1a62 100644 --- a/README.zh.md +++ b/README.zh.md @@ -20,7 +20,7 @@ DeepSeek Harness 目前处于 _开发者预览_ 阶段,正在快速迭代。** npx @deepseek-ai/dsh web ``` -该命令会启动 Web UI,默认地址为 `http://127.0.0.1:3080`。详见 [Web UI 指南](docs/user/guide/index.md)。 +该命令默认会在 `http://127.0.0.1:3080` 启动 Web UI,本机启动时还会用默认浏览器打开页面。通过 SSH 启动时只打印宿主机 URL,因为本地转发地址由 SSH 客户端或编辑器持有。传入 `--no-open` 可仅运行服务器而不打开浏览器。详见 [Web UI 指南](docs/user/guide/index.md)。 ### 从源码运行 @@ -34,6 +34,8 @@ pnpm run build pnpm dsh web ``` +`pnpm run build` 会准备仓库产物。`pnpm dsh web` 会直接使用这些已构建产物,不会重新构建。 + ## 社区与支持 - 欢迎通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6ad4739c19..295eb1868f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -77,6 +77,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | +| [`open`](https://github.com/sindresorhus/open) | MIT | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index b6ed406f54..0e1f2b0f5d 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -3,7 +3,8 @@ # The persona is the complete system prompt, so global identity, Web orientation, # tool guidance, and later assembly listeners cannot add prompt text. Runtime # context snapshots are suppressed for this preset, and the model composes only -# persistent `bash` and `str_replace_editor`. Context compaction is absent. +# the persistent shell (`bash` on POSIX, `pwsh` on win32) and +# `str_replace_editor`. Context compaction is absent. - id: persona name: '@deepseek-ai/dsh-persona' @@ -15,6 +16,8 @@ # The PTY registry is an agent-owned service, so it lives in an entry-local # realm. The backend still consumes the host sandbox policy and subprocess # implementation, while the tool registers into this agent's scoped catalog. +# Exactly one shell stack mounts per host: the bash stack gates off win32 and +# its pwsh twin gates off POSIX, mirroring the one-shot shell rows. - id: persistent-shell name: cordis:group group: true @@ -26,11 +29,13 @@ - id: terminal-bash name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 - id: persistent-bash name: '@deepseek-ai/dsh-tool-bash-persistent' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 description: |- @@ -43,6 +48,27 @@ * Please avoid commands that may produce a very large amount of output. * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + - id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform !== 'win32' + config: + shellDialect: pwsh + timeoutMs: 300000 + + - id: persistent-pwsh + name: '@deepseek-ai/dsh-tool-pwsh-persistent' + disabled: !!js process.platform !== 'win32' + config: + timeoutMs: 300000 + description: |- + Run commands in a PowerShell shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * State is persistent across command calls and discussions with the user. + * Use native Windows paths (C:\...) and $env:NAME variables; this is PowerShell, not bash. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process. + # The bare local filesystem shadows the host's sandboxed provider only for this # preset. The editor shares that realm and requires absolute paths. - id: filesystem diff --git a/apps/cli/package.json b/apps/cli/package.json index 1323329b2b..ae07ed5df5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index ef63dd2fc6..a5851993dc 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 7828f55a2e4adfd85a0018baada6945ea75aacb0 -README.zh.md: e14e13731c314efd4d39913b91f2e90ba624e55c +README.md: e60f9d9e00dc77c8b2f22edcba67f9e8f3ba2f07 +README.zh.md: a99531a67d447039803a7a8248f2cc30a19d9be9 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 7828f55a2e..e60f9d9e00 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -24,7 +24,7 @@ The shipped apps own these command lines: | Profile | Arguments | |---|---| -| `web` | `--host`, `--port`, repeatable `--trusted-host` | +| `web` | `--host`, `--port`, repeatable `--trusted-host`, `--no-open` | | `headless` | the task text, as the positional argument | A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. @@ -64,16 +64,17 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, and repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities). The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles. ```sh dsh web +dsh web --no-open dsh web --patch ./extra.cordis.yml dsh web --dump-config dsh web --help ``` -The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. The CLI intentionally does not support `--host 0.0.0.0` yet and exits with a usage error; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. +The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default and, for a local launch, opens that canonical host URL only after the complete Loader tree settles. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses the browser handoff because the SSH client or editor owns the local forwarded address; the host URL is still printed. The CLI intentionally does not support `--host 0.0.0.0` yet and exits with a usage error. Immediately before a local handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, a diagnostic on stderr states the reason, leaves the server running, and names the URL for manual use. `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index e14e13731c..a99531a67d 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -24,7 +24,7 @@ | Profile | 参数 | |---|---| -| `web` | `--host`、`--port`、可重复的 `--trusted-host` | +| `web` | `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` | | `headless` | 任务文本,作为位置参数 | 一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 @@ -64,16 +64,17 @@ dsh --profile tui ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。 ```sh dsh web +dsh web --no-open dsh web --patch ./extra.cordis.yml dsh web --dump-config dsh web --help ``` -生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。CLI 目前有意不支持 `--host 0.0.0.0`,并会以用法错误退出;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 +生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`;本机启动时,只在完整 Loader 配置树结算后才用默认浏览器打开该规范宿主机 URL。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有;宿主机 URL 仍会打印。CLI 目前有意不支持 `--host 0.0.0.0`,并会以用法错误退出。本机交接前会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 诊断会说明原因、给出 URL 供手动访问,服务器仍继续运行。`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。 diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 611a098b6f..b76326d799 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -36,8 +36,8 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] }) expect(parse(['--profile', 'web', '-h'])) .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] }) - expect(parse(['web', '--host', '127.0.0.1', '--port', '8080', '--dev'])) - .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '127.0.0.1', '--port', '8080', '--dev'] }) + expect(parse(['web', '--host', '127.0.0.1', '--port', '8080', '--no-open', '--future-web-flag'])) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '127.0.0.1', '--port', '8080', '--no-open', '--future-web-flag'] }) expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) .toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] }) // Launcher flags placed after that boundary belong to the app too. diff --git a/apps/cli/tests/fixtures/web-browser-open/open.mjs b/apps/cli/tests/fixtures/web-browser-open/open.mjs new file mode 100644 index 0000000000..0f196f0e3f --- /dev/null +++ b/apps/cli/tests/fixtures/web-browser-open/open.mjs @@ -0,0 +1,42 @@ +import { spawn } from 'node:child_process' +import { join } from 'node:path' + +const handoffProbe = ` +const { writeFileSync } = require('node:fs') +const marker = process.argv[1] +const helperPid = Number(process.argv[2]) +setTimeout(() => { + let helperAlive = true + if (process.platform === 'win32') { + try { + process.kill(helperPid, 0) + } catch { + helperAlive = false + } + } + if (helperAlive) writeFileSync(marker, '') +}, 50) +` + +export default async function open(url) { + if (process.env.BROWSER_OPEN_TEST_FAILURE !== undefined) { + throw new Error(process.env.BROWSER_OPEN_TEST_FAILURE) + } + const response = await fetch(url) + const html = await response.text() + console.log(`dsh browser-open: ${JSON.stringify({ + url, + status: response.status, + bootManifest: html.includes('__DSH_BOOT__'), + apiKeyPresent: process.env.DEEPSEEK_API_KEY !== undefined, + dshHomePresent: process.env.DSH_HOME !== undefined, + })}`) + // The Windows launcher writes the server-exit marker only while its helper + // remains alive, so the assembled test detects an early helper exit. + const launcher = spawn(process.execPath, [ + '--eval', handoffProbe, + '--', join(process.cwd(), `.dsh-browser-open-${process.ppid}`), String(process.pid), + ], { stdio: 'ignore' }) + launcher.unref() + return launcher +} diff --git a/apps/cli/tests/fixtures/web-browser-open/register.mjs b/apps/cli/tests/fixtures/web-browser-open/register.mjs new file mode 100644 index 0000000000..8f3e53c4f3 --- /dev/null +++ b/apps/cli/tests/fixtures/web-browser-open/register.mjs @@ -0,0 +1,41 @@ +import { existsSync, rmSync } from 'node:fs' +import { registerHooks } from 'node:module' +import { join } from 'node:path' + +const openerUrl = new URL('./open.mjs', import.meta.url).href +const exitMarker = join(process.cwd(), `.dsh-browser-open-${process.pid}`) + +const markerPoll = setInterval(() => { + if (!existsSync(exitMarker)) return + rmSync(exitMarker, { force: true }) + process.exit(0) +}, 25) +markerPoll.unref() + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'open') return { shortCircuit: true, url: openerUrl } + return nextResolve(specifier, context) + }, +}) + +// The SSH case has no opener helper to stop the long-lived Web process. +if (process.env.DSH_BROWSER_OPEN_TEST_EXIT_ON_READY === '1') { + const originalLog = console.log + console.log = (...args) => { + originalLog(...args) + if (typeof args[0] === 'string' && args[0].startsWith('dsh web: ')) { + setTimeout(() => process.exit(0), 250) + } + } +} + +if (process.env.DSH_BROWSER_OPEN_TEST_EXIT_ON_FAILURE === '1') { + const originalError = console.error + console.error = (...args) => { + originalError(...args) + if (typeof args[0] === 'string' && args[0].startsWith('web-app: could not open the default browser because ')) { + setTimeout(() => process.exit(0), 0) + } + } +} diff --git a/apps/cli/tests/lazy-search-startup.compat.spec.ts b/apps/cli/tests/lazy-search-startup.compat.spec.ts index c563c850e6..d2172ebe57 100644 --- a/apps/cli/tests/lazy-search-startup.compat.spec.ts +++ b/apps/cli/tests/lazy-search-startup.compat.spec.ts @@ -57,6 +57,7 @@ function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; cod const child = spawn(process.execPath, [ builtBin, 'web', + '--no-open', '--host', '127.0.0.1', '--port', diff --git a/apps/cli/tests/web-browser-open.snapshot.ts b/apps/cli/tests/web-browser-open.snapshot.ts new file mode 100644 index 0000000000..0441ab6e17 --- /dev/null +++ b/apps/cli/tests/web-browser-open.snapshot.ts @@ -0,0 +1,239 @@ +/** Assembled keyless snapshot for the default `dsh web` browser handoff. */ + +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { afterEach, describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const builtBin = join(repoRoot, 'apps/cli/lib/bin.js') +const frontendIndex = join(repoRoot, 'apps/web/dist/index.html') +const openerHook = new URL('./fixtures/web-browser-open/register.mjs', import.meta.url).href +const openingMessage = 'dsh web: opening the default browser; pass --no-open to disable' +const tempRoots: string[] = [] +const builtArtifactsExist = existsSync(builtBin) && existsSync(frontendIndex) + +if (process.env.DSH_EXAMPLE_MODE === 'lib' && !builtArtifactsExist) { + throw new Error('dsh web browser-open snapshot requires built CLI and Web artifacts in lib mode') +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +interface BrowserOpenRecord { + url: string + status: number + bootManifest: boolean + apiKeyPresent: boolean + dshHomePresent: boolean +} + +function normalizeLocalUrl(url: string): string { + return url.replace(/:\d+$/, ':{{port}}') +} + +describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot', () => { + it('hands the reachable page to the default browser after the shipped tree settles', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-snapshot-')) + tempRoots.push(root) + const result = await execa(process.execPath, [ + '--import', openerHook, + builtBin, + 'web', + '--port', '0', + ], { + cwd: root, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-browser-open-no-call', + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_HOME: join(root, '.dsh'), + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + SSH_CONNECTION: '', + SSH_TTY: '', + }, + input: '', + timeout: 30_000, + killSignal: 'SIGKILL', + reject: false, + }) + const readyUrl = /dsh web: (http:\/\/[^\s]+)/u.exec(result.stdout)?.[1] + const openLine = result.stdout.split('\n').find(line => line.startsWith('dsh browser-open: ')) + const opening = result.stdout.includes(openingMessage) + if (readyUrl === undefined || openLine === undefined || !opening) { + throw new Error(`dsh web browser-open evidence missing\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + const opened = JSON.parse(openLine.slice('dsh browser-open: '.length)) as BrowserOpenRecord + + expect({ + exitCode: result.exitCode, + opening, + readyUrl: normalizeLocalUrl(readyUrl), + openedUrl: normalizeLocalUrl(opened.url), + status: opened.status, + bootManifest: opened.bootManifest, + apiKeyPresent: opened.apiKeyPresent, + dshHomePresent: opened.dshHomePresent, + stderr: result.stderr, + }).toMatchInlineSnapshot(` + { + "apiKeyPresent": false, + "bootManifest": true, + "dshHomePresent": false, + "exitCode": 0, + "openedUrl": "http://127.0.0.1:{{port}}", + "opening": true, + "readyUrl": "http://127.0.0.1:{{port}}", + "status": 200, + "stderr": "", + } + `) + }) + + it('prints the launcher reason and manual URL after the Web app is ready', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-failure-snapshot-')) + tempRoots.push(root) + const result = await execa(process.execPath, [ + '--import', openerHook, + builtBin, + 'web', + '--port', '0', + ], { + cwd: root, + env: { + ...process.env, + BROWSER_OPEN_TEST_FAILURE: 'fixture desktop unavailable', + DEEPSEEK_API_KEY: 'keyless-browser-open-no-call', + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_BROWSER_OPEN_TEST_EXIT_ON_FAILURE: '1', + DSH_HOME: join(root, '.dsh'), + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + SSH_CONNECTION: '', + SSH_TTY: '', + }, + input: '', + timeout: 30_000, + killSignal: 'SIGKILL', + reject: false, + }) + const readyUrl = /dsh web: (http:\/\/[^\s]+)/u.exec(result.stdout)?.[1] + const diagnostic = result.stderr.split(/\r?\n/u) + .find(line => line.startsWith('web-app: could not open the default browser because ')) + ?.replace(/http:\/\/127\.0\.0\.1:\d+/u, 'http://127.0.0.1:{{port}}') + + expect({ + diagnostic, + exitCode: result.exitCode, + opened: result.stdout.includes('dsh browser-open: '), + opening: result.stdout.includes(openingMessage), + readyUrl: readyUrl === undefined ? undefined : normalizeLocalUrl(readyUrl), + }).toMatchInlineSnapshot(` + { + "diagnostic": "web-app: could not open the default browser because fixture desktop unavailable; visit http://127.0.0.1:{{port}} manually", + "exitCode": 0, + "opened": false, + "opening": true, + "readyUrl": "http://127.0.0.1:{{port}}", + } + `) + }) + + it('prints the host URL without launching a browser in a VS Code Remote SSH session', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-ssh-snapshot-')) + tempRoots.push(root) + const result = await execa(process.execPath, [ + '--import', openerHook, + builtBin, + 'web', + '--port', '0', + ], { + cwd: root, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-browser-open-no-call', + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1', + DSH_HOME: join(root, '.dsh'), + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22', + SSH_TTY: '', + VSCODE_IPC_HOOK_CLI: '/tmp/vscode-ipc', + }, + input: '', + timeout: 30_000, + killSignal: 'SIGKILL', + reject: false, + }) + const readyUrl = /dsh web: (http:\/\/[^\s]+)/u.exec(result.stdout)?.[1] + + expect({ + exitCode: result.exitCode, + opening: result.stdout.includes(openingMessage), + readyUrl: readyUrl === undefined ? undefined : normalizeLocalUrl(readyUrl), + opened: result.stdout.includes('dsh browser-open: '), + stderr: result.stderr, + }).toMatchInlineSnapshot(` + { + "exitCode": 0, + "opened": false, + "opening": false, + "readyUrl": "http://127.0.0.1:{{port}}", + "stderr": "", + } + `) + }) + + it('rejects a project browser command before starting the Web app', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-env-snapshot-')) + tempRoots.push(root) + writeFileSync(join(root, '.env'), 'BROWSER=./project-browser\n') + const result = await execa(process.execPath, [ + '--import', openerHook, + builtBin, + 'web', + '--port', '0', + ], { + cwd: root, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-browser-open-no-call', + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_HOME: join(root, '.dsh'), + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + SSH_CONNECTION: '', + SSH_TTY: '', + }, + input: '', + timeout: 30_000, + killSignal: 'SIGKILL', + reject: false, + }) + + const diagnostic = result.stderr.split(/\r?\n/u) + .find(line => line.startsWith('Error: dsh: ')) + ?.replace(/^Error: dsh: .*[/\\]\.env/u, 'dsh: {{root}}/.env') + + expect({ + diagnostic, + exitCode: result.exitCode, + opening: result.stdout.includes(openingMessage), + opened: result.stdout.includes('dsh browser-open: '), + ready: result.stdout.includes('dsh web: '), + }).toMatchInlineSnapshot(` + { + "diagnostic": "dsh: {{root}}/.env sets "BROWSER", which only the launching environment may set (it decides how this process starts, where its code and instructions load from, or how it reaches the network); export BROWSER instead of putting it in a .env file", + "exitCode": 1, + "opened": false, + "opening": false, + "ready": false, + } + `) + }) +}) diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 5898314e65..ce37022cdf 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -122,7 +122,7 @@ describe('shipped agent presets gate both shell tools by platform', () => { } }) - it('minimal mounts no shell tool row at all (its shell is the PTY stack)', () => { + it('minimal mounts no shell tool row and gates its persistent shell stack by platform', () => { const entries: unknown = yaml.load( readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'), { schema: entryListSchema }, @@ -133,5 +133,26 @@ describe('shipped agent presets gate both shell tools by platform', () => { typeof entry === 'object' && entry !== null && (entry as Record).id === id )), `${id} must be absent from minimal`).toBe(false) } + const group = entries.find((entry): entry is Record => ( + typeof entry === 'object' && entry !== null && (entry as Record).id === 'persistent-shell' + )) + if (group === undefined) throw new TypeError('minimal preset must mount persistent-shell') + const rows = group.config as unknown[] + if (!Array.isArray(rows)) throw new TypeError('persistent-shell must carry a row list') + const byId = new Map(rows + .filter((entry): entry is Record => typeof entry === 'object' && entry !== null) + .map(entry => [entry.id, entry])) + // The bash stack (terminal-bash + persistent-bash) mounts on POSIX only; the + // pwsh twin (terminal-bash with shellDialect pwsh + persistent-pwsh) mounts on + // win32 only — exactly one persistent shell per host. + for (const id of ['terminal-bash', 'persistent-bash']) { + expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(true) + expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(false) + } + for (const id of ['terminal-pwsh', 'persistent-pwsh']) { + expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(false) + expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(true) + } + expect(byId.get('terminal-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' }) }) }) diff --git a/apps/web/index.html b/apps/web/index.html index 1ce5ff35ff..75c4e5bac6 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,7 +5,7 @@ - DeepSeek Harness + DSH Local Build
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 3d7bb3c249..0e48e08ec0 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -10,17 +10,40 @@ // benches over src). This smoke additionally pins the resident interaction // fixture's cross-plugin projection because only the built connection/runtime/ // workspace graph can prove that transport-to-row path end to end. +import { resolve } from 'node:path' import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { expect, it } from 'vitest' import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' installAssembledBootEnv() +const buildEnvironmentModulePath = '../../../scripts/client-build-environment.ts' +const buildEnvironmentModule: unknown = await import(buildEnvironmentModulePath) +if (typeof buildEnvironmentModule !== 'object' || buildEnvironmentModule === null) { + throw new TypeError('client build environment module must be an object') +} +const readClientBuildRecord: unknown = Reflect.get(buildEnvironmentModule, 'readClientBuildRecord') +if (!isBuildRecordReader(readClientBuildRecord)) { + throw new TypeError('client build environment module must export readClientBuildRecord') +} +const record: unknown = readClientBuildRecord(resolve(import.meta.dirname, '../../..')) +if (typeof record !== 'object' || record === null) throw new TypeError('client build record must be an object') +const clientBuildEnvironment: unknown = Reflect.get(record, 'environment') +if (typeof clientBuildEnvironment !== 'object' || clientBuildEnvironment === null) { + throw new TypeError('client build record environment must be an object') +} + +function isBuildRecordReader(value: unknown): value is (root: string) => unknown { + return typeof value === 'function' +} + it('boots the built plugin graph and renders a fixture session end to end', async () => { mountAssembledApp() // The sidebar renders from the boot graph: every inject layer activated. const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + expect(document.querySelector('svg[viewBox="26 0 156 24"]')).not.toBeNull() + expect(screen.queryByText('DSH Local Build')).toBeNull() // The compact layout dropped group session counts; the fixture workspace // group row renders immediately with its sessions beneath it. const fixtureGroup = (await within(tree).findAllByText('fixture')) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index e15f339d25..0f0418c5d1 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -1,6 +1,6 @@ /** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */ -import { existsSync } from 'node:fs' +import { existsSync, globSync } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,6 +10,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { readClientBuildRecord } from '../../../scripts/client-build-environment.ts' import { REPO_ROOT } from './support.ts' function spawnSpec(argv: readonly string[], cwd: string, env?: Record): SubprocessSpawnSpec { @@ -70,11 +71,13 @@ async function stopTree(child: SubprocessHandle): Promise { it('hot-reloads a real client-plugin source edit without refreshing the page', async () => { const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-')) const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts') - const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js') const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js') if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first') + const clientBuildEnvironment = readClientBuildRecord(REPO_ROOT).environment + const clientBundlePaths = globSync('packages/*/*/lib/client.js{,.map}', { cwd: REPO_ROOT }) + .map(path => join(REPO_ROOT, path)) + const originalClientBundles = await Promise.all(clientBundlePaths.map(async path => [path, await readFile(path)] as const)) const originalSource = await readFile(sourcePath) - const originalBundle = await readFile(bundlePath) const oldText = 'Into the Unknown' const sourceNeedle = "'hero.headline': 'Into the Unknown'" const newText = `HMR UPDATED ${'x'.repeat(80)}` @@ -89,10 +92,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a const failures: unknown[] = [] try { subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime) - watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT)) + watcher = subprocessCtx.subprocess.spawn(spawnSpec( + ['pnpm', 'run', 'dev:web'], + REPO_ROOT, + { ...clientBuildEnvironment }, + )) await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web') host = subprocessCtx.subprocess.spawn(spawnSpec( - [process.execPath, binPath, 'web', '--port', '0'], + [process.execPath, binPath, 'web', '--no-open', '--port', '0'], world, { DEEPSEEK_API_KEY: 'keyless-hmr-no-call', @@ -122,7 +129,9 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a } finally { await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error)) if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error)) - await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error)) + await Promise.all(originalClientBundles.map(async ([path, content]) => { + await writeFile(path, content).catch((error: unknown) => failures.push(error)) + })) if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error)) await browser?.close().catch((error: unknown) => failures.push(error)) await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error)) diff --git a/apps/web/tests/home-path-tilde.snapshot.ts b/apps/web/tests/home-path-tilde.snapshot.ts new file mode 100644 index 0000000000..059a0ac003 --- /dev/null +++ b/apps/web/tests/home-path-tilde.snapshot.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +// Assembled POSIX home-path display: the fixture Host home is `/home/fixture` +// and a second Workspace lives under it. The sidebar hover card must show +// `~/Documents/project` while copy still writes the full path. +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts' + +const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt') + +installAssembledBootEnv() + +describe('assembled POSIX home-path display', () => { + it('shows the home-descendant Workspace path as ~ and copies the full path', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const group = (await within(tree).findAllByText('project')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (group == null) throw new Error('home-descendant Workspace group missing') + + fireEvent.pointerEnter(group.parentElement as HTMLElement) + const hoverPath = await waitFor(() => { + const found = screen.getByText('~/Documents/project') + expect(found).toBeTruthy() + return found + }, { timeout: 2_000 }) + expect(screen.queryByText('/home/fixture/Documents/project')).toBeNull() + const copy = screen.getByRole('button', { name: 'Copy: /home/fixture/Documents/project' }) + + const shape = [ + `hover=${hoverPath.textContent}`, + `copy=${copy.getAttribute('aria-label')}`, + ].join('\n') + '\n' + if (REFRESHING_GOLDEN) { + mkdirSync(dirname(EXPECTED), { recursive: true }) + writeFileSync(EXPECTED, shape) + } + await expect(shape).toMatchFileSnapshot(EXPECTED) + act(() => { fireEvent.pointerLeave(group.parentElement as HTMLElement) }) + }) +}) diff --git a/apps/web/tests/message-feedback-layout.e2e.ts b/apps/web/tests/message-feedback-layout.e2e.ts new file mode 100644 index 0000000000..89f20eee79 --- /dev/null +++ b/apps/web/tests/message-feedback-layout.e2e.ts @@ -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 { + return page.evaluate(({ viewportWidth, open }) => { + const rated = document.querySelector('button[aria-label="Remove rating"]') + if (rated === null) throw new Error('no rated feedback control in the DOM') + const row = rated.parentElement?.closest('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('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('[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('[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 + + 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 { + 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 => { + 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 | undefined + const sweep = (): Promise => { + 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([]) + }) +}) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index a0b34e53b9..1e2a0953b2 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' import { chromium } from 'playwright' import { strFromU8, unzipSync } from 'fflate' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -415,8 +415,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // Read summaries are host-open file links; they also must not open details. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) - await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { ok: true, value: { opened: true as const } }, + })) + try { + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + } finally { + openPath.mockRestore() + } }, 60_000) it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => { diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index ad9bc0903f..407a53dd22 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -224,7 +224,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup expect(document).toContain('name: Private Preview') expect(document).toContain('contextWindow: 131072') expect(document).toContain('maxTokens: 64000') - expect(document).not.toContain('id: deepseek-v4-flash') + expect(document).not.toMatch(/^\s*- id: deepseek-v4-flash$/m) await page.keyboard.press('Escape') // A connected Workspace is what puts a live composer — and its model diff --git a/apps/web/tests/rail-search-expand.e2e.ts b/apps/web/tests/rail-search-expand.e2e.ts new file mode 100644 index 0000000000..74f7cb12c9 --- /dev/null +++ b/apps/web/tests/rail-search-expand.e2e.ts @@ -0,0 +1,68 @@ +// Web e2e scenario: the collapsed rail's search control in the real event +// order. The rail click flips the sidebar wide and mounts WorkspaceBrowser's +// outside-click dismissal listener during its own React dispatch; the same +// click then keeps bubbling to document with the unmounted rail button as its +// target — outside searchRoot. The package-level jsdom test cannot replay +// that continuation (fireEvent does not re-bubble through listeners mounted +// mid-dispatch), so the guard that keeps the gesture alive +// (.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md) +// is pinned here, in the assembled application under a real browser click. +// +// Zero model calls: collapsing the sidebar and expanding the search are pure +// client layout gestures; the scenario needs no session content at all. +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +/** WorkspaceBrowser's rail-search focus delay (EXPAND_SLIDE_MS) plus flush headroom. */ +const FOCUS_SETTLE_MS = 600 + +describe('web e2e: rail search click survives its own document-level bubble', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('expands the search and lands focus in the input from one rail click', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-rail-search-expand')) + await page.getByRole('button', { name: 'Collapse sidebar' }).click() + const railSearch = page.getByRole('button', { name: 'Search sessions' }) + // The wide chrome stays mounted through the 150ms collapse crossfade; the + // rail control (no aria-expanded) replaces it at settle. + await expect.poll(async () => railSearch.getAttribute('aria-expanded'), { timeout: 10_000 }).toBeNull() + + // The one real click under test: it must expand the sidebar AND leave the + // search expanded after its own bubble reaches document. + await railSearch.click() + + const wideSearch = page.getByRole('button', { name: 'Search sessions' }) + await expect.poll(async () => wideSearch.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true') + const input = page.getByPlaceholder('Search sessions...') + await expect.poll( + async () => input.evaluate(el => document.activeElement === el), + { timeout: FOCUS_SETTLE_MS + 10_000 }, + ).toBe(true) + + // The guard ends with the gesture: a genuine outside click on an empty + // query dismisses the expanded search as before. + await page.getByRole('button', { name: 'New session' }).first().click() + await expect.poll(async () => wideSearch.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts new file mode 100644 index 0000000000..39e0707255 --- /dev/null +++ b/apps/web/tests/reference-composer.e2e.ts @@ -0,0 +1,189 @@ +// Web e2e scenario: the shipped composition discovers local files and cold +// sessions through the real Host, groups both domains in the shared @ menu, +// and projects each pick as a complete inline range without issuing a model call. +import { writeFile } 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 { createUserMessage } from '@deepseek-ai/dsh-llm' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-reference/types' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/reference-composer', import.meta.url)) +const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') +const ORDER_EXPECTED = join(SNAPSHOT_DIR, 'order.expected.md') +const MODE = webSnapshotMode() +const SOURCE_SESSION_ID = 'reference-source-session' +const TARGET_SESSION_ID = 'reference-order-target-session' + +/** Build one closed source session with a stable title for reference discovery. */ +function sourceSessionFixture(): string { + const session = Session.create(SessionId(SOURCE_SESSION_ID)) + session.append('turn/start', { + turn: 1, + }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Research context for the reference menu.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Research notes', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +/** Build one target log with the direct message durably before its recalled context. */ +function targetSessionFixture(): string { + const session = Session.create(SessionId(TARGET_SESSION_ID)) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: '@Research notes what changed?' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: '## Referenced sessions\n\nsnapshot' }], + source: { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [{ + sessionId: SOURCE_SESSION_ID, + label: 'Research notes', + capturedThroughSeq: 4, + compacted: false, + originalMessages: 2, + retainedMessages: 2, + omittedMessages: 0, + omittedBytes: 0, + truncated: false, + inputIndex: 0, + }], + }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Reference order target', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe.skipIf(MODE === 'record')('web e2e: file and session references through the real host', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, sourceSessionFixture(), SOURCE_SESSION_ID) + await seedSession(scaffold, targetSessionFixture(), TARGET_SESSION_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await writeFile(join(scaffold.workspaceCwd, 'workspace', 'reference.txt'), 'reference fixture\n') + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('groups both sources and projects files and sessions as structured inline icon labels', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-composer')) + const input = page.locator('textarea').first() + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + + await input.fill('@') + await expect.poll(() => menu.getByRole('option').count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(2) + const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('Files & folders') + expect(snapshot).toContain('Session conversations') + expect(snapshot).not.toContain('text: reference Files & folders') + expect(snapshot).toContain('File \u00b7 reference.txt') + expect(snapshot).toContain('Session \u00b7 Research notes') + expect(snapshot).not.toContain('text: Subagents') + + await input.fill('@reference') + await menu.getByRole('option', { name: /File \u00b7 reference\.txt/ }).click() + const fileReference = page.locator('[data-reference-appearance="file"]') + await expect.poll(() => fileReference.textContent()).toBe('@reference.txt') + await expect.poll(() => fileReference.locator('svg').count()).toBe(1) + await expect.poll(() => input.inputValue()).toBe('@reference.txt ') + + await input.fill('@Research') + await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click() + const sessionReference = page.locator('[data-reference-appearance="session"]') + await expect.poll(() => sessionReference.textContent()).toBe('@Research notes') + await expect.poll(() => sessionReference.locator('svg').count()).toBe(1) + await expect.poll(() => input.inputValue()).toBe('@Research notes ') + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('renders the durable direct-message then recall order', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order')) + const group = page.getByRole('treeitem', { name: /Ungrouped/ }) + await group.waitFor({ timeout: 15_000 }) + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + const target = page.getByRole('treeitem').filter({ hasText: /^dsh-web-e2e-ws-/ }).first() + await target.waitFor({ timeout: 15_000 }) + await target.click() + await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).waitFor({ timeout: 15_000 }) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(TARGET_SESSION_ID).join('{{targetId}}') + await compareOrRefreshGolden(ORDER_EXPECTED, snapshot, MODE) + expect(snapshot.indexOf('Research notes what changed?')).toBeLessThan(snapshot.indexOf('Session recall Research notes')) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md', 'order.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index ac2ca402e6..ac3a0292ec 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -458,10 +458,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { await fileLink.waitFor({ timeout: 10_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') - await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { ok: true, value: { opened: true as const } }, + })) + try { + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + } finally { + openPath.mockRestore() + } // Path label survives from the recorded args (a.txt). await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) }) + it.skipIf(MODE === 'record')('a Host open refusal keeps the reason and retries the same path', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-file-open-failure')) + const fileLink = page.locator('[data-variant="read"] button').first() + await fileLink.waitFor({ timeout: 10_000 }) + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { + ok: false as const, + error: { code: 'internal', message: 'xdg-open is not available', details: {} }, + }, + })) + try { + await fileLink.click() + const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) + await dialog.waitFor({ timeout: 5_000 }) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FILE_OPEN_FAILURE_EXPECTED, snapshot, MODE) + await expect.poll(() => dialog.innerText(), { timeout: 5_000 }) + .toContain('path open failed: xdg-open is not available') + await page.getByRole('button', { name: 'Retry' }).click() + await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2) + expect(openPath.mock.calls[0]![0].payload).toEqual(openPath.mock.calls[1]![0].payload) + await page.getByRole('button', { name: 'Cancel' }).click() + await expect.poll(() => page.getByRole('dialog', { name: 'Couldn’t open file' }).count(), { + timeout: 5_000, + }).toBe(0) + } finally { + // Shared page: a leftover mask blocks later cases even when this one fails. + if (await page.getByRole('dialog', { name: 'Couldn’t open file' }).count() > 0) { + await page.keyboard.press('Escape') + } + openPath.mockRestore() + } + }) + it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction')) const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ }) @@ -505,6 +551,6 @@ describe('web e2e: seeded history renders through cold resume', () => { // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'seed.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', 'seed.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 7388453567..3ac6706a6f 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -160,7 +160,7 @@ describe('dsh web keyless CLI smoke', () => { const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href const child = spawn( process.execPath, - ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'], { cwd: sessionsDir, env: { @@ -226,7 +226,7 @@ describe('dsh web keyless CLI smoke', () => { const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href const child = spawn( process.execPath, - ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'], { cwd: workspace, env: { @@ -339,7 +339,7 @@ describe('dsh web keyless CLI smoke', () => { const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href const child = spawn( process.execPath, - ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'], { cwd: workspace, env: { @@ -421,7 +421,7 @@ describe('dsh web keyless CLI smoke', () => { const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href const child = spawn( process.execPath, - ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'], { cwd: workspace, env: { @@ -491,6 +491,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke // Pin the in-browser picker: the shipped `-auto` row would resolve to // the native OS chooser on this bind, and no page can drive that. '--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + '--no-open', '--port', String(port), ], { @@ -540,6 +541,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await connectFreshWorkspace(page, sessionsDir) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) + const productTitle = await page.title() await screen(page, '02-empty-state') const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.` await input.fill(prompt) @@ -549,8 +551,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 }) expect(pageErrors).toEqual([]) await page.waitForFunction( - () => document.title !== 'DeepSeek Harness' && document.title.endsWith(' — DeepSeek Harness'), - undefined, + expected => document.title !== expected && document.title.endsWith(` — ${expected}`), + productTitle, { timeout: 15_000 }, ) await expect.poll(async () => (await rpc<{ items: { sessionId: string }[] }>(baseUrl, 'session.list', {})).items.length, { @@ -561,8 +563,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke if (sessionId === undefined) throw new Error('created Web session was not listed') const durableTitle = await waitForProviderTitle(baseUrl, sessionId) await page.waitForFunction( - expected => document.title === `${expected} — DeepSeek Harness`, - durableTitle, + ({ expected, product }) => document.title === `${expected} — ${product}`, + { expected: durableTitle, product: productTitle }, { timeout: 15_000 }, ) const sessionTree = page.getByRole('tree', { name: 'Sessions' }) diff --git a/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt b/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt new file mode 100644 index 0000000000..23ed9fb622 --- /dev/null +++ b/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt @@ -0,0 +1,2 @@ +hover=~/Documents/project +copy=Copy: /home/fixture/Documents/project diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index bafa739a58..85a3661eb8 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -20,9 +20,13 @@ - text: Stopped - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} - textbox "Message the agent" - button "Commands": - img @@ -31,4 +35,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} diff --git a/apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md b/apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md new file mode 100644 index 0000000000..7780a8057a --- /dev/null +++ b/apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md @@ -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 | diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 43c9665ac1..09f677bcc8 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -20,9 +20,13 @@ - text: Stopped - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} - button "2 queued messages" [expanded] - list: - listitem: @@ -50,4 +54,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} diff --git a/apps/web/tests/snapshots/reference-composer/menu.expected.md b/apps/web/tests/snapshots/reference-composer/menu.expected.md new file mode 100644 index 0000000000..b5fccf69f5 --- /dev/null +++ b/apps/web/tests/snapshots/reference-composer/menu.expected.md @@ -0,0 +1,6 @@ +- listbox "Trigger suggestions": + - text: Files & folders + - option "File · reference.txt reference.txt" [selected] + - text: Session conversations + - option "Session · Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}" + - option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}" diff --git a/apps/web/tests/snapshots/reference-composer/order.expected.md b/apps/web/tests/snapshots/reference-composer/order.expected.md new file mode 100644 index 0000000000..75543314b2 --- /dev/null +++ b/apps/web/tests/snapshots/reference-composer/order.expected.md @@ -0,0 +1,23 @@ +- banner: + - navigation "Session hierarchy": + - button "Reference order target" [disabled] + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Research notes what changed? Referenced session · Research notes {{clock}} +- button "Copy": + - img +- button "Session recall Research notes": + - img + - text: Session recall Research notes +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md new file mode 100644 index 0000000000..fece247a7a --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md @@ -0,0 +1,7 @@ +- dialog "Couldn’t open file": + - heading "Couldn’t open file" [level=2] + - button "Close": + - img + - paragraph: "path open failed: xdg-open is not available" + - button "Cancel" + - button "Retry" diff --git a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md index 01e67a351f..cbac0d4880 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md @@ -30,6 +30,10 @@ - button "Copy": - img - tooltip "Copy" +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index 23771924bc..b39e720600 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -69,8 +69,8 @@ describe('web e2e: startup auto-selection', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree')) await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 }) const headline = page.getByText('Into the Unknown', { exact: true }) - const fish = headline.locator('xpath=preceding-sibling::span[1]/*[name()="svg"]') - const fishHitbox = fish.locator('..') + const fishHitbox = headline.locator('xpath=preceding-sibling::span[1]') + const fish = fishHitbox.locator('svg') expect(await fish.evaluate(node => getComputedStyle(node).color)) .toBe(await headline.evaluate(node => getComputedStyle(node).color)) await fishHitbox.hover() diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 82baea4aa8..43fccd65b5 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -106,9 +106,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // this exact occurrence into the current turn's steering outbox. await input.fill(STEER) await input.press('Enter') - const queued = page.getByText(STEER, { exact: true }) - await queued.waitFor({ timeout: 10_000 }) const queuedRow = page.getByRole('listitem').filter({ hasText: STEER }) + await queuedRow.waitFor({ timeout: 10_000 }) const steerButton = queuedRow.getByRole('button', { name: 'Steer queued message' }) await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true) await steerButton.click({ timeout: 10_000 }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 80908322ed..111aefe63c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -53,6 +53,7 @@ "tests/cold-blank-session.e2e.ts", "tests/stats-paged-history.e2e.ts", "tests/sidebar-scrollbar.e2e.ts", + "tests/rail-search-expand.e2e.ts", "tests/conversation-column-overflow.e2e.ts", "tests/code-mode-round.e2e.ts", "tests/composer-draft-scroll.e2e.ts", @@ -60,7 +61,9 @@ "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/reference-composer.e2e.ts", "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", "tests/markdown-inline-code-links.e2e.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 33c4a048ae..cd27136cb7 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,11 +2,29 @@ import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' import type { Plugin } from 'vite' import react from '@vitejs/plugin-react' +import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' + 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. ' + 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.' +const DEFAULT_CLIENT_TITLE = 'DSH Local Build' + +/** Escape build-time text before placing it in the HTML title element. */ +function escapeHtmlText(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>') +} + +/** Project the public build title into the initial HTML document. */ +function clientDocumentTitle(): Plugin { + const title = escapeHtmlText(process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE) + return { + name: 'dsh-client-document-title', + transformIndexHtml(html) { + return html.replace('DSH Local Build', `${title}`) + }, + } +} /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ function rejectStandaloneServe(): Plugin { @@ -90,7 +108,7 @@ function npmPackageOf(id: string): string | undefined { } export default defineConfig({ - plugins: [rejectStandaloneServe(), react()], + plugins: [rejectStandaloneServe(), clientDocumentTitle(), react()], build: { sourcemap: true, rollupOptions: { @@ -146,6 +164,7 @@ export default defineConfig({ ], }, define: { + ...clientBuildEnvironmentDefines(process.env), // vendored loader internal.ts: fromInternal() probes the Node major — // "0.0.0" takes neither branch, returning undefined (exactly the empty // internal slot the shell boot fills with the client module loader). diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index e12756c849..12740a7ebd 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 710c399510b6b123123e0a9586d86bfc3a96dff9 -capability-seams.zh.md: e0559d464ba1ecd2160eaba40c24bfd829e4a0d6 +capability-seams.md: 23fc498cc280518489aadbe752a1702d43f17972 +capability-seams.zh.md: 0a8822bc8d1f0296ac6698b59fda60666faaf54a diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 710c399510..23fc498cc2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -66,6 +66,9 @@ flowchart LR svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] + pkg_file_reference["file-reference"] + svc_fileReferences["ctx.fileReferences
File reference discovery"] + pkg_file_reference_local["file-reference-local"] svc_sessionReferenceResolver["ctx.sessionReferenceResolver
Cross-session snapshot preparation"] pkg_session_title["session-title"] svc_sessionTitle["ctx.sessionTitle
Log-backed session titles"] @@ -222,6 +225,8 @@ flowchart LR pkg_directory_picker_browse --> svc_directoryPicker pkg_directory_picker_native --> svc_directoryPicker pkg_e2b --> svc_e2b + pkg_file_reference --> svc_fileReferences + pkg_file_reference_local --> svc_fileReferences pkg_fs --> svc_fs pkg_fs_e2b --> svc_fs pkg_fs_local --> svc_fs @@ -433,6 +438,7 @@ flowchart LR | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry. | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | The interface returns path-only completion candidates within the addressed Agent cwd through its unary Remote contract; providers own namespace access and ranking without reading file contents. | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index e0559d464b..0a8822bc8d 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -68,6 +68,9 @@ flowchart LR svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] + pkg_file_reference["file-reference"] + svc_fileReferences["ctx.fileReferences
File reference discovery"] + pkg_file_reference_local["file-reference-local"] svc_sessionReferenceResolver["ctx.sessionReferenceResolver
Cross-session snapshot preparation"] pkg_session_title["session-title"] svc_sessionTitle["ctx.sessionTitle
Log-backed session titles"] @@ -224,6 +227,8 @@ flowchart LR pkg_directory_picker_browse --> svc_directoryPicker pkg_directory_picker_native --> svc_directoryPicker pkg_e2b --> svc_e2b + pkg_file_reference --> svc_fileReferences + pkg_file_reference_local --> svc_fileReferences pkg_fs --> svc_fs pkg_fs_e2b --> svc_fs pkg_fs_local --> svc_fs @@ -435,6 +440,7 @@ flowchart LR | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | 该接口通过其一元 Remote 契约返回指定 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问和排序,但不会读取文件内容。 | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 98e63f18c1..e8ea42a82a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: faddce16d1079b43f5fcb443f4a3e24c63621f54 -config-catalog.zh.md: 2fd4c56c865b13543fa67a5ebf35e545f19b33c4 +config-catalog.md: 6bd408a7ff350a182101fcc0c21269d0169cb7bc +config-catalog.zh.md: 5914e492ab44eb59f5265d0618317a650df3481f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index faddce16d1..6bd408a7ff 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -587,6 +587,26 @@ export interface Config { Source: [`packages/e2b/e2b/src/index.ts:43`](../packages/e2b/e2b/src/index.ts) + + +## `@deepseek-ai/dsh-file-reference-local` + +Requires: `agents` + +```ts config-catalog +/** Local file-reference discovery configuration. */ +export interface Config { + /** Maximum ranked candidates returned for one query. */ + maxResults?: number + /** Maximum indexed files and directories per agent workspace. */ + maxEntries?: number + /** Directory basenames never traversed or offered. */ + excludedDirectories?: string[] +} +``` + +Source: [`packages/context/file-reference-local/src/index.ts:35`](../packages/context/file-reference-local/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` @@ -870,6 +890,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Maximum accumulated base64 image payload per request (default 20 MiB). */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -886,12 +908,14 @@ export interface DeepSeekCatalogModel { contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number + /** Accepted request modalities; omission is text-only. */ + inputModalities?: ModelModality[] } ``` -Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts) @@ -940,10 +964,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -1031,7 +1056,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -1045,19 +1070,80 @@ export interface PiAiModelProfile { export type PiAiModelOverride = Omit /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * default) or per model (winning over the route). Only the switches pi-ai's - * reasoning dispatch reads are offered; the rest of pi-ai's compat surface - * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols define their reasoning - * fields in the protocol itself — so resolution rejects a model-level switch - * anywhere else, while a route-level default skips past models it cannot fit. + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. "The three Responses + * protocols" below means `openai-responses`, `azure-openai-responses`, and + * `openai-codex-responses`, which pi-ai gives one shared compat type, so a + * switch settable on one is settable on all three. */ export interface PiAiCompatProfile { - /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ - thinkingFormat?: PiAiThinkingFormat - /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions` and the three Responses protocols. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: NonNullable + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** + * Kwargs sent as `chat_template_kwargs`, which pi-ai reads only under the + * two `chat-template` thinking formats; `openai-completions`. Nothing checks + * that pairing: the format in force may come from the installed catalog + * entry or from pi-ai's own baseURL detection, neither of which resolution + * can read, so kwargs set beside another format are sent nowhere. + */ + chatTemplateKwargs?: NonNullable + /** + * Whether the endpoint accepts `strict` in tool definitions; + * `openai-completions`, the three Responses protocols, `bedrock-converse-stream`. + */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: NonNullable + /** + * Whether the endpoint accepts long prompt-cache retention; + * `openai-completions`, the three Responses protocols, `anthropic-messages`. + */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean } /** One request modality a pi-ai model may accept. */ @@ -1074,21 +1160,12 @@ export type PiAiModality = Model['input'][number] export type PiAiReasoningEfforts = Partial> /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude - -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' +export type PiAiThinkingFormat = NonNullable ``` Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:192`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:201`](../packages/llm/llm-pi-ai/src/config.ts) @@ -1590,7 +1667,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:66`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:63`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1601,41 +1678,23 @@ Requires: `sessions` ```ts config-catalog /** Plugin configuration. */ export interface Config { - /** - * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests). On filesystems with POSIX modes, - * missing directories and databases are created owner-only; existing path - * modes are preserved. Filesystem setup errors other than an existing database - * fail initialization. The backend does not protect confidentiality or - * integrity when another principal can replace the database entry in its - * parent directory. - */ + /** SQLite database path, or `:memory:` for an in-process database. */ path: string - /** - * SQLite `journal_mode` pragma. `wal` (the default) is the recorded - * durability model; pick a rollback-journal mode (`delete`/`truncate`/ - * `persist`) on filesystems where WAL's shared-memory files do not work - * (network mounts). See {@link JournalMode}. - */ + /** Durable SQLite journal mode; defaults to `wal`. */ journalMode?: JournalMode + /** Maximum wait for another SQLite connection's lock; defaults to 5,000 ms. */ + busyTimeoutMs?: number /** Maximum cold Session preparations retained for history-to-resume reuse. */ preparedSessionCacheSize?: number /** Fixed live-event coalescing window; not a backend completion deadline. */ writeBatchMaxDelayMs?: number } -/** - * Journal modes the backend will run under. `wal` is the default and the - * durability model the persistence ADR records; the rollback-journal modes - * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's - * shared-memory files do not work (network mounts). `memory`/`off` are - * excluded: dropping journal durability silently contradicts what this - * backend promises. - */ +/** Durable journal modes accepted by the backend. */ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session/session-persistence-sqlite/src/index.ts:79`](../packages/session/session-persistence-sqlite/src/index.ts) +Source: [`packages/session/session-persistence-sqlite/src/index.ts:36`](../packages/session/session-persistence-sqlite/src/index.ts) @@ -2114,7 +2173,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) @@ -2145,7 +2204,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) @@ -2310,9 +2369,11 @@ Requires: `terminals` · `sandboxPolicy` · `subprocess` export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -2340,9 +2401,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -Source: [`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal/terminal-bash/src/config.ts) +Source: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) @@ -2425,7 +2489,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:400`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:432`](../packages/shell/tool-bash-persistent/src/index.ts) @@ -2570,6 +2634,28 @@ export interface Config { Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) + + +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +Requires: `tools` · `terminals` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` @@ -2948,6 +3034,8 @@ Requires: `webServer` ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { + /** Permit default-browser handoff after the Loader tree settles; an SSH launch suppresses it. */ + openBrowser: boolean /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** @@ -2962,7 +3050,7 @@ export interface Config { } ``` -Source: [`packages/bundle/web-app/src/index.ts:38`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts) @@ -3108,6 +3196,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-brand-official` ([`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts)) - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) @@ -3122,6 +3211,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-model-selection` ([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission-presets` ([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-renderer` ([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) @@ -3176,6 +3266,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — abstract `CredentialProvider` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) +- `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-jobs` — abstract `JobRegistry` ([`packages/jobs/jobs/src/index.ts`](../packages/jobs/jobs/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 2fd4c56c86..5914e492ab 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -420,7 +420,7 @@ export interface ConnectionConfig { ## `@deepseek-ai/dsh-client-hmr` -需要:`clientModuleHost` · `webServer` +需要:`clientModules` · `webServer` ```ts config-catalog /** Plugin config, validated by the same-named schemastery schema. */ @@ -589,6 +589,26 @@ export interface Config { 来源:[`packages/e2b/e2b/src/index.ts:43`](../packages/e2b/e2b/src/index.ts) + + +## `@deepseek-ai/dsh-file-reference-local` + +需要:`agents` + +```ts config-catalog +/** Local file-reference discovery configuration. */ +export interface Config { + /** Maximum ranked candidates returned for one query. */ + maxResults?: number + /** Maximum indexed files and directories per agent workspace. */ + maxEntries?: number + /** Directory basenames never traversed or offered. */ + excludedDirectories?: string[] +} +``` + +来源:[`packages/context/file-reference-local/src/index.ts:35`](../packages/context/file-reference-local/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` @@ -664,7 +684,7 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-claude-code` -需要:`bash` +需要:`shell` ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -702,7 +722,7 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-codex` -需要:`bash` +需要:`shell` ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -729,7 +749,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-apiproxy` -需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` +需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry` ```ts config-catalog /** Gateway plugin configuration. */ @@ -872,6 +892,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Maximum accumulated base64 image payload per request (default 20 MiB). */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -888,12 +910,14 @@ export interface DeepSeekCatalogModel { contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number + /** Accepted request modalities; omission is text-only. */ + inputModalities?: ModelModality[] } ``` -依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts) @@ -942,10 +966,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -1033,7 +1058,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -1047,19 +1072,80 @@ export interface PiAiModelProfile { export type PiAiModelOverride = Omit /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * default) or per model (winning over the route). Only the switches pi-ai's - * reasoning dispatch reads are offered; the rest of pi-ai's compat surface - * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols define their reasoning - * fields in the protocol itself — so resolution rejects a model-level switch - * anywhere else, while a route-level default skips past models it cannot fit. + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. "The three Responses + * protocols" below means `openai-responses`, `azure-openai-responses`, and + * `openai-codex-responses`, which pi-ai gives one shared compat type, so a + * switch settable on one is settable on all three. */ export interface PiAiCompatProfile { - /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ - thinkingFormat?: PiAiThinkingFormat - /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions` and the three Responses protocols. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: NonNullable + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** + * Kwargs sent as `chat_template_kwargs`, which pi-ai reads only under the + * two `chat-template` thinking formats; `openai-completions`. Nothing checks + * that pairing: the format in force may come from the installed catalog + * entry or from pi-ai's own baseURL detection, neither of which resolution + * can read, so kwargs set beside another format are sent nowhere. + */ + chatTemplateKwargs?: NonNullable + /** + * Whether the endpoint accepts `strict` in tool definitions; + * `openai-completions`, the three Responses protocols, `bedrock-converse-stream`. + */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: NonNullable + /** + * Whether the endpoint accepts long prompt-cache retention; + * `openai-completions`, the three Responses protocols, `anthropic-messages`. + */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean } /** One request modality a pi-ai model may accept. */ @@ -1076,21 +1162,12 @@ export type PiAiModality = Model['input'][number] export type PiAiReasoningEfforts = Partial> /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude - -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' +export type PiAiThinkingFormat = NonNullable ``` -依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) +依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) -来源:[`packages/llm/llm-pi-ai/src/config.ts:192`](../packages/llm/llm-pi-ai/src/config.ts) +来源:[`packages/llm/llm-pi-ai/src/config.ts:201`](../packages/llm/llm-pi-ai/src/config.ts) @@ -1308,7 +1385,7 @@ export interface Config { ## `@deepseek-ai/dsh-permission-presets` -需要:`bash` · `approval` · `sessions` +需要:`shell` · `approval` · `sessions` ```ts config-catalog /** The {@link PermissionPresetService} config: preset table and composition default. */ @@ -1592,7 +1669,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -来源:[`packages/session/session-persistence-jsonl/src/index.ts:66`](../packages/session/session-persistence-jsonl/src/index.ts) +来源:[`packages/session/session-persistence-jsonl/src/index.ts:63`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1603,41 +1680,23 @@ export type JsonlCompression = 'zstd' | 'none' ```ts config-catalog /** Plugin configuration. */ export interface Config { - /** - * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests). On filesystems with POSIX modes, - * missing directories and databases are created owner-only; existing path - * modes are preserved. Filesystem setup errors other than an existing database - * fail initialization. The backend does not protect confidentiality or - * integrity when another principal can replace the database entry in its - * parent directory. - */ + /** SQLite database path, or `:memory:` for an in-process database. */ path: string - /** - * SQLite `journal_mode` pragma. `wal` (the default) is the recorded - * durability model; pick a rollback-journal mode (`delete`/`truncate`/ - * `persist`) on filesystems where WAL's shared-memory files do not work - * (network mounts). See {@link JournalMode}. - */ + /** Durable SQLite journal mode; defaults to `wal`. */ journalMode?: JournalMode + /** Maximum wait for another SQLite connection's lock; defaults to 5,000 ms. */ + busyTimeoutMs?: number /** Maximum cold Session preparations retained for history-to-resume reuse. */ preparedSessionCacheSize?: number /** Fixed live-event coalescing window; not a backend completion deadline. */ writeBatchMaxDelayMs?: number } -/** - * Journal modes the backend will run under. `wal` is the default and the - * durability model the persistence ADR records; the rollback-journal modes - * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's - * shared-memory files do not work (network mounts). `memory`/`off` are - * excluded: dropping journal durability silently contradicts what this - * backend promises. - */ +/** Durable journal modes accepted by the backend. */ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -来源:[`packages/session/session-persistence-sqlite/src/index.ts:79`](../packages/session/session-persistence-sqlite/src/index.ts) +来源:[`packages/session/session-persistence-sqlite/src/index.ts:36`](../packages/session/session-persistence-sqlite/src/index.ts) @@ -2116,7 +2175,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) @@ -2147,7 +2206,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) @@ -2306,16 +2365,18 @@ export interface Config { ## `@deepseek-ai/dsh-terminal-bash` -需要:`pty` · `sandboxPolicy` · `subprocess` +需要:`terminals` · `sandboxPolicy` · `subprocess` ```ts config-catalog /** Public plugin configuration. */ export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -2343,9 +2404,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -来源:[`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal/terminal-bash/src/config.ts) +来源:[`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) @@ -2396,7 +2460,7 @@ export type TokenMeterConfig = Record ## `@deepseek-ai/dsh-tool-bash` -需要:`tools` · `bash` · `systemPrompt` · `bashEnv` +需要:`tools` · `shell` · `systemPrompt` · `shellEnv` ```ts config-catalog /** Configuration for the bash tool. */ @@ -2412,7 +2476,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` -需要:`tools` · `pty` +需要:`tools` · `terminals` ```ts config-catalog /** Configuration for the persistent Bash tool. */ @@ -2428,7 +2492,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-bash-persistent/src/index.ts:400`](../packages/shell/tool-bash-persistent/src/index.ts) +来源:[`packages/shell/tool-bash-persistent/src/index.ts:432`](../packages/shell/tool-bash-persistent/src/index.ts) @@ -2507,7 +2571,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-jobs` -需要:`tools` · `tasks` · `systemPrompt` +需要:`tools` · `jobs` · `systemPrompt` ```ts config-catalog /** Configures bounded `job_output` waits and completion-notice delivery. */ @@ -2561,7 +2625,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` -需要:`tools` · `bash` · `systemPrompt` · `bashEnv` +需要:`tools` · `shell` · `systemPrompt` · `shellEnv` ```ts config-catalog /** Configuration for the pwsh tool. */ @@ -2573,11 +2637,33 @@ export interface Config { 来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) + + +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +需要:`tools` · `terminals` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` -需要:`tools` · `workflows` · `subagents` · `systemPrompt` +需要:`tools` · `workflowEngine` · `subagents` · `systemPrompt` ```ts config-catalog /** Deployment policy for the fixed Ralph workflow. */ @@ -2757,7 +2843,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-terminal` -需要:`pty` · `tools` · `systemPrompt` +需要:`terminals` · `tools` · `systemPrompt` ```ts config-catalog /** Model-facing terminal tool configuration. */ @@ -2825,7 +2911,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-workflow` -需要:`tools` · `workflows` · `systemPrompt` +需要:`tools` · `workflowEngine` · `systemPrompt` ```ts config-catalog /** Config: the model-facing tool name plus result rendering caps. */ @@ -2952,6 +3038,8 @@ export interface WebRuntimeConfig { ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { + /** Permit default-browser handoff after the Loader tree settles; an SSH launch suppresses it. */ + openBrowser: boolean /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** @@ -2966,7 +3054,7 @@ export interface Config { } ``` -来源:[`packages/bundle/web-app/src/index.ts:38`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts) @@ -3112,6 +3200,7 @@ export interface Config { - `@deepseek-ai/dsh-client-runtime`([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-brand-official`([`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts)) - `@deepseek-ai/dsh-client-ui-commands`([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis`([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) @@ -3126,6 +3215,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-model-selection`([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission-presets`([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-reference`([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-renderer`([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) @@ -3180,6 +3270,7 @@ export interface Config { - `@deepseek-ai/dsh-code-runtime` — 抽象 `CodeRuntime`([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — 抽象 `Credentials`([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) +- `@deepseek-ai/dsh-file-reference` — 抽象 `FileReferenceService`([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — 抽象 `FileSystem`([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — 抽象 `DirectoryPicker`([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-jobs` — 抽象 `JobRegistry`([`packages/jobs/jobs/src/index.ts`](../packages/jobs/jobs/src/index.ts)) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 6a67241ba0..3dd3b32c87 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 4ef2a195fdf525599ad48da32ee4bccfbd9a8c1d -development.zh.md: 66799906e22c3dfc164459ba9f4c97e3972ef94a +development.md: 904245f93546122e4e3a54e020b302a3bcd39d1a +development.zh.md: fe160763ffc80bda9bbc7317fb7b3b69df1b3f88 diff --git a/docs/development.md b/docs/development.md index 4ef2a195fd..904245f935 100644 --- a/docs/development.md +++ b/docs/development.md @@ -75,6 +75,8 @@ Both tsdown passes use the same complete workspace match. They neither scan buil Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision. +`pnpm run build` embeds the caller's exact `DSH_CLIENT_*` environment and uses no public client values when none are set. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build. Each successful complete build writes a gitignored record that binds those values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. + Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. Business services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order. diff --git a/docs/development.zh.md b/docs/development.zh.md index 66799906e2..fe160763ff 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -75,6 +75,8 @@ pnpm run build:web Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。 +`pnpm run build` 会内联调用方精确的 `DSH_CLIENT_*` 环境;未设置时不使用任何公开 client 值。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令。每次完整构建成功后都会写入一份被 gitignore 的记录,把这些值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。 + 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。 业务服务在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index d933130f59..dfd6001cf3 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: c906ee6329fac66e7391c266213dd150dd5b8e09 -event-producer-consumer.zh.md: 77bf401b7215bd263c0d84f04e0eabe6b28b7915 +event-producer-consumer.md: 2e4bec93f5b9f68ab885d50bab27e9a9a6036028 +event-producer-consumer.zh.md: e3f5602287d7b688a2191937871e1639dc271e65 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c906ee6329..2e4bec93f5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,13 +9,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`team`](../packages/experimental/team) | @@ -40,7 +40,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 77bf401b72..e3f5602287 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,13 +11,13 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`team`](../packages/experimental/team) | @@ -42,7 +42,7 @@ | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1b0e8fad9c..9dc9775839 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 0bd5f80534ba65e0d483bd04228cdac321e082bc -module-graph.zh.md: 6239520b7d819eb14c2b859afa44a0c987545200 +module-graph.md: 55fbda46e76db21b5132f56716d3c300a3e469a0 +module-graph.zh.md: cd205a3f6d8d8ec3ddc8b07cce0f8d3b3eac9779 diff --git a/docs/module-graph.md b/docs/module-graph.md index 0bd5f80534..55fbda46e7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -125,6 +125,7 @@ flowchart TD pkg_client_runtime["client-runtime"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_attachment["client-ui-attachment"] + pkg_client_ui_brand_official["client-ui-brand-official"] pkg_client_ui_commands["client-ui-commands"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -139,6 +140,7 @@ flowchart TD pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] + pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] @@ -170,6 +172,8 @@ flowchart TD end subgraph group_context["packages/context"] pkg_agent_instructions["agent-instructions"] + pkg_file_reference["file-reference"] + pkg_file_reference_local["file-reference-local"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -289,6 +293,7 @@ flowchart TD pkg_tool_bash["tool-bash"] pkg_tool_bash_persistent["tool-bash-persistent"] pkg_tool_pwsh["tool-pwsh"] + pkg_tool_pwsh_persistent["tool-pwsh-persistent"] end subgraph group_storage["packages/storage"] pkg_storage["storage"] @@ -407,6 +412,7 @@ flowchart TD pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_attachment pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment @@ -509,6 +515,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants + pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -562,6 +571,7 @@ flowchart TD pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_persistence_sqlite --> pkg_invariants + pkg_session_persistence_sqlite --> pkg_llm pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence pkg_session_projection_cache --> pkg_invariants @@ -833,6 +843,11 @@ flowchart TD pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session pkg_agent_instructions --> pkg_tools + pkg_file_reference_local --> pkg_agent + pkg_file_reference_local --> pkg_file_reference + pkg_file_reference_local --> pkg_invariants + pkg_file_reference_local --> pkg_system_prompt + pkg_file_reference_local --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction pkg_session_reference --> pkg_invariants @@ -840,6 +855,7 @@ flowchart TD pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_typert_protocol pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -927,6 +943,11 @@ flowchart TD pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools + pkg_tool_pwsh_persistent --> pkg_agent + pkg_tool_pwsh_persistent --> pkg_invariants + pkg_tool_pwsh_persistent --> pkg_terminal + pkg_tool_pwsh_persistent --> pkg_timeout + pkg_tool_pwsh_persistent --> pkg_tools pkg_tool_terminal --> pkg_agent pkg_tool_terminal --> pkg_invariants pkg_tool_terminal --> pkg_jobs @@ -1160,6 +1181,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants @@ -1167,6 +1189,7 @@ flowchart TD pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_typert_registry pkg_client_runtime --> pkg_agent @@ -1204,6 +1227,7 @@ flowchart TD pkg_client_test_runtime --> pkg_invariants pkg_client_ui_input_trigger --> pkg_client_locale pkg_client_ui_input_trigger --> pkg_client_runtime + pkg_client_ui_input_trigger --> pkg_file_reference pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_settings_models --> pkg_api_remotes pkg_client_ui_settings_models --> pkg_client_connection @@ -1233,6 +1257,14 @@ flowchart TD pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_client_locale + pkg_client_ui_reference --> pkg_client_runtime + pkg_client_ui_reference --> pkg_client_ui_input_trigger + pkg_client_ui_reference --> pkg_file_reference + pkg_client_ui_reference --> pkg_invariants + pkg_client_ui_reference --> pkg_session_reference + pkg_client_ui_reference --> pkg_typert_protocol pkg_cordis_client_runner --> pkg_api_remotes pkg_cordis_client_runner --> pkg_client_connection pkg_cordis_client_runner --> pkg_client_modules @@ -1276,6 +1308,10 @@ flowchart TD pkg_client_ui_attachment --> pkg_client_runtime pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_brand_official --> pkg_client_runtime + pkg_client_ui_brand_official --> pkg_client_ui_conversation + pkg_client_ui_brand_official --> pkg_client_ui_sidebar + pkg_client_ui_brand_official --> pkg_invariants pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_client_locale pkg_client_ui_commands --> pkg_client_runtime @@ -1332,6 +1368,7 @@ flowchart TD pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_client_connection pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1355,6 +1392,7 @@ flowchart TD pkg_client_ui_workflow_run --> pkg_session pkg_client_ui_workflow_run --> pkg_tool_workflow pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_ui_workspace --> pkg_client_connection pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_conversation @@ -1463,7 +1501,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1489,6 +1527,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`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), [`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) | @@ -1499,7 +1538,7 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | +| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | @@ -1549,7 +1588,8 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`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), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1566,6 +1606,7 @@ flowchart TD | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1599,23 +1640,25 @@ flowchart TD | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`client-runtime`](../packages/client/runtime) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-title`](../packages/session/session-title), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry) | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`client-runtime`](../packages/client/runtime), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-slots`](../packages/client/ui-slots), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1624,11 +1667,11 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6239520b7d..cd205a3f6d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -127,6 +127,7 @@ flowchart TD pkg_client_runtime["client-runtime"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_attachment["client-ui-attachment"] + pkg_client_ui_brand_official["client-ui-brand-official"] pkg_client_ui_commands["client-ui-commands"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] @@ -141,6 +142,7 @@ flowchart TD pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] + pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] @@ -172,6 +174,8 @@ flowchart TD end subgraph group_context["packages/context"] pkg_agent_instructions["agent-instructions"] + pkg_file_reference["file-reference"] + pkg_file_reference_local["file-reference-local"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -291,6 +295,7 @@ flowchart TD pkg_tool_bash["tool-bash"] pkg_tool_bash_persistent["tool-bash-persistent"] pkg_tool_pwsh["tool-pwsh"] + pkg_tool_pwsh_persistent["tool-pwsh-persistent"] end subgraph group_storage["packages/storage"] pkg_storage["storage"] @@ -409,6 +414,7 @@ flowchart TD pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_attachment pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment @@ -511,6 +517,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants + pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -564,6 +573,7 @@ flowchart TD pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_persistence_sqlite --> pkg_invariants + pkg_session_persistence_sqlite --> pkg_llm pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence pkg_session_projection_cache --> pkg_invariants @@ -835,6 +845,11 @@ flowchart TD pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session pkg_agent_instructions --> pkg_tools + pkg_file_reference_local --> pkg_agent + pkg_file_reference_local --> pkg_file_reference + pkg_file_reference_local --> pkg_invariants + pkg_file_reference_local --> pkg_system_prompt + pkg_file_reference_local --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction pkg_session_reference --> pkg_invariants @@ -842,6 +857,7 @@ flowchart TD pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_typert_protocol pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -929,6 +945,11 @@ flowchart TD pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools + pkg_tool_pwsh_persistent --> pkg_agent + pkg_tool_pwsh_persistent --> pkg_invariants + pkg_tool_pwsh_persistent --> pkg_terminal + pkg_tool_pwsh_persistent --> pkg_timeout + pkg_tool_pwsh_persistent --> pkg_tools pkg_tool_terminal --> pkg_agent pkg_tool_terminal --> pkg_invariants pkg_tool_terminal --> pkg_jobs @@ -1162,6 +1183,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants @@ -1169,6 +1191,7 @@ flowchart TD pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_typert_registry pkg_client_runtime --> pkg_agent @@ -1206,6 +1229,7 @@ flowchart TD pkg_client_test_runtime --> pkg_invariants pkg_client_ui_input_trigger --> pkg_client_locale pkg_client_ui_input_trigger --> pkg_client_runtime + pkg_client_ui_input_trigger --> pkg_file_reference pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_settings_models --> pkg_api_remotes pkg_client_ui_settings_models --> pkg_client_connection @@ -1235,6 +1259,14 @@ flowchart TD pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_client_locale + pkg_client_ui_reference --> pkg_client_runtime + pkg_client_ui_reference --> pkg_client_ui_input_trigger + pkg_client_ui_reference --> pkg_file_reference + pkg_client_ui_reference --> pkg_invariants + pkg_client_ui_reference --> pkg_session_reference + pkg_client_ui_reference --> pkg_typert_protocol pkg_cordis_client_runner --> pkg_api_remotes pkg_cordis_client_runner --> pkg_client_connection pkg_cordis_client_runner --> pkg_client_modules @@ -1278,6 +1310,10 @@ flowchart TD pkg_client_ui_attachment --> pkg_client_runtime pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_brand_official --> pkg_client_runtime + pkg_client_ui_brand_official --> pkg_client_ui_conversation + pkg_client_ui_brand_official --> pkg_client_ui_sidebar + pkg_client_ui_brand_official --> pkg_invariants pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_client_locale pkg_client_ui_commands --> pkg_client_runtime @@ -1334,6 +1370,7 @@ flowchart TD pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_client_connection pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1357,6 +1394,7 @@ flowchart TD pkg_client_ui_workflow_run --> pkg_session pkg_client_ui_workflow_run --> pkg_tool_workflow pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_ui_workspace --> pkg_client_connection pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_conversation @@ -1465,7 +1503,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1491,6 +1529,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`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), [`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) | @@ -1501,7 +1540,7 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | +| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | @@ -1551,7 +1590,8 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`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), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1568,6 +1608,7 @@ flowchart TD | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1601,23 +1642,25 @@ flowchart TD | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`client-runtime`](../packages/client/runtime) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-title`](../packages/session/session-title), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry) | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`client-runtime`](../packages/client/runtime), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-slots`](../packages/client/ui-slots), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1626,11 +1669,11 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 295a69a8a8..e6e7363a9f 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: b680bccf22f7840663e5268eb3feeb6b16f7fd42 -persistence-catalog.zh.md: 4b50582fa55dbdc672d8c45debe108b97a55f0e2 +persistence-catalog.md: cbe9cec195ad899ba27dd08743d64090ae7277f2 +persistence-catalog.zh.md: 8057a0910e505b95872c341283d1acdbf033faaf diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b680bccf22..cbe9cec195 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -90,7 +90,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:372`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:340`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:408`](../packages/core/session/src/types.ts) ## Events @@ -226,14 +226,18 @@ Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/ * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event with `interrupted: true`; undispatched tool calls are absent. The + * marker distinguishes that prefix without re-deriving interruption from turn + * boundaries. An aborted turn with no such event streamed no visible content. */ -'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } +'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } ``` Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) ### `command/*` @@ -543,7 +547,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) @@ -557,7 +561,7 @@ Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -632,7 +636,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) @@ -778,7 +782,7 @@ Source: [`packages/experimental/team/src/types.ts:208`](../packages/experimental Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) ### `tool/*` @@ -797,7 +801,7 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) @@ -872,7 +876,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) ### `tool-workflow/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4b50582fa5..8057a0910e 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -92,7 +92,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:372`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:340`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:408`](../packages/core/session/src/types.ts) ## 事件 @@ -228,14 +228,18 @@ export type SessionEvent = { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event with `interrupted: true`; undispatched tool calls are absent. The + * marker distinguishes that prefix without re-deriving interruption from turn + * boundaries. An aborted turn with no such event streamed no visible content. */ -'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } +'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } ``` 类型:[TokenUsage](subsystems/llm-streaming.md) -来源:[`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) ### `command/*` @@ -545,7 +549,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) @@ -559,7 +563,7 @@ export type SessionEvent = { 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -来源:[`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -634,7 +638,7 @@ export type SessionEvent = { 'session/end-seed': Record ``` -来源:[`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) @@ -780,7 +784,7 @@ export type SessionEvent = { 类型:[TodoItem](subsystems/session.md) -来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) ### `tool/*` @@ -799,7 +803,7 @@ export type SessionEvent = { 类型:[CallId](subsystems/core.md) -来源:[`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) @@ -874,7 +878,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) ### `tool-workflow/*` diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 21290b63f6..6666570ea6 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: c1b2ab5f1e0926864f25409c021691d078df9e0f -llm-streaming.zh.md: 7bf04bf7a4a7de63d20eb67461842ff49f3b189d +llm-streaming.md: 8731a535267bddd2bf99a06a2beee92d3083232d +llm-streaming.zh.md: b9e71bba7428dafbbc7a366a97c2d7ddc9a87d46 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index c1b2ab5f1e..8731a53526 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -298,7 +298,8 @@ One keep/drop decision covers content and metadata together: a `max-tokens` fini * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a @@ -317,6 +318,14 @@ declare class BlockAssembler { * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[]; + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with non-whitespace content, in stream order. + * Tool calls are omitted because interruption precedes dispatch; retaining + * one would require a fabricated result. Open unknown blocks are also omitted. + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[]; /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined; /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 7bf04bf7a4..b9e71bba74 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -304,7 +304,8 @@ interface TokenUsage { * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a @@ -323,6 +324,14 @@ declare class BlockAssembler { * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[]; + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with non-whitespace content, in stream order. + * Tool calls are omitted because interruption precedes dispatch; retaining + * one would require a fabricated result. Open unknown blocks are also omitted. + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[]; /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined; /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 2950610348..baa245bcd8 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 5b1b224e419aca205baba69894ed64467b8fb4e1 -persistence.zh.md: a91e7d66b92270c82287d054e619b665e96ea206 +persistence.md: ef193806ce6234d1c25e2118ca2db7233c0b9b2e +persistence.zh.md: 5fa47497249888f478e471e2798b6dfcc724db84 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 5b1b224e41..ef193806ce 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -4,7 +4,7 @@ English | [中文](persistence.zh.md) The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md). -The seam is a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session/session-persistence), `ctx.sessionPersistence`) defining locate/create/append, reusable Session preparation, logical load/inspect, physical suffix reads, and lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted event type** — and two interchangeable backends implementing the same contract. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session/session-persistence), `ctx.sessionPersistence`) defining locate/create/append, reusable Session preparation, logical load/inspect, physical suffix reads, and lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted event type** — and three interchangeable providers implementing the same contract. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -230,10 +230,10 @@ interface SessionPersistenceSnapshot { ## The backends -Both implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite: +All implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite: - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. -- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row fields `(session_id, seq, type, time, data, source_event_seqs, surface_op)` map 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. +- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — an opt-in `node:sqlite` backend using schema 17 to store exact same-block delta runs in bounded physical `text-chunks`, `reasoning-chunks`, and `tool-call-chunks` rows. It reconstructs the complete logical event stream before returning it, packs only newly durable batches, and rejects older schemas rather than migrating them. diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index a91e7d66b9..5fa4749724 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -4,7 +4,7 @@ 事件日志的**持久性 seam**。[session.md](session.md) 描述了内存中的 `Session`:仅追加的 `SessionEvent` 日志即为真源。本页描述如何使该日志持久化:抽象的 `SessionPersistence` 服务、它的后端、flush 检查点、崩溃恢复,以及随日志一同存储的元数据头。日志承载的事件词汇在生成的[持久化日志事件目录](../persistence-catalog.md)中逐项列举。 -该 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session/session-persistence),`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append、可复用的 Session 准备流程、逻辑 load/inspect、物理后缀读取,以及轻量的 list/snapshot 观察——**没有平行的持久化事件类型**——以及两个实现同一约定的可互换后端。见 [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。 +该 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session/session-persistence),`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append、可复用的 Session 准备流程、逻辑 load/inspect、物理后缀读取,以及轻量的 list/snapshot 观察——**没有平行的持久化事件类型**——以及三个实现同一约定的可互换提供方。见 [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。 ## flush 检查点 @@ -232,8 +232,8 @@ interface SessionPersistenceSnapshot { 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过共享的 `runPersistenceContract` 套件: -- **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 -- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行字段 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。 +- **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——逐会话仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。 +- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 17 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。 diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index b6358e19d6..d354125b2a 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-reference.md -session-reference.md: 4140eb2a27d79ee0975d97ae9e96a1b502d74187 -session-reference.zh.md: 9dcfdbd07d4df1c54c51dc06d36af2f052969783 +session-reference.md: e3ef2c00b7ddbeed9fa3f9d074df436d8c0cc7b5 +session-reference.zh.md: 74063c1525a1f7f67663e9b11148e18902b22748 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index 4140eb2a27..e3ef2c00b7 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -2,9 +2,23 @@ English | [中文](session-reference.zh.md) -Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) defines canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. +Host-backed file discovery plus structured cross-session reference requests and prepared message contexts. The [file-reference contract](../../packages/context/file-reference) owns path-only completion records and grammar; the [session-reference contract](../../packages/context/session-reference) defines canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. -Source: [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) +Sources: [`packages/context/file-reference/src/types.ts`](../../packages/context/file-reference/src/types.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) + +## File candidates + +`FileReferenceCandidate` is the path-only discovery result. The addressed agent supplies the working-directory scope; providers decide ranking and namespace access without reading file contents. + +```ts type-equiv +/** One path-only completion candidate inside the target session cwd. */ +interface FileReferenceCandidate { + /** User-facing path accepted by normal prompts and filesystem tools. */ + path: string + /** Directories keep completion open; files finish the mention. */ + kind: 'file' | 'directory' +} +``` ## Inputs and candidates @@ -36,6 +50,16 @@ interface SessionReferenceCandidate { } ``` +The `sessionReferenceResolver/candidates` Remote method serves the same discovery to browser consumers and attaches each candidate's canonical prompt mention. + +```ts type-equiv +/** One discovery candidate carrying its canonical prompt mention. */ +interface SessionReferenceMentionCandidate extends SessionReferenceCandidate { + /** Canonical `@[label](dsh-session:…)` mention serialized into the prompt draft. */ + mention: string +} +``` + ## Prepared messages Preparation preserves readable current-message content and returns at most one aggregated context. @@ -74,6 +98,37 @@ type SessionReferenceErrorCode = Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.fileReferences` — `FileReferenceService` (abstract seam) + +Host capability for cancellable file-reference discovery. + +```ts cordis-catalog +/** + * List file and directory candidates for one agent's working directory. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Remote face of {@link list}; the decorator cannot mark the abstract + * member, so this concrete adapter carries the identical contract. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](core.md) + +Source: [`packages/context/file-reference/src/index.ts:27`](../../packages/context/file-reference/src/index.ts) + ### `ctx.sessionReferenceResolver` — `SessionReferenceResolver` @@ -92,11 +147,22 @@ Exact-read consumer that prepares immutable cross-session message context. async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise /** - * Snapshot all references before enqueue and return one aggregated durable context. + * Remote face of {@link listCandidates}: the configured candidate limit + * applies, and every candidate carries the canonical mention a host inserts + * into the prompt draft. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd/title substring. + * @param signal - caller cancellation. + * @returns mention-carrying candidates in rank order. + */ +@Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Snapshot all references for one accepted direct message and return one aggregated durable context. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. - * @param signal - optional cancellation boundary for host request teardown. + * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise @@ -104,5 +170,5 @@ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferen Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) -Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +Source: [`packages/context/session-reference/src/index.ts:75`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 9dcfdbd07d..74063c1525 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -2,9 +2,23 @@ [English](session-reference.md) | 中文 -结构化的跨会话引用请求与准备后的消息上下文。[包约定](../../packages/context/session-reference) 定义规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 +由 Host 支撑的文件发现,以及结构化的跨会话引用请求与准备后的消息上下文。[文件引用约定](../../packages/context/file-reference)负责仅含路径的补全记录与语法;[会话引用约定](../../packages/context/session-reference)定义规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 -来源:[`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) +来源:[`packages/context/file-reference/src/types.ts`](../../packages/context/file-reference/src/types.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) + +## 文件候选项 + +`FileReferenceCandidate` 是仅含路径的发现结果。被寻址的 agent 提供工作目录范围;提供方负责排序和命名空间访问,但不会读取文件内容。 + +```ts type-equiv +/** One path-only completion candidate inside the target session cwd. */ +interface FileReferenceCandidate { + /** User-facing path accepted by normal prompts and filesystem tools. */ + path: string + /** Directories keep completion open; files finish the mention. */ + kind: 'file' | 'directory' +} +``` ## 输入与候选项 @@ -36,6 +50,16 @@ interface SessionReferenceCandidate { } ``` +`sessionReferenceResolver/candidates` Remote 方法向浏览器消费方提供同一发现能力,并为每个候选附上规范提示词 mention。 + +```ts type-equiv +/** One discovery candidate carrying its canonical prompt mention. */ +interface SessionReferenceMentionCandidate extends SessionReferenceCandidate { + /** Canonical `@[label](dsh-session:…)` mention serialized into the prompt draft. */ + mention: string +} +``` + ## 准备后的消息 准备过程保留可读的当前消息内容,并最多返回一个聚合上下文。 @@ -74,6 +98,37 @@ type SessionReferenceErrorCode = Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.fileReferences` — `FileReferenceService` (abstract seam) + +Host capability for cancellable file-reference discovery. + +```ts cordis-catalog +/** + * List file and directory candidates for one agent's working directory. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Remote face of {@link list}; the decorator cannot mark the abstract + * member, so this concrete adapter carries the identical contract. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](core.md) + +Source: [`packages/context/file-reference/src/index.ts:27`](../../packages/context/file-reference/src/index.ts) + ### `ctx.sessionReferenceResolver` — `SessionReferenceResolver` @@ -92,11 +147,22 @@ Exact-read consumer that prepares immutable cross-session message context. async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise /** - * Snapshot all references before enqueue and return one aggregated durable context. + * Remote face of {@link listCandidates}: the configured candidate limit + * applies, and every candidate carries the canonical mention a host inserts + * into the prompt draft. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd/title substring. + * @param signal - caller cancellation. + * @returns mention-carrying candidates in rank order. + */ +@Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Snapshot all references for one accepted direct message and return one aggregated durable context. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. - * @param signal - optional cancellation boundary for host request teardown. + * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise @@ -104,5 +170,5 @@ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferen Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) -Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +Source: [`packages/context/session-reference/src/index.ts:75`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 1c33960769..c2b5d5629e 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: aea9d00b38e384e7a973ce168c3a75a62e70a8bb -session.zh.md: 8c56029af5144569f1ab6df73a8fe2278f9ef5b4 +session.md: 3dc8762468efd2f1515ae10e7400a7d85941b461 +session.zh.md: 3edbe86c3dea7dcfaa3c1fef6a5ca9e4a4b2c36a diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index aea9d00b38..3dc8762468 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -59,9 +59,13 @@ interface SessionEventMap { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event with `interrupted: true`; undispatched tool calls are absent. The + * marker distinguishes that prefix without re-deriving interruption from turn + * boundaries. An aborted turn with no such event streamed no visible content. */ - 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } /** * The model requested one tool invocation: `name` with the raw `arguments` * JSON string exactly as the model produced it (unparsed). `callId` pairs the diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 8c56029af5..3edbe86c3d 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -59,9 +59,13 @@ interface SessionEventMap { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event with `interrupted: true`; undispatched tool calls are absent. The + * marker distinguishes that prefix without re-deriving interruption from turn + * boundaries. An aborted turn with no such event streamed no visible content. */ - 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } /** * The model requested one tool invocation: `name` with the raw `arguments` * JSON string exactly as the model produced it (unparsed). `callId` pairs the diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 424f804508..02e5b57c17 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 3ffc2f1c4211b93812e240d2a02a23d70b7ef5fe -tool-catalog.zh.md: e5751ae05d019b88f26cd65a9588052b7c73c390 +tool-catalog.md: 13c56ed6d21c5aaac9909ed839ff111a867805df +tool-catalog.zh.md: e1b9581f25103cf5857af6de09bfbe03b3e52993 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 3ffc2f1c42..13c56ed6d2 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`, `cordis_inspect_list`, `cordis_inspect_query`, `cordis_inspect_self`, `cordis_run`, `cordis_stop`, `cordis_undefine` | `ctx.tools`, `ctx.dynamicCordisRunner` | `tool/call`, `tool/result`, `process-local dynamic package lifecycle` | - | Not in any shipped tree (a deliberate opt-in — dynamic package code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). The toolset injects `ctx.dynamicCordisRunner` from `@deepseek-ai/dsh-cordis-host-runner`, which owns the definition registry and the vm sandbox; a composition missing it never activates the tools. A running package may register ADDITIONAL model-visible tools until it is stopped, undefined, or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal API. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (read_image registration)`, `ctx.llm + an image-capable route (read_image execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background jobs) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | @@ -527,6 +528,33 @@ Source: [`packages/shell/tool-bash-persistent/src/index.ts`](../packages/shell/t One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. + + +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts`](../packages/shell/tool-pwsh-persistent/src/index.ts) + +One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. + ## `@deepseek-ai/dsh-tool-str-replace-editor` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index e5751ae05d..e1b9581f25 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,6 +24,7 @@ | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`、`cordis_inspect_list`、`cordis_inspect_query`、`cordis_inspect_self`、`cordis_run`、`cordis_stop`、`cordis_undefine` | `ctx.tools`、`ctx.dynamicCordisRunner` | `tool/call`、`tool/result`、`process-local dynamic package lifecycle` | - | 不在任何随产品发布的树中,需要显式选择启用;动态 Package 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。该工具集注入 `@deepseek-ai/dsh-cordis-host-runner` 提供的 `ctx.dynamicCordisRunner`,后者拥有定义注册表和 vm 沙箱;组合缺少它时这些工具不会激活。运行中的 Package 在停止、undefine 或 DSH 重启前可以注册**额外的**模型可见工具;发生这类工具集变化时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | | `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (read_image registration)`、`ctx.llm + an image-capable route (read_image execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 | | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | @@ -529,6 +530,33 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 + + +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +在持久 PowerShell shell 中运行命令。包括当前目录和已导出环境变量在内的状态会在此 agent 的多次调用之间保留。 + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts`](../packages/shell/tool-pwsh-persistent/src/index.ts) + +一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 + ## `@deepseek-ai/dsh-tool-str-replace-editor` diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index faff9bb2ee..1942599b5d 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: 099f434ec4602aa402239e83c708d81fcadd7732 -providers.zh.md: 367c90b525ad628b3cd86b2d22045c25064e88a1 +providers.md: 59682d38c71893c16118639cb2b24f63497a3c13 +providers.zh.md: a9deac5c8ba46950af75b02e631aa83c56a4b72f diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 099f434ec4..59682d38c7 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -79,6 +79,42 @@ Every list must name at least one modality except a model's own, where an empty Both fields state a claim about your endpoint rather than checking it. A model that declares images its endpoint does not serve is not caught here; the provider rejects the request instead. +### Request compatibility + +A gateway can hold a working key at a reachable address and still refuse every request. pi-ai decides the shape of a request — which role carries the system prompt, which field caps the output, how a thinking level travels — from the endpoint's URL, and an address it does not recognize is addressed as though it were OpenAI itself. Most OpenAI-compatible gateways refuse at least one thing OpenAI accepts. + +Two account for most of it. A model that declares reasoning has its system prompt sent as `role: "developer"`, which many gateways reject outright, and the output cap is sent as `max_completion_tokens`, which a server that only knows `max_tokens` refuses. The form has no field for either; correct them on the route in `$DSH_HOME/settings.yaml`: + +```yaml +llm-pi-ai: + providers: + my-gateway: + apiKeyEnv: GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.example/v1 + compat: + supportsDeveloperRole: false + maxTokensField: max_tokens + models: + - id: my-model +``` + +A route's `compat` is the default for its models, and a model's own wins field by field, so one model can be corrected without restating the route: + +```yaml + models: + - id: my-model + - id: my-reasoner + compat: + thinkingFormat: deepseek +``` + +What neither sets keeps the installed catalog's value for that model, and what the catalog does not describe falls to pi-ai's detection. Give every switch you name a value: a key left empty (`supportsDeveloperRole:`) is refused rather than ignored, because an empty value would erase what the catalog knows while saying nothing in its place. A name no protocol accepts is refused too, and the message lists the ones that are available. + +Each switch belongs to the protocols that declare it, so a switch valid on one `api` may be refused on another — the message names what that protocol does offer. Like `input` above, a switch states a claim about your endpoint rather than checking it: setting one your gateway does not actually need simply sends a different request. + +Every switch, its accepted values, and the protocols that take it are listed under `PiAiCompatProfile` in the [generated `dsh-llm-pi-ai` configuration reference](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) — which is derived from the source, so it cannot fall behind what the adapter accepts. + ## Select a model Configured providers appear in the model picker. Selecting a model also makes it the default for new sessions. A session that has already sent a request retains the model recorded in its own log. @@ -90,9 +126,12 @@ If a saved default names a provider that was deleted, the composer displays **Se - **`MISSING_CREDENTIAL`** — Store the provider key through the Models page or supply the referenced environment variable. - **`UNKNOWN_MODEL`** — Select a configured model or add the missing model to the custom provider. - **Fetching available models returns 401** — Check the key. Model discovery calls the OpenAI-compatible `GET /models` endpoint; enter models manually for endpoints that do not provide it. +- **The gateway refuses every request although the key and URL are right** — Its request shape differs from OpenAI's. Start with `compat.supportsDeveloperRole: false` and `compat.maxTokensField: max_tokens` on the route. +- **Only reasoning models fail** — pi-ai sends their system prompt as the `developer` role, which the gateway rejects. Set `compat.supportsDeveloperRole: false`. +- **A compat switch is refused as having no value** — A key written with nothing after the colon. Give it a value, or remove the key to keep the installed catalog's. - **An image is refused before sending** — The model declares no image modality. Give a custom provider's model `input: [text, image]`; DeepSeek's own chat-completions route is text-only and cannot be configured otherwise. - **The provider rejects a request carrying an image** — The model declares images its endpoint does not actually serve. Remove `image` from whichever list granted it — the model's `input`, or the route's `defaultInput` — then start a new session: the attached image stays in the session log, so the same request repeats until the session moves off it. ## Advanced configuration -The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors. +The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default for every plugin; [`dsh-llm-pi-ai`](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) is the provider section this page configures. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 367c90b525..a9deac5c8b 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -79,6 +79,42 @@ llm-pi-ai: 这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。 +### 请求兼容性 + +网关可能持有可用的密钥、地址也通得到,却仍然拒绝每一个请求。pi-ai 依据端点的 URL 决定请求的形状——系统提示词由哪个角色承载、输出上限写在哪个字段、思考级别如何传输——而对于它无法识别的地址,会当作 OpenAI 本身来对待。多数 OpenAI 兼容网关至少会拒绝 OpenAI 所接受的某一样东西。 + +其中两样占了绝大多数。声明了推理能力的模型,其系统提示词会以 `role: "developer"` 发出,很多网关直接拒绝;输出上限则写作 `max_completion_tokens`,只认 `max_tokens` 的服务端会拒绝。表单里没有这两个字段;请在 `$DSH_HOME/settings.yaml` 的路由上更正: + +```yaml +llm-pi-ai: + providers: + my-gateway: + apiKeyEnv: GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.example/v1 + compat: + supportsDeveloperRole: false + maxTokensField: max_tokens + models: + - id: my-model +``` + +路由的 `compat` 是其模型的默认值,模型自身的则逐字段胜出,因此更正某一个模型无需重述整条路由: + +```yaml + models: + - id: my-model + - id: my-reasoner + compat: + thinkingFormat: deepseek +``` + +两者都未设置的字段,沿用已安装 catalog 为该模型记录的值;catalog 也未描述的,落到 pi-ai 的检测。凡是写下的开关都要给值:冒号后留空的键(`supportsDeveloperRole:`)会被拒绝而不是被忽略,因为空值会抹掉 catalog 已知的信息,却又没有给出任何替代。任何协议都不接受的名字同样会被拒绝,报错会列出可用的那些。 + +每个开关归属于声明了它的那些协议,因此在某个 `api` 上合法的开关,在另一个上可能被拒绝——报错会点名该协议实际提供哪些。与上面的 `input` 一样,开关陈述的是关于你的端点的一个断言,而不是对它的检查:设置一个网关其实并不需要的开关,只是发出一个不同的请求而已。 + +全部开关、各自接受的取值,以及接受它们的协议,都列在[生成的 `dsh-llm-pi-ai` 配置参考](../../config-catalog.md#deepseek-aidsh-llm-pi-ai)的 `PiAiCompatProfile` 之下——该参考派生自源码,因此不会落后于适配器实际接受的内容。 + ## 选择模型 已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 @@ -90,9 +126,12 @@ llm-pi-ai: - **`MISSING_CREDENTIAL`**:通过模型页存储提供方密钥,或提供被引用的环境变量。 - **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供方添加缺失的模型。 - **获取可用模型返回 401**:检查密钥。模型发现会调用 OpenAI 兼容的 `GET /models` 端点;对于不提供该端点的服务,请手动输入模型。 +- **密钥与地址都正确,网关却拒绝每一个请求**:它的请求形状与 OpenAI 不同。先在路由上设 `compat.supportsDeveloperRole: false` 与 `compat.maxTokensField: max_tokens`。 +- **只有推理模型失败**:pi-ai 把它们的系统提示词以 `developer` 角色发出,而网关拒绝该角色。设 `compat.supportsDeveloperRole: false`。 +- **某个 compat 开关因没有值而被拒绝**:冒号后什么都没写。给它一个值,或删掉该键以沿用已安装 catalog 的值。 - **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`;DeepSeek 自身的 chat-completions 路由是纯文本的,且无法通过配置改变。 - **提供方拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。 ## 进阶配置 -自动生成的[插件配置目录](../../config-catalog.md)列出所有受支持的字段与默认值。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 +自动生成的[插件配置目录](../../config-catalog.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供方段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 diff --git a/examples/acp-agent/code-mode-image.cordis.snapshot.yml b/examples/acp-agent/code-mode-image.cordis.snapshot.yml index f722a66efd..4f227ec19a 100644 --- a/examples/acp-agent/code-mode-image.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode-image.cordis.snapshot.yml @@ -13,7 +13,7 @@ name: '@deepseek-ai/dsh-acp-demo' config: provider: deepseek-official - model: deepseek-v4-flash + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -37,6 +37,8 @@ name: DeepSeek models: - id: deepseek-v4-flash - inputModalities: [text, image] + inputModalities: [text] - id: deepseek-v4-pro inputModalities: [text] + - id: deepseek-v4-flash-vision-exp + inputModalities: [text, image] diff --git a/examples/acp-agent/image.cordis.snapshot.yml b/examples/acp-agent/image.cordis.snapshot.yml index f056f775d3..7a355618d3 100644 --- a/examples/acp-agent/image.cordis.snapshot.yml +++ b/examples/acp-agent/image.cordis.snapshot.yml @@ -1,9 +1,9 @@ # Keyless replay for the read-image success scenario. Include patches cannot # target entries behind a nested include, so this restates the replay overlay # directly over the base cordis.yml (the fs.cordis.snapshot.yml pattern) and -# re-pins the recorded flash model. The replay catalog declares image input on -# flash, so the strict read_image gate accepts the route and the tool result -# carries the durable image block; the live DeepSeek route cannot record this. +# re-pins the recorded vision model. The replay catalog declares image input, +# so the strict read_image gate accepts the route and the tool result carries +# the durable image block. - id: base name: '@deepseek-ai/cordis-plugin-include' config: @@ -16,7 +16,7 @@ name: '@deepseek-ai/dsh-acp-demo' config: provider: deepseek-official - model: deepseek-v4-flash + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -36,6 +36,8 @@ name: DeepSeek models: - id: deepseek-v4-flash - inputModalities: [text, image] + inputModalities: [text] - id: deepseek-v4-pro inputModalities: [text] + - id: deepseek-v4-flash-vision-exp + inputModalities: [text, image] diff --git a/examples/acp-agent/image.cordis.yml b/examples/acp-agent/image.cordis.yml index a12cef1e85..3d60d107b4 100644 --- a/examples/acp-agent/image.cordis.yml +++ b/examples/acp-agent/image.cordis.yml @@ -3,7 +3,8 @@ # snapshot harness scopes per run, so the overlay itself carries no paths. The # app config is restated to re-pin `deepseek-v4-flash` (base ships pro; the # authored fixture and the pinned header class are flash), because a config -# patch replaces the whole app config. +# patch replaces the whole app config. Keyless replay supplies its own exact +# image-capable catalog while the production endpoint remains unavailable. - id: base name: '@deepseek-ai/cordis-plugin-include' config: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 4742906b8a..bedd75c311 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -16,6 +16,7 @@ import { } from '@deepseek-ai/dsh-acp-snapshot' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' +import { OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' /** * The acp-agent example's snapshot suite: the scenario table for @@ -74,6 +75,7 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const PERSISTENT_PWSH_CONFIG = fileURLToPath(new URL('./persistent-pwsh.cordis.yml', import.meta.url)) const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath( new URL('../background-job-admission.cordis.yml', import.meta.url), ) @@ -222,27 +224,26 @@ const SCENARIOS: Scenario[] = [ posixOnly: true, }, // Authored keyless replays through the assembled app: the replay catalog - // declares flash image-capable (success) or text-only (refusal), and the + // declares the vision model image-capable and Flash text-only, and the // real read_image tool executes against the workspace fixture and the real - // attachment store. Both boot the same composed header (the tool registers - // with the attachment store, independent of route), so they share one class. + // attachment store. The success route selects the vision model while the + // refusal route retains text-only Flash, so each pins its exact header. { name: 'read-image', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'image', - // The overlay adds no prompt section (read_image carries no guidance), so - // the composed system prompt is byte-identical to the default class; only - // the tool-schema sidecar is class-specific. - systemPromptSource: 'text-turn', configPath: IMAGE_CONFIG, }, { name: 'read-image-text-route', hasModelTurn: true, recorded: false, - headerClass: 'image', + pinsHeader: true, + headerClass: 'image-text-route', + systemPromptSource: 'text-turn', + toolSchemasSource: 'read-image', configPath: IMAGE_TEXT_ROUTE_CONFIG, }, // Authored keyless replay of the oversized-image refusal: admission rejects @@ -296,6 +297,15 @@ const SCENARIOS: Scenario[] = [ // newline and one recording replays on every host. pwshOnly: true, }, + { + name: 'persistent-pwsh-tool-turn', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'persistent-pwsh', + configPath: PERSISTENT_PWSH_CONFIG, + pwshOnly: true, + }, // Authored keyless replay through a test-only partial-Landlock provider: // the exact compatibility notice must stay ordinary stderr when the wrapped // `false` command exits 1, rather than becoming SANDBOX_UNAVAILABLE. @@ -688,7 +698,7 @@ defineAcpSnapshotSuite({ hasPwsh, }) -it('pins pi-ai image offload in the request sent by the assembled app', async () => { +it('pins native DeepSeek image offload in the request sent by the assembled app', async () => { const requests: Record[] = [] const server = createServer((request: IncomingMessage, response: ServerResponse) => { let body = '' @@ -697,13 +707,21 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () request.on('end', () => { requests.push(JSON.parse(body) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end([ - 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) + const events = requests.length === 1 + ? [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ] + : [ + 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ] + response.end(events.join('\n\n')) }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -722,7 +740,7 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () { type: 'image', data: image, mimeType: 'image/png' }, { type: 'text', text: ' with the newer image ' }, { type: 'image', data: image, mimeType: 'image/png' }, - { type: 'text', text: ', then reply with DONE.' }, + { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, ], }, ], @@ -734,13 +752,14 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () mode: 'record', configPath: IMAGE_OFFLOAD_CONFIG, fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), + workspaceDir: join(SNAPSHOTS_DIR, 'read-image', 'workspace'), env: { DSH_SNAPSHOT_API_KEY: 'snapshot-key', - DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}/v1`, + DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`, }, }) expect(result.stderr).toBe('') - expect(requests).toHaveLength(1) + expect(requests).toHaveLength(2) const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) expect(offloaded?.content).toMatchInlineSnapshot(` @@ -764,11 +783,62 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () "type": "image_url", }, { - "text": ", then reply with DONE.", + "text": ", then use read_image on red.png and reply with DONE.", "type": "text", }, ] `) + + const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{ + role?: unknown + content?: unknown + }> + const toolMessage = followup.find(message => message.role === 'tool') + if (toolMessage === undefined || typeof toolMessage.content !== 'string') { + throw new Error('native read_image request has no tool content') + } + const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => ( + cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`] + )))] + let toolContent = toolMessage.content + for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}') + toolMessage.content = toolContent + expect(followup).toEqual([ + { + role: 'user', + content: `Compare the older image ${OFFLOADED_IMAGE_TEXT} with the newer image ${OFFLOADED_IMAGE_TEXT}, then use read_image on red.png and reply with DONE.`, + }, + { + role: 'user', + content: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n' + + '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: 'assistant', + content: '', + tool_calls: [{ + id: 'native-read-image', + type: 'function', + function: { name: 'read_image', arguments: '{"file_path":"red.png"}' }, + }], + }, + { + role: 'tool', + tool_call_id: 'native-read-image', + content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n', + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Attached image(s) from tool result:' }, + { + type: 'image_url', + image_url: { url: `data:image/png;base64,${image}` }, + }, + ], + }, + ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) } diff --git a/examples/acp-agent/tests/fixtures/image-offload.cordis.yml b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml index 6007c5d44d..530f7b9663 100644 --- a/examples/acp-agent/tests/fixtures/image-offload.cordis.yml +++ b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml @@ -1,5 +1,5 @@ -# Keyless assembled-request snapshot for pi-ai image offload. The local -# provider endpoint is supplied by the snapshot test; the real attachment +# Keyless assembled-request snapshot for native DeepSeek image offload. The +# local provider endpoint is supplied by the snapshot test; the real attachment # store and ACP bridge carry two uploaded images into one model request. - id: base name: '@deepseek-ai/cordis-plugin-include' @@ -8,12 +8,21 @@ patches: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true + config: + apiKeyEnv: DSH_SNAPSHOT_API_KEY + baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL + thinking: disabled + maxRequestImageBytes: 92 + models: + - id: deepseek-v4-flash-vision-exp + contextWindow: 32768 + maxTokens: 1024 + inputModalities: [text, image] - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: snapshot - model: vision + provider: deepseek-official + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -25,17 +34,3 @@ - insert: - id: attachment-local name: '@deepseek-ai/dsh-attachment-local' - - id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - snapshot: - apiKeyEnv: DSH_SNAPSHOT_API_KEY - api: openai-completions - baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL - maxRequestImageBytes: 92 - models: - - id: vision - contextWindow: 32768 - maxTokens: 1024 - input: [text, image] diff --git a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts index 3f9f9ebe55..314f1e9f74 100644 --- a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts +++ b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts @@ -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 () => {}, diff --git a/examples/acp-agent/tests/goal-snapshots/goal-round-driver/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-round-driver/session.expected.jsonl index 09b40762e7..ec25708a77 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-round-driver/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-round-driver/session.expected.jsonl @@ -57,6 +57,7 @@ {"type":"user/message","seq":55,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"step/end","seq":58,"time":0,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":59,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} -{"type":"goal/change","seq":60,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-round-driver snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}} +{"type":"assistant/message","seq":58,"time":0,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true},"sourceEventSeqs":[56,57],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":0,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":60,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"goal/change","seq":61,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-round-driver snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-round-driver/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-round-driver/stdout.expected.jsonl index 9a54cb88c8..c0a4330ea9 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-round-driver/stdout.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-round-driver/stdout.expected.jsonl @@ -3,3 +3,4 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL READY"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL ROUND ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} diff --git a/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml b/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml new file mode 100644 index 0000000000..7b90b2298b --- /dev/null +++ b/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml @@ -0,0 +1,45 @@ +# Keyless replay counterpart to persistent-pwsh.cordis.yml. +- id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-pro + +- id: terminal + name: '@deepseek-ai/dsh-terminal' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + config: + shellDialect: pwsh + timeoutMs: 30000 + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh-persistent + name: '@deepseek-ai/dsh-tool-pwsh-persistent' diff --git a/examples/acp-agent/tests/persistent-pwsh.cordis.yml b/examples/acp-agent/tests/persistent-pwsh.cordis.yml new file mode 100644 index 0000000000..0b3cd18c70 --- /dev/null +++ b/examples/acp-agent/tests/persistent-pwsh.cordis.yml @@ -0,0 +1,42 @@ +# Minimal live counterpart for the persistent-pwsh-tool-turn snapshot composition. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + models: + - id: deepseek-v4-pro + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: terminal + name: '@deepseek-ai/dsh-terminal' + +- id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + config: + shellDialect: pwsh + timeoutMs: 30000 + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh-persistent + name: '@deepseek-ai/dsh-tool-pwsh-persistent' diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index c6396be679..af088a8ff7 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -10,5 +10,6 @@ {"type":"request/context","seq":8,"time":1785730444532,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1785498791456,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1785730444541,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"step/end","seq":11,"time":1785730444547,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":1785730444547,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"assistant/message","seq":11,"time":1786334791338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"104e9294-f9b8-4248-b7df-0b7e2a069c0a"},"interrupted":true},"sourceEventSeqs":[9,10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1786334791338,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":13,"time":1786334791338,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl index cb25d1c6bb..078b607e91 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl index cb63f29c34..e8990b8f69 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl @@ -6,13 +6,13 @@ {"type":"user/message","seq":4,"time":1786431644558,"data":{"content":[{"type":"text","text":"Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"08e67dbb-9432-4fe4-b7da-4483998c0a31"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1786431644558,"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":"99b9db8d-e4ec-4ea9-b5e2-1e4c0ff6354b"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1786431644558,"data":{"title":"Using ONE run_code program, create","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":1786431644571,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"644382c5-5a05-4bda-b8dc-b9195d6a7d8b"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"ef352c42-b661-4b71-8c6a-7dbbd0a9f591"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786431644573,"data":{"turn":1,"step":1,"callId":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}} {"type":"tool/code-dispatch-start","seq":15,"time":1786431644697,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"}}} {"type":"tool/code-dispatch","seq":16,"time":1786431644828,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}} @@ -28,6 +28,6 @@ {"type":"assistant/chunk","seq":26,"time":1786431644889,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":27,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":28,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a6da60ea-d420-432b-ba00-9b99af045110"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"} +{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a721cef2-2c49-4336-8d07-5f6cc15f4b67"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"} {"type":"step/end","seq":30,"time":1786431644890,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":31,"time":1786431644890,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index e96ddb107c..786aa8fe80 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -1,6 +1,6 @@ You are an AI agent powered by DeepSeek Harness. -You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. +You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Your working directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl b/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl index 89cffe656d..08d65c2c91 100644 --- a/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl +++ b/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl @@ -6,12 +6,12 @@ {"type":"user/message","seq":4,"time":1783952000003,"data":{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1783952000004,"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":"0c0c0c0c-0000-4000-8000-000000000002"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1783952000004,"data":{"title":"Inspect this image, then reply","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783952000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1783952000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":11,"time":1783952000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0c0c0c0c-0000-4000-8000-000000000003"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"e58e49ab-9c34-4ba0-9276-9429b32c5ea0"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"step/end","seq":14,"time":1783952000010,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":15,"time":1783952000010,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json new file mode 100644 index 0000000000..653e9a346c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl new file mode 100644 index 0000000000..220bdd516e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl @@ -0,0 +1,34 @@ +{"type":"session","version":0,"id":"0b7ff6ab-2486-4b2f-a43e-0fa29a1a46ed","createdAt":1785678162241,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785898456879,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"}]}} +{"type":"turn/start","seq":1,"time":1785898456880,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785898456880,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785678162261,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785898456903,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1785898456903,"data":{"title":"Use the pwsh tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":1785898456904,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1785898456904,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":8,"time":1785678162968,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":9,"time0":1785678163361,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]}} +{"type":"assistant/chunk","seq":31,"time":1785678163671,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":32,"time0":1785678163671,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,17,0,0,0,0,0,109,0,0,0,0,0,0,22,305],"id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","args":["","{","\"","command","\"",": ","\"","[","Console","]","::","Out",".Write","('","P","WS","H","_OK","')","\"","}"]}} +{"type":"assistant/chunk","seq":53,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}}} +{"type":"assistant/chunk","seq":54,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":56,"time":1785898456913,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1785898456913,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."},{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"82945de6-83e2-4b93-b6d2-89d58921eacf"},"usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1785898456913,"data":{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}} +{"type":"tool/result","seq":59,"time":1785898456933,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_e0MSVSocL0o4UWjOdG4c2072"},"content":[{"type":"tool-result","toolCallId":"call_00_e0MSVSocL0o4UWjOdG4c2072","content":[{"type":"text","text":"PWSH_OK"}],"isError":false}],"role":"user","id":"874a846b-54b7-45cc-b3cb-edb8f868e1c5"}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":60,"time":1785898456933,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":61,"time":1785898456939,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":62,"time":1785678165136,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":63,"time0":1785678165312,"data":{"turn":1,"step":2,"index":0,"dt":[44,56,0,0,42,0,0,0,48,0,0,0,48,0,0,60,0,0,0,0,39,0,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," \"","P","WS","H","_OK","\"."," Now"," I"," need"," to"," reply"," with"," \"","D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":88,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":89,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":90,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":91,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":92,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":93,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":94,"time":1785898456944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":95,"time":1785898456944,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"36aaf6a0-1556-42e4-aed3-626caa8f7aaf"},"usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"} +{"type":"step/end","seq":96,"time":1785898456944,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":97,"time":1785898456944,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md new file mode 100644 index 0000000000..229b3a6f6c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md @@ -0,0 +1,3 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a concise snapshot agent working in {{cwd}}. diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json new file mode 100644 index 0000000000..20f5a3e55c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json @@ -0,0 +1,21 @@ +{ + "initial": [ + { + "name": "pwsh", + "description": "Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json index b75f1d9580..7d29e8fa61 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json @@ -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." } ] } diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json index 6fbff83b8c..f81eb48573 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json @@ -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" } } ] diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl index f1c5ffe374..6d5f0ce330 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl @@ -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"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl index 0b979e801f..7c74dd2d71 100644 --- a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl @@ -6,13 +6,13 @@ {"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1783951000004,"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":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1783951000004,"data":{"title":"Use read_image on wide.png in","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9676ac40-f7a8-4a7b-9326-a45fef18f11e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a25d70ac-2bd6-4e44-9121-ed74975ee229"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1783951000009,"data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}} {"type":"tool/result","seq":15,"time":1783951000014,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/wide.png\": at least one image side exceeds the 2000px limit; downscale the image and read the smaller copy"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1783951000014,"data":{"turn":1,"step":1}} @@ -21,6 +21,6 @@ {"type":"assistant/chunk","seq":19,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TOOLARGE"}}}} {"type":"assistant/chunk","seq":20,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":21,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c15b391-a95a-4113-9d47-2a1dfc991cf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"3a95dd83-34f7-4bc0-afb6-7ba3c9b483be"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":1783951000018,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":24,"time":1783951000018,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image/session.jsonl b/examples/acp-agent/tests/snapshots/read-image/session.jsonl index 6acde5a0ff..72064a302d 100644 --- a/examples/acp-agent/tests/snapshots/read-image/session.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image/session.jsonl @@ -6,13 +6,13 @@ {"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1786344283033,"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":"eecd1df6-153c-4a34-b198-42bfc9f9701e"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1786344283033,"data":{"title":"Use read_image to look at","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"41e9fb55-6edb-419d-b76c-554daa5a1c5d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"2b71c837-237d-4d92-a857-8b8ad1a3f237"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786344283039,"data":{"turn":1,"step":1,"callId":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}} {"type":"tool/result","seq":15,"time":1786344283069,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-call"},"content":[{"type":"tool-result","toolCallId":"read-image-call","content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"0b5779fc-523e-4275-9a32-8eb5e39f521e"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1786344283069,"data":{"turn":1,"step":1}} @@ -21,6 +21,6 @@ {"type":"assistant/chunk","seq":19,"time":1786344283078,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":20,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":21,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"73a87a50-8e0b-42af-8c54-d9b6fbe375f1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"5a45946c-b9f4-4f2c-a7c3-2569e541ec1d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":1786344283079,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":24,"time":1786344283079,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md new file mode 100644 index 0000000000..2d4ef255b8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md @@ -0,0 +1,24 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/examples/package.json b/examples/package.json index 021ed0dc32..23f7768686 100644 --- a/examples/package.json +++ b/examples/package.json @@ -96,6 +96,7 @@ "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-terminal": "workspace:*", "@deepseek-ai/dsh-tool-pwsh": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-skill": "workspace:*", diff --git a/knip.json b/knip.json index 7969b4f230..8733835187 100644 --- a/knip.json +++ b/knip.json @@ -25,7 +25,11 @@ ".": { "entry": [ "scripts/**/*.mjs", - "scripts/**/*.cjs" + "scripts/**/*.cjs", + "scripts/types/client-build-environment/index.d.ts" + ], + "ignoreUnresolved": [ + "client-build-environment" ], "project": [ "scripts/**/*.ts", @@ -502,6 +506,16 @@ "tests/**/*.ts" ] }, + "packages/context/file-reference": { + "ignoreDependencies": [ + "zod" + ] + }, + "packages/context/session-reference": { + "ignoreDependencies": [ + "zod" + ] + }, "packages/session/session-checkpoint-policy": { "entry": [ "tests/**/*.spec.ts", diff --git a/package.json b/package.json index 3ec9a6c1c5..d42108564d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "website" ], "scripts": { - "build": "npm run build:lib && npm run build:web", + "build": "tsx scripts/build.ts", + "build:official": "tsx scripts/build.ts --profile official", "build:lib": "npm run build:lib:host && npm run build:lib:client", "build:lib:host": "tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index c72b4b9da3..71e2a21e43 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -464,7 +464,9 @@ describe('ACP prompt lifecycle', () => { await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] })) .resolves.toEqual({ stopReason: 'end_turn' }) - await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') }) + // 'partial' is the cancelled turn's finalized prefix update; 'next' proves + // the second prompt settled independently of the aborted turn's late end. + await vi.waitFor(() => { expect(messageText(harness!)).toBe('partialnext') }) }) it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => { diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0bc596bf71..b4415a6457 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -58,37 +58,41 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^" + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 210ec0bf80..6a5164f160 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -4,16 +4,20 @@ import type { Context } from '@deepseek-ai/cordis' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' +import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' +import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' export type { TypertClientRemote as ClientRemote } from '@deepseek-ai/dsh-typert-protocol' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-commands/remote' +export type {} from '@deepseek-ai/dsh-file-reference/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' +export type {} from '@deepseek-ai/dsh-session-reference/remote' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -86,6 +90,10 @@ export type { // reason: a Client contribution names what it sends without importing a Host // package, and this assembly is where both planes legitimately meet. export type { JsonValue } from '@deepseek-ai/dsh-session/types' +// Reference-discovery result vocabulary for the fileReferences and +// sessionReferenceResolver namespaces. +export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' declare module '@deepseek-ai/cordis' { interface Context { @@ -106,7 +114,8 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, + commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, + pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index a0161dd047..49c7276d42 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -24,6 +24,12 @@ "path": "../../credentials/credentials" }, + { + "path": "../../context/file-reference" + }, + { + "path": "../../context/session-reference" + }, { "path": "../../extensions/cordis-host-runner" }, diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 9be66947bb..f67c375cd0 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -100,11 +100,11 @@ const BOOTSTRAP_NAMES = new Set([ 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB', 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS', 'PYTHONHOME', - // Version-control command hooks and config redirects. + // Version-control hooks, config redirects, and ambient command selectors. 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR', 'GIT_ASKPASS', 'SSH_ASKPASS', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT', - 'EDITOR', 'VISUAL', 'PAGER', + 'EDITOR', 'VISUAL', 'PAGER', 'BROWSER', // Network reach and trust. 'DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL', 'SSL_CERT_FILE', 'SSL_CERT_DIR', diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 9eec620285..8726895c1b 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -133,6 +133,7 @@ describe('loadLayeredEnv', () => { ['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'], ['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'], ['a lowercase network proxy', 'https_proxy=http://attacker.example\n'], + ['a browser command', 'BROWSER=./script\n'], ])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => { const home = tmp() const project = tmp() diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 2a1c5b01db..b23d1fc79c 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: 28fb5b3dcfc7fbb912493a6b97495e2ed5a3eece -README.zh.md: 92157f05497e53c48506666a3a53d638d98a7c9e +README.md: c8a6874bc01696fc7c9ca65faf772da81ac1e964 +README.zh.md: f36115cd97ccfeabd9bbb1a408be89c4c1df8723 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 28fb5b3dcf..c8a6874bc0 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{openBrowser, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, and registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true. After its Loader tree settles, it prints the `dsh web:` URL line when `printUrl` is true and opens the canonical host URL in the default browser when `openBrowser` is true and the inherited `SSH_CONNECTION` and `SSH_TTY` are blank or absent. An SSH launch keeps the URL line but suppresses browser handoff because the SSH client or editor owns the local forwarded address. Immediately before a handoff, the runtime prints `dsh web: opening the default browser; pass --no-open to disable`. A short-lived Node helper runs the maintained platform opener with the canonical scrubbed child environment. On Windows it stays alive until the short-lived PowerShell launcher exits, because `open` reports spawn before that launcher has handed the URL to the shell; elsewhere the helper stops after the opener accepts spawn. A helper failure writes a diagnostic with its reason and the manual URL to stderr without stopping the server, and no path waits for the browser to exit. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, `--no-open`, and the app's `--help`, then provides `webStartup`; browser opening defaults on for local launches, and `--no-open` turns it off for this invocation. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model retry defaults @@ -28,3 +28,6 @@ The prompt section sits near the system prompt's head and is stable for the life - **The frontend dist must be built** — `require.resolve` of the dist fails loud at activation with a build hint; there is no source-serving fallback. - **`lanAddresses` is a boot-time snapshot** — interface changes after boot are not re-advertised; the printed LAN URL always matches the configured trust fence. +- **Only handoff startup is observable** — observation ends when the platform opener accepts spawn, except that Windows waits for its short-lived PowerShell launcher to exit; a later browser exit is not reported, and the printed URL remains the manual fallback. +- **SSH forwarding owns the browser URL** — the printed canonical URL names the remote host's loopback endpoint; automatic handoff is suppressed, and the SSH client or editor must expose and open its local forwarded address. +- **Browser command overrides are launch-only** — a discovered `.env` may not set `BROWSER`; only an inherited value may reach an opener path that honors the variable, so a checkout cannot choose an executable for automatic handoff. diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 92157f0549..f36115cd97 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 目前有意不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{openBrowser, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,并在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量。自身 Loader 配置树结算后,它在 `printUrl` 为 true 时打印 `dsh web:` URL 行;`openBrowser` 为 true 且继承的 `SSH_CONNECTION` 与 `SSH_TTY` 均为空或不存在时,才会用默认浏览器打开规范宿主机 URL。SSH 启动仍保留 URL 行,但会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有。交接前,运行时会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`。短生命周期 Node helper 使用规范的脱敏子进程环境运行受维护的平台 opener。在 Windows 上,helper 会保持存活,直至短生命周期的 PowerShell launcher 退出,因为 `open` 会在 launcher 把 URL 交给 shell 之前、仅在 spawn 时返回;其他平台则在 opener 接受 spawn 后结束。helper 失败时会向 stderr 写入包含原因和手动访问 URL 的诊断,不会停止服务器,且任何路径都不会等待浏览器退出。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` 以及应用自己的 `--help`,再提供 `webStartup`;本机启动默认会打开浏览器,`--no-open` 则只对本次调用关闭该行为。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 目前有意不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型重试默认值 @@ -28,3 +28,6 @@ Web 使用共享的有界 normal 默认值,在首次请求后最多再重试 - **前端 dist 必须已构建**:对 dist 的 `require.resolve` 在激活时明确报错并给出构建提示;没有从源码直接服务的回退路径。 - **`lanAddresses` 是启动期快照**:启动后的网卡变化不会重新公告;打印的 LAN URL 始终与配置的信任栅栏一致。 +- **只观测交接启动**:平台 opener 接受 spawn 后即结束观察,但 Windows 会等待其短生命周期 PowerShell launcher 退出;之后的浏览器退出不会上报,已打印 URL 仍是手动访问的回退路径。 +- **SSH 转发持有浏览器 URL**:打印出的规范 URL 指向远端宿主机 loopback 端点;自动交接会被跳过,SSH 客户端或编辑器必须暴露并打开其本地转发地址。 +- **浏览器命令覆盖只能来自启动环境**:被发现的 `.env` 不得设置 `BROWSER`;只有继承值可以抵达会读取该变量的 opener 路径,避免 checkout 为自动交接选择可执行文件。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 203f8ff017..61151bdc65 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -79,6 +79,12 @@ writeEveryEvents: 200 writeIntervalMs: 5000 + - id: session-reference + name: '@deepseek-ai/dsh-session-reference' + + - id: file-reference-local + name: '@deepseek-ai/dsh-file-reference-local' + # Whole-log turn/step counts for the chat stats strip (the sessionStats # projection key); the projection registry itself is a base-layer row. - id: session-stats @@ -122,15 +128,17 @@ # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the # frontend-static fallback owner, registers the web-surface prompt - # section and the bash runtime variable, and prints the URL line. The - # webStartup provider supplies invocation-only values; after the server - # binds, this row samples LAN trust once and provides `webRuntime`. A - # complete agent-preset persona suppresses the prompt section for that - # agent while retaining the host-owned shell variable. + # section and the bash runtime variable, prints the URL line, and opens the + # canonical local URL after the full tree settles. The webStartup provider + # supplies invocation-only values; after the server binds, this row samples + # LAN trust once and provides `webRuntime`. A complete agent-preset persona + # suppresses the prompt section for that agent while retaining the host-owned + # shell variable. - id: web-runtime name: '@deepseek-ai/dsh-web-app' inject: [webStartup] config: + openBrowser: !!js ctx.webStartup.openBrowser printUrl: true surfaceContext: true trustedHosts: !!js ctx.webStartup.trustedHosts @@ -201,6 +209,10 @@ - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' + # Official occupants for the generic sidebar and conversation brand slots. + - id: ui-brand-official + name: '@deepseek-ai/dsh-client-ui-brand-official' + - id: ui-attachment name: '@deepseek-ai/dsh-client-ui-attachment' @@ -226,7 +238,7 @@ name: '@deepseek-ai/dsh-client-ui-workspace' # Input triggers: the '/' | '@' pipeline (ui-input-trigger), the command surface over - # it (ui-commands), and the two reference sources (ui-skill / ui-subagent). + # it (ui-commands), and the reference sources (ui-skill / ui-reference). - id: ui-input-trigger name: '@deepseek-ai/dsh-client-ui-input-trigger' @@ -239,6 +251,9 @@ - id: ui-subagent name: '@deepseek-ai/dsh-client-ui-subagent' + - id: ui-reference + name: '@deepseek-ai/dsh-client-ui-reference' + # Background jobs: the session-header list over the jobsBySession mirror. - id: ui-jobs name: '@deepseek-ai/dsh-client-ui-jobs' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 3cf68746c0..c753882a98 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -55,6 +55,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", + "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", @@ -76,6 +77,7 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", @@ -95,16 +97,22 @@ "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", - "commander": "^15.0.0" + "commander": "^15.0.0", + "open": "^11.0.0" }, "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 9b5207dd02..6965310437 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -5,11 +5,13 @@ * the built frontend dist (workspace knowledge of this bundle, never user * config), mounts the `frontend-static` fallback owner over it, registers the * harness-source and web-surface prompt sections, the bash-visible web runtime - * variable, and the URL line. App command-line values arrive through the - * `webStartup` service expressions in the bundle patch. + * variable, the URL line, and the default-browser handoff. App command-line + * values arrive through the `webStartup` service expressions in the bundle + * patch. * @module @deepseek-ai/dsh-web-app */ +import { spawn, type ChildProcess } from 'node:child_process' import { createRequire } from 'node:module' import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' @@ -17,6 +19,8 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -36,6 +40,8 @@ export const inject = ['webServer'] /** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { + /** Permit default-browser handoff after the Loader tree settles; an SSH launch suppresses it. */ + openBrowser: boolean /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** @@ -50,6 +56,7 @@ export interface Config { } export const Config: z = z.object({ + openBrowser: z.boolean().default(true), printUrl: z.boolean().default(true), surfaceContext: z.boolean().default(true), trustedHosts: z.array(String).default([]), @@ -72,6 +79,46 @@ const LOOPBACK_HOST = '127.0.0.1' /** The webserver schema's all-interfaces bind literal. */ const ALL_INTERFACES_HOST = '0.0.0.0' +/** Whether this process was launched through SSH, including a forwarded-port session. */ +function launchedThroughSsh(ctx: Context): boolean { + const environment = launchEnvironmentOf(ctx) + return ['SSH_CONNECTION', 'SSH_TTY'].some((name) => { + const value = environment.getFrom(name, ['process'])?.value + return value !== undefined && value !== '' + }) +} + +const BROWSER_OPENER_MODULE = import.meta.resolve('open') + +const BROWSER_OPENER_PROGRAM = ` +try { + const { default: open } = await import(${JSON.stringify(BROWSER_OPENER_MODULE)}) + const launcher = await open(process.argv[1]) + if (process.platform === 'win32') { + // open resolves at PowerShell spawn; keep it referenced until that launcher hands the URL to Windows. + const code = launcher.exitCode ?? await new Promise((resolve, reject) => { + function onError(error) { + launcher.off('close', onClose) + reject(error) + } + function onClose(code) { + launcher.off('error', onError) + resolve(code) + } + launcher.ref() + launcher.once('error', onError) + launcher.once('close', onClose) + }) + if (code !== 0) throw new Error('browser operating-system launcher exited with code ' + String(code)) + } + process.exitCode = 0 +} catch (error) { + // The parent turns this exit into the manual-URL warning. + console.error(error) + process.exitCode = 1 +} +` + /** * Resolve one LAN-trust snapshot from the active server bind. * @@ -123,17 +170,64 @@ function resolveDistIndex(): string { } } -/** Test hook: hosts with no built frontend dist substitute the resolver; production never touches this. */ -export const internals: { resolveDistIndex: () => string } = { resolveDistIndex } +/** Start the maintained platform opener without forwarding Harness credentials. */ +function spawnBrowserLauncher(url: string): ChildProcess { + return spawn(process.execPath, [ + '--input-type=module', + '--eval', BROWSER_OPENER_PROGRAM, + '--', url, + ], { + env: scrubbedParentEnv(), + stdio: ['ignore', 'inherit', 'pipe'], + }) +} + +/** Hand one URL to the operating system's default browser. */ +async function openBrowser(url: string): Promise { + const launcher = spawnBrowserLauncher(url) + let launcherStderr = '' + launcher.stderr?.setEncoding('utf8') + launcher.stderr?.on('data', (chunk: string) => { launcherStderr += chunk }) + await new Promise((resolve, reject) => { + function onError(error: Error): void { + launcher.off('close', onClose) + reject(error) + } + function onClose(code: number | null): void { + launcher.off('error', onError) + if (code !== 0) { + const firstLine = launcherStderr.trim().split(/\r?\n/u)[0] + const reason = firstLine === undefined || firstLine === '' + ? `browser launcher exited with code ${String(code)}` + : firstLine.replace(/^(?:[A-Za-z]*Error):\s*/u, '') + reject(new Error(reason)) + return + } + if (launcherStderr !== '') process.stderr.write(launcherStderr) + resolve() + } + launcher.once('error', onError) + launcher.once('close', onClose) + }) +} + +/** Test hooks for the built dist and native browser handoff; production never mutates them. */ +export const internals: { + resolveDistIndex: () => string + openBrowser: (url: string) => Promise +} = { resolveDistIndex, openBrowser } /** * Mount the Web runtime: dist serving, surface prompt, the bash runtime - * variable, and the URL line. + * variable, the URL line, and the default-browser handoff. * @param ctx - plugin context carrying the webServer service. * @param config - validated {@link Config}. */ export function apply(ctx: Context, config: Config): void { const runtime = resolveLanTrust(ctx.webServer.host, config.trustedHosts) + // The loopback URL belongs to this host. Under SSH, the operator reaches it + // through a local forwarding address that this process cannot derive. + const handoffBrowser = config.openBrowser && !launchedThroughSsh(ctx) // Release dependent rows only after bind-dependent trust has been sampled once. ctx.provide(WEB_RUNTIME_SERVICE, runtime) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) @@ -156,28 +250,40 @@ export function apply(ctx: Context, config: Config): void { }) }) } - if (config.printUrl) { - // The URL line is a readiness signal: supervisors (and the keyless CLI - // smoke) RPC as soon as they observe it, so it must not print while - // sibling rows (the /api route owner) are still mounting. Await Loader - // settlement first; a hand-built tree without a Loader prints at once. - const printUrl = (): void => { + if (config.printUrl || handoffBrowser) { + // The URL line and browser handoff are readiness signals: supervisors RPC + // as soon as they observe the line, while a browser requests the page as + // soon as it opens. Neither may run while sibling rows such as the /api + // route owner are still mounting. Await Loader settlement first; a + // hand-built tree without a Loader is already the complete tree. + const announceReady = (): void => { + const webUrl = localWebUrl(ctx) // Reuse the exact LAN snapshot provided to the /api trust fence. const lanCandidate = runtime.lanAddresses[0] const port = ctx.webServer.port - console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) + if (config.printUrl) { + console.log(`dsh web: ${webUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) + } + if (handoffBrowser) { + console.log('dsh web: opening the default browser; pass --no-open to disable') + void internals.openBrowser(webUrl).catch((error: unknown) => { + const reason = error instanceof Error ? error.message : String(error) + console.error(`web-app: could not open the default browser because ${reason}; visit ${webUrl} manually`) + }) + } } // This row's own activation can precede a sibling failure. The app owns - // readiness by waiting for its Loader tree, or prints at once in a + // readiness by waiting for its Loader tree, or announces at once in a // hand-built context without Loader. const settled = ctx.get('loader')?.await() - if (settled === undefined) printUrl() + if (settled === undefined) announceReady() else { void settled.then(() => { // The tree can be disposed while the boot was in flight (early - // SIGTERM); a URL line for a dead server would only mislead, and - // reading the torn-down port would turn a clean shutdown into a crash. - if (ctx.get('webServer') !== undefined) printUrl() + // SIGTERM); a URL line or browser tab for a dead server would only + // mislead, and reading the torn-down port would turn a clean shutdown + // into a crash. + if (ctx.get('webServer') !== undefined) announceReady() // Loader reports a failed boot; this row only stays quiet. }, () => {}) } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index af6997cff7..9faad244dd 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -1,6 +1,6 @@ /** * The web app's command-line provider: it parses the `dsh --profile web` flag - * family (`--host`, `--port`, `--trusted-host`) and its `--help` + * family (`--host`, `--port`, `--trusted-host`, `--no-open`) and its `--help` * text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}. * Ordinary rows inject that service before reading it from lazy config. * @module @deepseek-ai/dsh-web-app/startup @@ -21,6 +21,8 @@ export const WEB_STARTUP_SERVICE = 'webStartup' /** What the web rows read from {@link WEB_STARTUP_SERVICE}. */ export interface WebStartupValues { + /** Whether this invocation opens the default browser after startup. */ + openBrowser: boolean /** `--host`, absent when the invocation did not name one. */ host?: string /** `--port`, absent when the invocation did not name one. */ @@ -32,6 +34,7 @@ export interface WebStartupValues { /** The web flag family, as commander parsed it. */ interface WebOptions { host?: string + open: boolean port?: string trustedHost?: string[] } @@ -46,11 +49,13 @@ function webCommand(): Command { .description('Serve the DeepSeek Harness browser UI.') .helpOption('-h, --help', 'show this help') .option('--host ', 'bind host') + .option('--no-open', 'do not open the Web UI in the default browser') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .addHelpText('after', ` Examples: dsh --profile web serve on the composed host and port + dsh --profile web --no-open serve without opening a browser dsh --profile web --port 8080 serve on another port `) } @@ -73,6 +78,7 @@ export function apply(ctx: Context): void { program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) } ctx.provide(WEB_STARTUP_SERVICE, { + openBrowser: options.open, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, trustedHosts: options.trustedHost ?? [], diff --git a/packages/bundle/web-app/tests/browser-open.spec.ts b/packages/bundle/web-app/tests/browser-open.spec.ts new file mode 100644 index 0000000000..0e2e22649d --- /dev/null +++ b/packages/bundle/web-app/tests/browser-open.spec.ts @@ -0,0 +1,101 @@ +/** Default-browser startup over a real Loader tree and listening Web server. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { apply, internals } from '../src/index.ts' + +const contexts: Context[] = [] +const tempRoots: string[] = [] +const originalResolveDistIndex = internals.resolveDistIndex +const originalOpenBrowser = internals.openBrowser + +beforeEach(() => { + vi.stubEnv('SSH_CONNECTION', '') + vi.stubEnv('SSH_TTY', '') +}) + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) + internals.resolveDistIndex = originalResolveDistIndex + internals.openBrowser = originalOpenBrowser + vi.unstubAllEnvs() + Reflect.deleteProperty(globalThis, '__dshWebAppApply') + Reflect.deleteProperty(globalThis, '__dshWebServer') +}) + +describe('web app browser startup', () => { + it('opens the canonical URL only after the complete page is reachable', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-')) + tempRoots.push(root) + const dist = join(root, 'dist') + mkdirSync(dist) + const index = join(dist, 'index.html') + writeFileSync(index, 'ready') + internals.resolveDistIndex = () => index + + const webserverModule = join(root, 'webserver.mjs') + const webAppModule = join(root, 'web-app.mjs') + writeFileSync(webserverModule, 'export default globalThis.__dshWebServer\n') + writeFileSync(webAppModule, [ + "export const name = 'fixture-web-app'", + "export const inject = ['webServer']", + 'export const apply = (ctx, config) => globalThis.__dshWebAppApply(ctx, config)', + '', + ].join('\n')) + const config = join(root, 'cordis.yml') + writeFileSync(config, [ + '- id: webserver', + ` name: ${pathToFileURL(webserverModule).href}`, + ' config:', + ' host: 127.0.0.1', + ' port: 0', + '- id: web-app', + ` name: ${pathToFileURL(webAppModule).href}`, + ' config:', + ' openBrowser: true', + ' printUrl: false', + ' surfaceContext: false', + ' trustedHosts: []', + '', + ].join('\n')) + + const globals = globalThis as unknown as { + __dshWebAppApply: typeof apply + __dshWebServer: typeof WebServer + } + globals.__dshWebAppApply = apply + globals.__dshWebServer = WebServer + + let openedUrl: string | undefined + let openedStatus: number | undefined + let resolveOpened!: () => void + const opened = new Promise((resolve) => { resolveOpened = resolve }) + internals.openBrowser = async (url) => { + openedUrl = url + openedStatus = (await fetch(url)).status + resolveOpened() + } + + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(config).href }, + }) + await ctx.loader.await() + await opened + + expect(openedUrl).toBe(`http://127.0.0.1:${String(ctx.webServer.port)}`) + expect(openedStatus).toBe(200) + }) +}) diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 26e347a503..ba3806232c 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -56,6 +56,7 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) ` inject: [${WEB_STARTUP_SERVICE}]`, ' config:', " host: !!js ctx.webStartup.host ?? '127.0.0.1'", + ' openBrowser: !!js ctx.webStartup.openBrowser', ' port: !!js ctx.webStartup.port ?? 3080', ' trustedHosts: !!js ctx.webStartup.trustedHosts', '- id: provider', @@ -89,12 +90,14 @@ describe('web command-line provider', () => { it('publishes each flag and releases direct service expressions', async () => { const { values, observed } = await bootProvider([ '--host', '127.0.0.1', + '--no-open', '--port', '8080', '--trusted-host', 'lab.internal', 'lab-2.internal', '--trusted-host', '10.0.0.9', ]) expect(values).toEqual({ host: '127.0.0.1', + openBrowser: false, port: 8080, trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'], }) @@ -104,9 +107,10 @@ describe('web command-line provider', () => { it('leaves deployment values to each consumer when flags omit them', async () => { const { values, observed } = await bootProvider([]) - expect(values).toEqual({ trustedHosts: [] }) + expect(values).toEqual({ openBrowser: true, trustedHosts: [] }) expect(observed.readerConfig).toEqual({ host: '127.0.0.1', + openBrowser: true, port: 3080, trustedHosts: [], }) @@ -115,6 +119,7 @@ describe('web command-line provider', () => { it('prints its own help and leaves the consumer pending', async () => { const { values, observed } = await bootProvider(['--help']) expect(observed.out).toContain('dsh --profile web') + expect(observed.out).toContain('--no-open') expect(observed.out).toContain('--trusted-host') expect(values).toBeUndefined() expect(observed.readerConfig).toBeUndefined() diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index a4e99e8e9b..ee4593cfd0 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -1,19 +1,28 @@ /** * Web runtime glue behavior: dist resolution through the bundle's own hook, * the frontend-static child claiming the fallback seat, the web-surface - * prompt section and bash runtime variables, and URL-line printing with the - * runtime's bind-dependent LAN snapshot. + * prompt section and bash runtime variables, and readiness publication through + * the URL line and default-browser handoff. */ +import { EventEmitter } from 'node:events' +import { spawn, type ChildProcess } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { WebServer } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, internals } from '../src/index.ts' +vi.mock('node:child_process', async importOriginal => ({ + ...await importOriginal(), + spawn: vi.fn(), +})) + vi.mock('node:os', async importOriginal => ({ ...await importOriginal(), networkInterfaces: () => ({ @@ -24,14 +33,30 @@ vi.mock('node:os', async importOriginal => ({ let dist: string | undefined +beforeEach(() => { + vi.stubEnv('SSH_CONNECTION', '') + vi.stubEnv('SSH_TTY', '') +}) + afterEach(() => { vi.restoreAllMocks() + vi.mocked(spawn).mockReset() + vi.unstubAllEnvs() internals.resolveDistIndex = originalResolve + internals.openBrowser = originalOpenBrowser if (dist !== undefined) rmSync(dist, { recursive: true, force: true }) dist = undefined }) const originalResolve = internals.resolveDistIndex +const originalOpenBrowser = internals.openBrowser + +type BrowserLauncher = ChildProcess & { stderr: PassThrough } + +/** Minimal browser-launcher process for the native handoff adapter. */ +function launcher(): BrowserLauncher { + return Object.assign(new EventEmitter(), { stderr: new PassThrough() }) as unknown as BrowserLauncher +} /** Stage a dist fixture and point the bundle's resolver at it. */ function stageDist(): string { @@ -70,9 +95,14 @@ interface BashContribution { } describe('web-app runtime glue', () => { - it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => { + it('mounts dist serving, prompt section, bash variables, and publishes the URL with the LAN snapshot', async () => { stageDist() const ctx = new Context() + // Editor markers and a project .env SSH value do not establish a remote launch. + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([ + { source: 'process', values: { VSCODE_IPC_HOOK_CLI: '/tmp/local-vscode-ipc' } }, + { source: 'project-env', path: '/work/.env', values: { SSH_CONNECTION: 'stale-project-value' } }, + ])) const { server, seat } = fakeHttpServer('0.0.0.0') ctx.provide('webServer', server) const contributions: BashContribution[] = [] @@ -83,8 +113,11 @@ describe('web-app runtime glue', () => { }, } as never) provideLoader(ctx) - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) + const lifecycle: string[] = [] + const log = vi.spyOn(console, 'log').mockImplementation((message) => { lifecycle.push(String(message)) }) + const openBrowser = vi.fn(async (url: string) => { lifecycle.push(`open:${url}`) }) + internals.openBrowser = openBrowser + apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) await ctx.plugin(SystemPrompt, { persona: '' }) // Settle the injected registrations. await new Promise(resolve => setTimeout(resolve, 0)) @@ -95,6 +128,13 @@ describe('web-app runtime glue', () => { trustedHosts: ['192.168.1.5', 'lab.internal'], }) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') + expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable') + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567') + expect(lifecycle).toEqual([ + 'dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)', + 'dsh web: opening the default browser; pass --no-open to disable', + 'open:http://127.0.0.1:4567', + ]) const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') const section = assembly.sections.find(entry => entry.name === 'app:web-surface') @@ -107,15 +147,18 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) - it('stays quiet with printUrl off', async () => { + it('publishes no readiness side effect when printing and browser opening are disabled', async () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] })) + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text) .toContain('rebuilding the affected Web artifacts') @@ -133,7 +176,7 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - apply(ctx, new Config({ printUrl: false, surfaceContext: false, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) const assembly = await ctx.systemPrompt.assemble() @@ -148,44 +191,69 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await ctx.fiber.dispose() }) - it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => { + it.each([ + ['SSH_CONNECTION', '10.0.0.2 55000 10.0.0.9 22'], + ['SSH_TTY', '/dev/pts/3'], + ] as const)('prints the host URL but skips browser handoff when %s marks an SSH launch', async (name, value) => { + vi.stubEnv(name, value) stageDist() - // Settlement path: the line waits for loader.await() so supervisors can - // RPC immediately after observing it. + const ctx = new Context() + ctx.provide('webServer', fakeHttpServer().server) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + expect(openBrowser).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('defers readiness publication until Loader settlement and drops it on failure or teardown', async () => { + stageDist() + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + // Settlement path: both actions wait for loader.await() so their consumers + // can request the complete app immediately. const settled = new Context() settled.provide('webServer', fakeHttpServer().server) let release: () => void const settlement = new Promise((resolve) => { release = resolve }) provideLoader(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(settled, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(settled, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() release!() await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567') await settled.fiber.dispose() // Failed path: Loader reports the sibling failure; the app prints no URL // for a process that is about to exit. log.mockClear() + openBrowser.mockClear() const failed = new Context() failed.provide('webServer', fakeHttpServer().server) provideLoader(failed, async () => { throw new Error('boot failed') }) - apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(failed, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() await failed.fiber.dispose() // Torn-down path: settlement resolves after the webserver is gone — no // line, no crash. log.mockClear() + openBrowser.mockClear() const torn = new Context() const child = torn.plugin((childCtx: Context) => { childCtx.provide('webServer', fakeHttpServer().server) @@ -194,11 +262,12 @@ describe('web-app runtime glue', () => { let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) provideLoader(torn, () => tornSettlement) - apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) await child.dispose() // the webServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() await torn.fiber.dispose() }) @@ -210,7 +279,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('webServer', server) - apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) await expect(ctx.systemPrompt.assemble()).rejects.toThrow('webServer service missing') @@ -228,4 +297,82 @@ describe('web-app runtime glue', () => { expect((error as Error).message).toContain('frontend dist not built') } }) + + it.each([ + ['Error', new Error('no desktop'), 'no desktop'], + ['non-Error', 'desktop unavailable', 'desktop unavailable'], + ] as const)('keeps the server running and reports the manual URL when a browser failure is %s', async (_kind, failure, reason) => { + stageDist() + const ctx = new Context() + ctx.provide('webServer', fakeHttpServer().server) + internals.openBrowser = vi.fn(async () => { throw failure }) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {}) + apply(ctx, new Config({ openBrowser: true, printUrl: false, surfaceContext: false, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable') + expect(diagnostic).toHaveBeenCalledWith( + `web-app: could not open the default browser because ${reason}; visit http://127.0.0.1:4567 manually`, + ) + expect(ctx.get('webServer')).toBeDefined() + await ctx.fiber.dispose() + }) + + it('scrubs the helper environment and reports helper spawn or exit failures', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'must-not-reach-browser') + vi.stubEnv('DSH_HOME', '/must-not-reach-browser') + const completed = launcher() + vi.mocked(spawn).mockReturnValueOnce(completed) + const completion = originalOpenBrowser('http://127.0.0.1:4567') + const [command, args, options] = vi.mocked(spawn).mock.calls[0]! + expect(command).toBe(process.execPath) + expect(args).toEqual([ + '--input-type=module', + '--eval', expect.stringContaining('await import('), + '--', 'http://127.0.0.1:4567', + ]) + expect(args?.[2]).toContain("if (process.platform === 'win32')") + expect(args?.[2]).toContain('launcher.ref()') + expect(options?.env).not.toHaveProperty('DEEPSEEK_API_KEY') + expect(options?.env).not.toHaveProperty('DSH_HOME') + expect(options?.env?.PATH).toBe(process.env.PATH) + expect(options?.stdio).toEqual(['ignore', 'inherit', 'pipe']) + completed.emit('close', 0) + await expect(completion).resolves.toBeUndefined() + expect(completed.listenerCount('error')).toBe(0) + + const completedWithStderr = launcher() + vi.mocked(spawn).mockReturnValueOnce(completedWithStderr) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const completionWithStderr = originalOpenBrowser('http://127.0.0.1:4567') + completedWithStderr.stderr?.write('launcher note\n') + completedWithStderr.emit('close', 0) + await expect(completionWithStderr).resolves.toBeUndefined() + expect(stderr).toHaveBeenCalledWith('launcher note\n') + + const failedWithReason = launcher() + vi.mocked(spawn).mockReturnValueOnce(failedWithReason) + const reasonFailure = originalOpenBrowser('http://127.0.0.1:4567') + const reasonAssertion = expect(reasonFailure).rejects.toThrow('desktop unavailable') + failedWithReason.stderr?.write('Error: desktop unavailable\n at fixture') + failedWithReason.emit('close', 1) + await reasonAssertion + + const failed = launcher() + vi.mocked(spawn).mockReturnValueOnce(failed) + const failure = originalOpenBrowser('http://127.0.0.1:4567') + const failureAssertion = expect(failure).rejects.toThrow('exited with code 3') + await Promise.resolve() + failed.emit('close', 3) + await failureAssertion + + const errored = launcher() + vi.mocked(spawn).mockReturnValueOnce(errored) + const error = originalOpenBrowser('http://127.0.0.1:4567') + const errorAssertion = expect(error).rejects.toThrow('spawn failed') + await Promise.resolve() + errored.emit('error', new Error('spawn failed')) + await errorAssertion + expect(errored.listenerCount('close')).toBe(0) + }) }) diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index 77d823538c..c00b64f5a9 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -29,12 +29,18 @@ { "path": "../../host/webserver" }, + { + "path": "../../util/launch-environment" + }, { "path": "../../core/system-prompt" }, { "path": "../../shell/shell-env" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 86a23bbf12..9baa35ebd0 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -66,6 +66,10 @@ Npm sections describe installation and development relationships; each build fac 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. +## Build-time browser environment + +Client business code may statically read `process.env.DSH_CLIENT_*`; every referenced value is public artifact content. The shared build-environment helper gives Vite and dynamic tsdown bundles the same build-process values, resolves unset names to `undefined`, and exposes no dynamic lookup or enumeration. A complete root build records the exact public values and a digest of all client artifacts; release and built-artifact consumers reject a missing or stale record. Use runtime configuration for choices that must change after build. + ## Shared modules and the module graph A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI libraries; `PRELOADED_CLIENT_EXTERNALS` names dynamic rows, currently runtime, whose ordinary `lib/client.js` factory arrives before shell boot. diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index a1de337e80..6036e9160e 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: cff74ddb048df8b37643f4c44f01e2bda4160a77 -README.zh.md: 5e5d623509b9152fb7b59ea228cfa42720b737af +README.md: b18aad486cd7d3fafe8261fee61fa9e26a7feaa6 +README.zh.md: e840493b0135cfa4f8e294efe8c612471a94e026 diff --git a/packages/client/README.md b/packages/client/README.md index cff74ddb04..b18aad486c 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -20,6 +20,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-attachment/`](ui-attachment/README.md) | Registers composer and message-image attachment presentation. | | [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. | | [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. | +| [`ui-brand-official/`](ui-brand-official/README.md) | Fills the generic browser-brand slots with the official name and marks. | | [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. | | [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. | | [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. | @@ -29,6 +30,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-commands/`](ui-commands/README.md) | Provides session-aware command discovery and dispatch. | | [`ui-input-trigger/`](ui-input-trigger/README.md) | Coordinates inline command and reference suggestions. | | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. | +| [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source. | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. | | [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs in the conversation header. | | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5e5d623509..e840493b01 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -20,6 +20,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-attachment/`](ui-attachment/README.md) | 注册输入框与消息图片的附件呈现。 | | [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 | | [`ui-sidebar/`](ui-sidebar/README.md) | 展示工作区与会话导航。 | +| [`ui-brand-official/`](ui-brand-official/README.md) | 使用官方名称和标记填充通用浏览器品牌 slot。 | | [`ui-workspace/`](ui-workspace/README.md) | 提供工作区选择与创建界面。 | | [`ui-conversation/`](ui-conversation/README.md) | 展示当前对话及其输入界面。 | | [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 | @@ -29,6 +30,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-commands/`](ui-commands/README.md) | 提供会话感知的命令发现与分发。 | | [`ui-input-trigger/`](ui-input-trigger/README.md) | 协调内联命令和引用建议。 | | [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 | +| [`ui-reference/`](ui-reference/README.md) | 统一的 Web `@file` / `@session` 引用 source。 | | [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent(子 agent)导航、子级 transcript(文本记录)的状态和内联引用。 | | [`ui-jobs/`](ui-jobs/README.md) | 在会话标题栏列出当前会话的后台任务。 | | [`ui-model-selection/`](ui-model-selection/README.md) | 在对话界面中提供模型选择。 | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 1064e82e37..cfcae2eb1f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1563,6 +1563,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // live under one workspace, whose account carries them in attach order. const wid = (raw: string): WorkspaceId => raw as WorkspaceId const fixtureEpoch = new Date(Date.now() - 300_000).toISOString() + const FIXTURE_HOME = '/home/fixture' const workspaces: WorkspaceView[] = options.empty ? [] : [{ workspaceId: wid('fx-ws-fixture'), path: '/tmp/fixture', @@ -1570,6 +1571,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')], createdAt: fixtureEpoch, updatedAt: fixtureEpoch, + }, { + workspaceId: wid('fx-ws-home'), + path: `${FIXTURE_HOME}/Documents/project`, + title: 'project', + sessionIds: [], + createdAt: fixtureEpoch, + updatedAt: fixtureEpoch, }] let nextWorkspace = 1 // Registry-global archive set mirroring the host: archived sessions keep @@ -1580,7 +1588,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // deterministic content mirroring the design mock so assembled Web tests // and snapshots can walk it. Leaves are materialized lazily: a child listed // by its parent lists as empty until something is created inside it. - const FIXTURE_HOME = '/home/fixture' const directoryTree = new Map([ ['/', ['home']], ['/home', ['fixture']], @@ -1859,6 +1866,51 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) /** Canonical fixture implementation of the generated Goal Remote contract. */ + /** Canonical fixture implementation of the generated reference-discovery Remote contracts. */ + const referenceRemotes = { + files(id: SessionId, query: string): RpcResult<{ path: string; kind: 'file' | 'directory' }[]> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const needle = query.toLocaleLowerCase() + const items = [ + { path: 'notes', kind: 'directory' as const }, + { path: 'README.md', kind: 'file' as const }, + { path: 'notes/demo.txt', kind: 'file' as const }, + ].filter(item => item.path.toLocaleLowerCase().includes(needle)) + return { ok: true, value: items } + }, + sessions(id: SessionId, query: string): RpcResult<{ + sessionId: SessionId + label: string + cwd?: string + createdAt: number + mention: string + }[]> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const needle = query.toLocaleLowerCase() + const value = sessions + .filter(item => item.sessionId !== id) + .filter(item => String(item.sessionId).toLocaleLowerCase().includes(needle) + || item.cwd?.toLocaleLowerCase().includes(needle) === true) + .map((item) => { + const label = item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId) + const encoded = btoa(JSON.stringify(item.sessionId)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, '') + return { + sessionId: item.sessionId, + label, + ...item.cwd === undefined ? {} : { cwd: item.cwd }, + createdAt: item.updatedAt, + mention: `@[${label}](dsh-session:${encoded})`, + } + }) + return { ok: true, value } + }, + } + const goalRemotes = { create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { const missing = requireGoalSession(id) @@ -2565,7 +2617,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, host: { describe: request => ok(request, { - version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, canOpenPath: true, + version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, }), // Deterministic native pick: the keyless lanes drive the full // pick-then-adopt path without an OS chooser (design-mock content, @@ -3046,6 +3098,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { args: { agentId: SessionId line?: string + query?: string images?: readonly unknown[] ref?: { id: string; revision: number } request?: { objective?: string; maxGoalRounds?: number } @@ -3055,6 +3108,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { switch (endpoint) { case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) + case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) + case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: args.request?.objective as string, ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index c4470b13aa..9847d48cdf 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -62,7 +62,7 @@ export interface ConnectionHandle { readonly api: IApiClient /** Whether the current page authority is loopback; non-browser contexts default to true. */ readonly isLoopback: boolean - /** Generation-scoped Host facts, including native path-open capability. */ + /** Generation-scoped Host facts, including the account home and native path-open capability. */ readonly hostDescription: HostDescriptionSource /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index e5290c0dbb..7965d627f4 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -80,7 +80,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff expect(connected).toBe(0) // never announced during the failed generation - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() @@ -102,7 +102,7 @@ describe('connection lifecycle', () => { }, }) } - return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) } let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) @@ -188,7 +188,7 @@ describe('connection lifecycle', () => { describeCalls++ return describeCalls === 1 ? firstDescribe.promise - : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) } const states: ConnectionState[] = [] let connected = 0 @@ -201,7 +201,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) }) api.endStreams() - firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(describeCalls).toBe(2) }) await vi.waitFor(() => { expect(connected).toBe(1) }) @@ -283,7 +283,7 @@ describe('connection lifecycle', () => { controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(3) }) - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission } finally { diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index bee4fc0ce0..7c9dc6accb 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -74,10 +74,11 @@ export class FakeApiClient implements IApiClient { version: string cwd: string attachedSessions: number + home: string canOpenPath: boolean }>> = () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true, + version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 4fe4a4ff2d..ed5089d345 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -523,7 +523,9 @@ describe('createFixtureApi', () => { it('describe answers the fixture identity', async () => { const api = createFixtureApi() const response = await api.host.describe(req({})) - expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } }) + expect(response.result).toMatchObject({ + ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1, home: '/home/fixture' }, + }) const empty = await createFixtureApi({ empty: true }).host.describe(req({})) expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) }) @@ -548,10 +550,16 @@ describe('createFixtureApi', () => { const api = createFixtureApi() const listed = await api.workspace.list(req({})) if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.items).toEqual([expect.objectContaining({ - workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', - sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], - })]) + expect(listed.result.value.items).toEqual([ + expect.objectContaining({ + workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', + sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], + }), + expect.objectContaining({ + workspaceId: 'fx-ws-home', path: '/home/fixture/Documents/project', title: 'project', + sessionIds: [], + }), + ]) // path collision → the existing entity comes back, created:false, no frame. const reused = await api.workspace.create(req({ path: '/tmp/fixture' })) if (!reused.result.ok) throw new Error('reuse failed') diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 837d27ae0a..3e383dff61 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: cad8a08f015a7115ebff76cfce4d3ab5eb7a175e -README.zh.md: 262ab1eeb8ce2d09e801e2af790046d8cdc11e55 +README.md: eae49163c14f122a3603bcaa8a61aaac7a9a7270 +README.zh.md: 9e8f82a0bcf0d2021c9b16bd51acf3c72dd04cb2 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index cad8a08f01..eae49163c1 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,6 +26,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base SlotRegistry gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; ui-renderer creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. +`abbreviateHomePath` is the display-only POSIX home abbreviation used by Web Workspace hover cards and Tool summaries; a Windows drive or UNC path stays verbatim, and a missing, empty, or filesystem-root home leaves the path unchanged. + `indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. `SessionListState.jobsBySession` mirrors the Host's `session/jobs` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order. @@ -54,7 +56,7 @@ The Chat builder keeps one mutable keyed store per Session. Content updates noti ## Trajectory request data -Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. +Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A cancellation-finalized `assistant/message` retains its durable result seq and provider provenance but does not complete the request; `step/end` classifies that request as an error. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. ## Code Mode child-call tree diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 262ab1eeb8..9e8f82a0bc 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,6 +26,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 SlotRegistry 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;ui-renderer 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或条目 store。 +`abbreviateHomePath` 是 Web Workspace 悬停卡片与 Tool 摘要使用的仅展示 POSIX 家目录缩写;Windows 盘符或 UNC 路径保持原样,缺失、空或文件系统根的 home 不改写路径。 + `indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 `SessionListState.jobsBySession` 按 last-wins 镜像宿主的 `session/jobs` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。 @@ -54,7 +56,7 @@ Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新 ## Trajectory 请求数据 -Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 +Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。由取消定稿的 `assistant/message` 会保留持久结果 seq 和提供方信息,但不会将请求标记为完成;`step/end` 会把该请求归类为错误。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 ## Code Mode 子调用树 diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index c3ef72e253..e07267d487 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -38,7 +38,11 @@ export interface ISession { * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. * @returns acceptance, or the business error (also mirrored into snapshot.promptError). */ - prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise> + prompt( + content: PromptContentPart[], + mode: 'queue' | 'steer', + signal?: AbortSignal, + ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index bc8eb73330..90a2b2f5f3 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -46,7 +46,7 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from './workspaces/service.ts' -export { resolveWorkspacePath } from './workspaces/path.ts' +export { abbreviateHomePath, resolveWorkspacePath } from './workspaces/path.ts' // Contract only: the scope implementation and its Host transport belong to // dsh-client-ui-settings (see that package's settings-scope.ts). export type { @@ -85,7 +85,7 @@ export { } from './sessions/conversation.ts' export { emptyAssistantBlock } from './sessions/partial.ts' export { isTokenDelta } from './sessions/assistant-timing.ts' -export { contextForm, contextProvenance } from './sessions/context-provenance.ts' +export { contextForm, contextProvenance, sessionRecallLabels } from './sessions/context-provenance.ts' export { displayFailureMessage } from './sessions/failure-display.ts' export type { ConversationContext, ConversationContextOriginKind, diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 5912ea388a..dbd3b2dd30 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -58,6 +58,19 @@ function joined(names: string[]): string | null { return names.length > 0 ? names.join(', ') : null } +/** + * The referenced-session labels of one durable `session-reference` recall + * source, in first-seen order; empty for every other source shape, including + * a foreign or older log whose reference entries carry no readable label. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns distinct non-empty reference labels. + */ +export function sessionRecallLabels(source: unknown): string[] { + const record = asRecord(source) + if (record === null || readString(record, 'kind') !== 'session-reference') return [] + return collect(record, 'references', 'label') +} + /** * Project one durable message source onto its transcript role and producer name. * diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 698d8ab88f..44b6f7f42d 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -92,14 +92,14 @@ export interface AssistantTiming { completedTime: number } -/** A finalized (or interruption-frozen) assistant message. */ +/** A finalized assistant message or an interruption-frozen streaming prefix. */ export interface AssistantMessageNode { kind: 'assistant' seq: number /** - * Stable identity of the finalized model output, carried from the - * `assistant/message` event. Absent on interruption-frozen partials: those - * were never finalized, so they address no durable message. + * Stable identity carried from the `assistant/message` event. Absent only on + * synthetic interruption fallbacks assembled from chunks without a durable + * assistant message. */ messageId?: MessageId /** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */ @@ -112,8 +112,9 @@ export interface AssistantMessageNode { requestConfig?: AssistantRequestConfig /** Timing derived from the recorded step/chunk/message event sequence. */ timing?: AssistantTiming - /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker. - * Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */ + /** Prefix of an aborted turn, rendered with a 已停止 marker. A durable + * finalized prefix uses its event seq; a chunk-only fallback uses a fractional + * seq derived from the closing boundary to keep it ordered inside the flow. */ interrupted?: true } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index f38e3c757b..02939cf28f 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -187,7 +187,11 @@ export class Session implements SessionFace { * @param mode - queue appends after the current turn; steer interrupts it. * @returns the prompt result (also mirrored into promptError on failure). */ - async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise> { + async prompt( + content: PromptContentPart[], + mode: 'queue' | 'steer', + signal?: AbortSignal, + ): Promise> { this.promptError = null this.lastAgentError = null // Synchronous, before the first await: the blank → engaging edge must be @@ -204,7 +208,7 @@ export class Session implements SessionFace { mode, content, clientTimeZone: resolvedClientTimeZone(), - })).result + }, signal)).result } else if (this.address.mode === 'one-shot') { result = { ok: false, @@ -231,7 +235,7 @@ export class Session implements SessionFace { ? [{ type: 'text' as const, text: part.text }] : []), clientTimeZone: resolvedClientTimeZone(), - })).result + }, signal)).result result = routed.ok ? { ok: true, value: { accepted: true } } : routed } } diff --git a/packages/client/runtime/src/client/workspaces/path.ts b/packages/client/runtime/src/client/workspaces/path.ts index d1512177ad..8bb3aa6645 100644 --- a/packages/client/runtime/src/client/workspaces/path.ts +++ b/packages/client/runtime/src/client/workspaces/path.ts @@ -5,9 +5,32 @@ * @returns an absolute path when a workspace root is available, otherwise the original path. */ export function resolveWorkspacePath(cwd: string | undefined, path: string): string { - if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path + if (path.startsWith('/') || isWindowsStylePath(path)) return path if (cwd === undefined || cwd === '') return path const base = cwd.replace(/[/\\]+$/, '') const rel = path.replace(/^[/\\]+/, '') return `${base}/${rel}` } + +/** Drive-letter or UNC path; Web display must not rewrite these as `~`. */ +function isWindowsStylePath(value: string): boolean { + return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\') +} + +/** + * Display-only POSIX home abbreviation. Windows drive and UNC paths stay + * verbatim, including when `home` itself is a Windows path. A missing, empty, + * or filesystem-root `home` leaves `path` unchanged so `/` cannot become `~`. + * @param path - absolute or already-short display path. + * @param home - host account home from `host.describe`; absent skips abbreviation. + * @returns `~` or `~/…` for the POSIX home and its descendants, otherwise `path`. + */ +export function abbreviateHomePath(path: string, home?: string): string { + if (home === undefined || home === '') return path + if (isWindowsStylePath(path) || isWindowsStylePath(home)) return path + const root = home.replace(/\/+$/, '') + if (root === '' || root === '/') return path + if (path.replace(/\/+$/, '') === root) return '~' + if (path.startsWith(`${root}/`)) return `~${path.slice(root.length)}` + return path +} diff --git a/packages/client/runtime/src/env.d.ts b/packages/client/runtime/src/env.d.ts deleted file mode 100644 index 54d3d9d8f3..0000000000 --- a/packages/client/runtime/src/env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Bundler-replaced NODE_ENV: vite/tsdown substitute the literal, so browsers - * never evaluate a bare `process`. tsconfig carries no node types on purpose. - */ -declare const process: { env: { NODE_ENV?: string } } diff --git a/packages/client/runtime/tests/client-apply.client.spec.ts b/packages/client/runtime/tests/client-apply.client.spec.ts index 1026c55569..199181fe2e 100644 --- a/packages/client/runtime/tests/client-apply.client.spec.ts +++ b/packages/client/runtime/tests/client-apply.client.spec.ts @@ -91,7 +91,7 @@ describe('runtime client apply', () => { expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new') // Mux sink and onConnected route without throwing (manager semantics own the behavior). bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }) + bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) }) it('selects the recent Workspace once when the first baselines have no current session', async () => { @@ -104,7 +104,7 @@ describe('runtime client apply', () => { })) bench.api.onList = () => Promise.resolve(ok({ items: [] })) - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }) + bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) await flushMicrotasks() const sessions = bench.ctx.get('sessions') as SessionRuntime diff --git a/packages/client/runtime/tests/context-provenance.client.spec.ts b/packages/client/runtime/tests/context-provenance.client.spec.ts index e243660d60..b64902719c 100644 Binary files a/packages/client/runtime/tests/context-provenance.client.spec.ts and b/packages/client/runtime/tests/context-provenance.client.spec.ts differ diff --git a/packages/client/runtime/tests/fake-api.client.ts b/packages/client/runtime/tests/fake-api.client.ts index 33a0efbbfd..e5bdaa5f66 100644 --- a/packages/client/runtime/tests/fake-api.client.ts +++ b/packages/client/runtime/tests/fake-api.client.ts @@ -108,10 +108,11 @@ export class FakeApiClient implements IApiClient { version: string cwd: string attachedSessions: number + home: string canOpenPath: boolean }>> = () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true, + version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) diff --git a/packages/client/runtime/tests/path.client.spec.ts b/packages/client/runtime/tests/path.client.spec.ts new file mode 100644 index 0000000000..455df0d112 --- /dev/null +++ b/packages/client/runtime/tests/path.client.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { abbreviateHomePath, resolveWorkspacePath } from '../src/client/workspaces/path.ts' + +describe('abbreviateHomePath', () => { + it('collapses a POSIX home and its descendants', () => { + expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~') + expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~') + expect(abbreviateHomePath('/Users/u/Documents/project', '/Users/u')).toBe('~/Documents/project') + expect(abbreviateHomePath('/Users/u/Documents/project/', '/Users/u/')).toBe('~/Documents/project/') + }) + + it('keeps prefix-adjacent names and non-home paths', () => { + expect(abbreviateHomePath('/Users/u2/a.ts', '/Users/u')).toBe('/Users/u2/a.ts') + expect(abbreviateHomePath('/etc/hosts', '/Users/u')).toBe('/etc/hosts') + expect(abbreviateHomePath('src/a.ts', '/Users/u')).toBe('src/a.ts') + expect(abbreviateHomePath('~/already', '/Users/u')).toBe('~/already') + }) + + it('does not abbreviate when home is missing, empty, or the filesystem root', () => { + expect(abbreviateHomePath('/Users/u/a.ts')).toBe('/Users/u/a.ts') + expect(abbreviateHomePath('/Users/u/a.ts', '')).toBe('/Users/u/a.ts') + expect(abbreviateHomePath('/etc/hosts', '/')).toBe('/etc/hosts') + expect(abbreviateHomePath('/etc/hosts', '///')).toBe('/etc/hosts') + }) + + it('leaves Windows drive and UNC paths verbatim', () => { + expect(abbreviateHomePath('C:\\Users\\u\\project', 'C:\\Users\\u')).toBe('C:\\Users\\u\\project') + expect(abbreviateHomePath('C:/Users/u/project', '/Users/u')).toBe('C:/Users/u/project') + expect(abbreviateHomePath('/Users/u/project', 'C:\\Users\\u')).toBe('/Users/u/project') + expect(abbreviateHomePath('\\\\server\\share\\u', '\\\\server\\share\\u')).toBe('\\\\server\\share\\u') + }) +}) + +describe('resolveWorkspacePath', () => { + it('joins a relative path under cwd and passes absolute paths through', () => { + expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts') + expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') + expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts') + expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts') + }) +}) diff --git a/packages/client/runtime/tests/wire-events.client.spec.ts b/packages/client/runtime/tests/wire-events.client.spec.ts index f6798bd914..13365c1462 100644 --- a/packages/client/runtime/tests/wire-events.client.spec.ts +++ b/packages/client/runtime/tests/wire-events.client.spec.ts @@ -127,7 +127,7 @@ describe('wire event bridge', () => { const bench = await mount() let resets = 0 bench.ctx.on('connection/reset', () => { resets++ }) - const description = { version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true } + const description = { version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true } bench.sinks?.onConnected?.(description) bench.sinks?.onConnected?.(description) // second generation after a reconnect expect(resets).toBe(2) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 72f0346680..44aab8cf1b 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -17,6 +17,7 @@ import type { UserConfig } from 'tsdown' import { transform } from 'lightningcss' import { optionalStringArray } from './modules/src/client/manifest.ts' import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts' +import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts' /** * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline @@ -57,7 +58,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|file-reference|session|llm|tools|brand)(\/|$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below @@ -470,6 +471,7 @@ function clientConfig(id: string, entry: string): UserConfig { // key: zustand probes `import.meta.env ? import.meta.env.MODE : ...`, and // the truthiness probe would otherwise survive as an empty import.meta. define: { + ...clientBuildEnvironmentDefines(process.env), 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'), 'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'), 'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }), diff --git a/packages/client/ui-brand-official/README.i18n.yaml b/packages/client/ui-brand-official/README.i18n.yaml new file mode 100644 index 0000000000..9d438ad955 --- /dev/null +++ b/packages/client/ui-brand-official/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-brand-official/README.md +README.md: 7197bc7f4731cd3e6471549900e4868ad6d4796d +README.zh.md: 14d3ee3f1aa2f12ddd20b851d4034e1417baf5c9 diff --git a/packages/client/ui-brand-official/README.md b/packages/client/ui-brand-official/README.md new file mode 100644 index 0000000000..7197bc7f47 --- /dev/null +++ b/packages/client/ui-brand-official/README.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-brand-official + +English | [中文](README.zh.md) + +This package fills `sidebar.brand.mark`, `sidebar.brand.name`, and `conversation.hero.brand.mark` only when `DSH_CLIENT_BUILD_PROFILE` is `official`. Other builds load the plugin but register no occupants, leaving the shell fallbacks visible. + +The three occupants install as one declaration-aware registration set through nested `slots.inject()` calls. The package therefore works whether its row activates before or after the sidebar and conversation declarers, withdraws all occupants when either declaration collapses, and leaves no partial brand mix during HMR. It retains no runtime state. The node half is an empty Loader seat, and the browser title remains a build-environment concern outside this package. + +## Model Experience + +None, as the package contributes browser presentation only; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **The package supplies one occupant set** — alternative presentation belongs in another Cordis package occupying the same slots. +- **The browser title is independent** — `DSH_CLIENT_TITLE` selects title text at build time rather than through a UI slot. diff --git a/packages/client/ui-brand-official/README.zh.md b/packages/client/ui-brand-official/README.zh.md new file mode 100644 index 0000000000..14d3ee3f1a --- /dev/null +++ b/packages/client/ui-brand-official/README.zh.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-brand-official + +[English](README.md) | 中文 + +仅当 `DSH_CLIENT_BUILD_PROFILE` 为 `official` 时,本包才填充 `sidebar.brand.mark`、`sidebar.brand.name` 和 `conversation.hero.brand.mark`。其他构建仍会加载插件,但不注册 occupant,因此显示 shell fallback。 + +三个占位者通过嵌套的 `slots.inject()` 作为一组声明感知注册安装。因此无论该包的条目先于还是后于侧边栏和会话声明方激活,它都能工作;任一声明折叠时会撤回全部占位者,HMR 期间不会留下混合品牌。它不保留运行时状态。node 半边是空的 Loader seat;浏览器标题仍属于本包之外的构建环境事项。 + +## 模型体验 + +无,因为本包只贡献浏览器呈现;这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;本包既不组装也不发送 provider 请求。 + +## 已知限制与暂缓事项 + +- **本包只提供一组 occupant** —— 其他呈现应由占用相同 slot 的另一个 Cordis 包提供。 +- **浏览器标题相互独立** —— `DSH_CLIENT_TITLE` 在构建期选择标题文字,而不经过 UI slot。 diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json new file mode 100644 index 0000000000..25c1c93cab --- /dev/null +++ b/packages/client/ui-brand-official/package.json @@ -0,0 +1,72 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-brand-official", + "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar and conversation Hero slots", + "version": "0.1.0-rc.7", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-brand-official" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-sidebar" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-brand-official/src/client/Brand.tsx b/packages/client/ui-brand-official/src/client/Brand.tsx new file mode 100644 index 0000000000..4e0a60fd26 --- /dev/null +++ b/packages/client/ui-brand-official/src/client/Brand.tsx @@ -0,0 +1,22 @@ +import { BrandWordmark, FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HeroBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SidebarBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-sidebar/client' + +type OfficialBrandMarkProps = HeroBrandMarkOwnerProps & SidebarBrandMarkOwnerProps + +/** + * Render the official mark with the presentation requested by its host surface. + * @param props - Host-supplied mark presentation. + * @returns the official whale mark. + */ +export function OfficialBrandMark({ size, className }: OfficialBrandMarkProps) { + return +} + +/** + * Render the official name artwork without its independently slotted mark. + * @returns the official name wordmark. + */ +export function OfficialBrandName() { + return +} diff --git a/packages/client/ui-brand-official/src/client/index.ts b/packages/client/ui-brand-official/src/client/index.ts new file mode 100644 index 0000000000..b291bda3e7 --- /dev/null +++ b/packages/client/ui-brand-official/src/client/index.ts @@ -0,0 +1,23 @@ +/** Official DeepSeek Harness occupants for the generic browser-brand slots. */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' +import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx' + +/** Required service: the UI slot registry. */ +export const inject = ['slots'] + +/** + * Fill every shipped brand slot as one declaration-aware registration set. + * @param ctx - Client root context. + */ +export function apply(ctx: ClientContext): void { + if (process.env.DSH_CLIENT_BUILD_PROFILE !== 'official') return + ctx.slots.inject('sidebar.brand.mark', () => + ctx.slots.inject('sidebar.brand.name', () => + ctx.slots.inject('conversation.hero.brand.mark', function* () { + yield ctx.slots.register({ name: 'sidebar.brand.mark' }, OfficialBrandMark) + yield ctx.slots.register({ name: 'sidebar.brand.name' }, OfficialBrandName) + yield ctx.slots.register({ name: 'conversation.hero.brand.mark' }, OfficialBrandMark) + }))) +} diff --git a/packages/client/ui-brand-official/src/index.ts b/packages/client/ui-brand-official/src/index.ts new file mode 100644 index 0000000000..df38f3cfa5 --- /dev/null +++ b/packages/client/ui-brand-official/src/index.ts @@ -0,0 +1,7 @@ +/** + * Official browser-brand plugin, node half. The empty apply gives Loader a + * host-side row while the browser half ships through `exports["./client"]`. + */ + +/** Host plugin body — this package contributes browser presentation only. */ +export function apply(): void {} diff --git a/packages/client/ui-brand-official/src/invariant.ts b/packages/client/ui-brand-official/src/invariant.ts new file mode 100644 index 0000000000..574054c383 --- /dev/null +++ b/packages/client/ui-brand-official/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-brand-official`. + * @module @deepseek-ai/dsh-client-ui-brand-official/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-brand-official' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-brand-official-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the package retains no mutable state, and its three + * slot occupants install and leave through one transactional effect. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx b/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx new file mode 100644 index 0000000000..ee5275b9b7 --- /dev/null +++ b/packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' +import { apply, inject } from '../src/client/index.ts' +import { OfficialBrandMark, OfficialBrandName } from '../src/client/Brand.tsx' + +afterEach(() => { + cleanup() + vi.unstubAllEnvs() +}) + +const HOLES = [ + 'sidebar.brand.mark', + 'sidebar.brand.name', + 'conversation.hero.brand.mark', +] as const + +async function bench(declare = true) { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const slots = ctx.get('slots') as SlotRegistry + const declareHoles = () => slots.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, () => null) + const disposeHoles = declare ? declareHoles() : undefined + return { ctx, slots, declareHoles, disposeHoles } +} + +describe('official browser-brand plugin', () => { + it('declares only the slot service it uses', () => { + expect(inject).toEqual(['slots']) + }) + + it('leaves every slot empty outside the official build profile', async () => { + vi.stubEnv('DSH_CLIENT_BUILD_PROFILE', 'local') + const subject = await bench() + await subject.ctx.plugin({ inject: [...inject], apply }).await() + for (const hole of HOLES) expect(subject.slots.entries(hole)).toHaveLength(0) + }) + + it('fills declarations before or after apply and removes every occupant on teardown', async () => { + vi.stubEnv('DSH_CLIENT_BUILD_PROFILE', 'official') + const before = await bench() + const fiber = before.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1) + + before.disposeHoles?.() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0) + before.declareHoles() + await Promise.resolve() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1) + + await fiber.dispose() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0) + + const after = await bench(false) + await after.ctx.plugin({ inject: [...inject], apply }).await() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0) + after.declareHoles() + await Promise.resolve() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) + }) + + it('renders the official name independently from both requested mark sizes', () => { + const name = render() + expect(name.container.querySelector('svg')?.getAttribute('viewBox')).toBe('26 0 156 24') + name.unmount() + + const mark = render() + expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('34') + expect(mark.container.querySelector('svg')?.getAttribute('class')).toBe('hero-mark') + mark.rerender() + expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('24') + }) +}) diff --git a/packages/client/ui-brand-official/tests/invariant.client.spec.ts b/packages/client/ui-brand-official/tests/invariant.client.spec.ts new file mode 100644 index 0000000000..d8d3dd2d95 --- /dev/null +++ b/packages/client/ui-brand-official/tests/invariant.client.spec.ts @@ -0,0 +1,18 @@ +import { Context } from '@deepseek-ai/cordis' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import { describe, expect, it } from 'vitest' +import * as BrandInvariant from '../src/invariant.ts' +import { apply as nodeApply } from '../src/index.ts' + +describe('official brand invariant companion', () => { + it('reserves package ownership with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + + await expect(ctx.plugin(BrandInvariant).await()).resolves.toBeDefined() + }) + + it('keeps the node half as an inert Loader seat', () => { + expect(() => { nodeApply() }).not.toThrow() + }) +}) diff --git a/packages/client/ui-brand-official/tsconfig.json b/packages/client/ui-brand-official/tsconfig.json new file mode 100644 index 0000000000..f98c0a8b2f --- /dev/null +++ b/packages/client/ui-brand-official/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-sidebar" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-brand-official/tsdown.config.ts b/packages/client/ui-brand-official/tsdown.config.ts new file mode 100644 index 0000000000..abc830c1f6 --- /dev/null +++ b/packages/client/ui-brand-official/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-brand-official', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 080051a1b8..8131af3495 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: d9b774bdf5bfc2beaa33fe0d3ada8263b863798d -README.zh.md: 94da3def811fb901132f53fd6dbf4de0ccd6b3c8 +README.md: dae46f4398dcbb6dcd50c0d7bdfac13be14d5dc2 +README.zh.md: 0b7af0b42eee060f7c827cdc9e06bc07b7bcc4ef diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index d9b774bdf5..dae46f4398 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with a loaded `compaction/summary` event shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when the cited `compaction/summary` event is outside the loaded window, the checkpoint remains visible but non-expandable. -The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Safari alone receives a pre-paint recovery when a native edit shortens the draft and leaves stale soft-wrap overflow; draft growth, programmatic updates, and other browsers never read layout for that recovery ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)). +The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. The Hero's leading mark is the independent root-scoped `conversation.hero.brand.mark` slot, with the fish mark as its fallback. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Safari alone receives a pre-paint recovery when a native edit shortens the draft and leaves stale soft-wrap overflow; draft growth, programmatic updates, and other browsers never read layout for that recovery ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)). Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model-selection, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. @@ -18,11 +18,11 @@ Approvals take over the composer through the chain this package declares: `Appro The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A direct message that cites another session precedes its recall row in durable order. The Chat snapshot associates exact labels only from that immediately following sourced recall, preserving multi-word titles without carrying one recall's labels onto a later direct message. Recall uses a chat-bubble glyph while other context keeps the document glyph; a source that names no producer shows the role alone. Composer and user-bubble references use the same inline language: a chat-bubble, file, or folder glyph plus business-color text, without a nested capsule. Like claimed slash commands, composer references keep their complete display text in the transparent textarea and use the aligned backdrop for color and the leading domain glyph; native text metrics own width, wrapping, selection, and caret placement. The occurrence range remains structured for serialization and boundary deletion, while an edit inside it converts the remaining characters to ordinary text. The session draft mirror stores each occurrence's clipboard projection, so a remount without the occurrence table restores canonical parseable reference text instead of a display-only label. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). -The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. +The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. A path click through the injected `openFile` asks the Host to open that path (relative paths resolve against the session cwd). A Host or OS refusal opens an in-page dialog with the thrown reason and a Retry of the same path; Cancel, Escape, the close control, and a mask click dismiss it ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md)). The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 94da3def81..0b7af0b42e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个已加载对应 `compaction/summary` 事件的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩(context compaction)图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;被引用的 `compaction/summary` 事件位于已加载窗口之外时,检查点仍然可见但不可展开。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。只有 Safari 会在原生编辑缩短草稿并留下陈旧软换行溢出时执行绘制前恢复;草稿增长、程序化更新与其他浏览器都不会为这项恢复读取布局([决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md))。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。Hero 前方的标记是独立的根作用域 `conversation.hero.brand.mark` slot,未被占用时回退到鱼形标记。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。只有 Safari 会在原生编辑缩短草稿并留下陈旧软换行溢出时执行绘制前恢复;草稿增长、程序化更新与其他浏览器都不会为这项恢复读取布局([决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md))。 别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model-selection,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约定里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它无法路由的提示词。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 @@ -16,11 +16,11 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 会话页头会在标题旁渲染会话作用域的 `'conversation.session.header.actions'` 列表,并在最右侧渲染独立的 `'conversation.session.header.utilities'` 列表。会话上下文和谱系控件保留在 `actions` 中;可选的会话工具不会改变它们的顺序或位置。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。引用其他会话的直接消息在持久顺序中位于其召回行之前。Chat 快照只从紧随其后的带来源召回中关联准确标签,因此既能保留多词标题,也不会把一条召回的标签带到后续直接消息上。召回使用聊天气泡图标,其他上下文保留文档图标;来源未提供生产者名称时只显示角色。输入框与用户气泡中的引用使用同一种行内语言:聊天气泡、文件或文件夹图标加业务色文字,不嵌套胶囊容器。与已认领的 slash command 相同,输入框引用会把完整展示文本保留在透明 textarea 中,再用对齐的 backdrop 提供颜色和开头的领域图标;宽度、换行、选择区与光标位置均由原生文本度量决定。occurrence 范围仍为序列化与边界整段删除保留结构身份,在范围内部编辑则会把剩余字符转为普通文本。会话草稿镜像会存储每个 occurrence 的剪贴板投影,因此在 occurrence 表缺失的情况下重新挂载时,会恢复可解析的规范引用文本,而不是仅供显示的标签。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的正文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 -聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。 +聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。经注入的 `openFile` 点击路径会请 Host 打开该路径(相对路径按会话 cwd 解析)。Host 或操作系统拒绝时,页面内对话框展示抛出的原因,并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框([决策](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md))。 聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 463c481eef..294009a09b 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -206,6 +206,7 @@ export function apply(ctx: Context): void { 'conversation.composer.dock': { kind: 'list', scope: 'session' }, 'conversation.input.left': { kind: 'list', scope: 'session' }, 'conversation.input.right': { kind: 'list', scope: 'session' }, + 'conversation.hero.brand.mark': { kind: 'single', scope: 'root' }, 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, 'conversation.hero.agentPreset': { kind: 'single', scope: 'root' }, }, @@ -337,6 +338,7 @@ export function apply(ctx: Context): void { inputTriggers.toggleSource('command', { trigger: '/', query: '', + quoted: false, position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline', span: { ...selection, draftRev: snapshot.draftRev }, }) @@ -396,10 +398,7 @@ export function apply(ctx: Context): void { fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner), openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd - void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => { - // Host/OS open failures stay silent in the chat row; the native - // app surfaces its own error dialog when the path is unusable. - }) + return workspaces.openPath(resolveWorkspacePath(cwd, path)) }, loadOlder: () => { void scoped.loadOlder() }, loadImage: attachment => conversation.resolveImage(sessionId, attachment), diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index d16608c856..ffee12984f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -190,3 +190,8 @@ .toBottom:hover { background: var(--dsw-alias-button-floating-hover); } + +/* Host open-path refusal: same dialog family as the workspace folder error. */ +.modalAction { + min-width: 72px; +} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 4d9df510e1..80f9bc2d73 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -1,7 +1,8 @@ // ChatView: the default conversation view — one stable keyed parent list over // final business Nodes, plus paging, pending steering and bottom-follow. // Each row dispatches through 'conversation.chat.node'; ui-tool owns the -// tool-call renderer and its recursive root/subcall composition. +// tool-call renderer and its recursive root/subcall composition. A Host +// open-path refusal from the injected opener is an in-page dialog here. // // Scroll: when nested under `[data-conversation-scroll]` (active conversation // column), that host is the scrollport and this view is flow content; when @@ -14,7 +15,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client' -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' @@ -95,6 +96,17 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP } } +/** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */ +function openFailureMessage(error: unknown, fallback: string): string { + const message = error instanceof Error ? error.message : String(error) + return message === '' ? fallback : message +} + +/** ProducedFiles opens the session workspace as `.`. */ +function isFolderOpenPath(path: string): boolean { + return path === '.' +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -159,6 +171,40 @@ export function ChatView({ const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) + const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null) + const [fileOpenBusy, setFileOpenBusy] = useState(false) + // Close/retry must ignore a settlement that started before the latest + // gesture; otherwise a cancelled in-flight refusal reopens the dialog. + const fileOpenRequest = useRef(0) + + const requestOpenFile = useCallback((path: string) => { + const id = ++fileOpenRequest.current + setFileOpenBusy(true) + void openFile(path).then( + () => { + if (id !== fileOpenRequest.current) return + setFileOpenError(null) + setFileOpenBusy(false) + }, + (error: unknown) => { + if (id !== fileOpenRequest.current) return + setFileOpenError({ + path, + message: openFailureMessage( + error, + t(isFolderOpenPath(path) ? 'fileOpen.folderUnknown' : 'fileOpen.unknown'), + ), + }) + setFileOpenBusy(false) + }, + ) + }, [openFile, t]) + + const closeFileOpenError = useCallback(() => { + fileOpenRequest.current += 1 + setFileOpenError(null) + setFileOpenBusy(false) + }, []) const pendingSteering = useMemo( () => inbox.filter(item => item.placement === 'steering'), @@ -390,7 +436,7 @@ export function ChatView({ useSession={useSession} selectedCallId={selectedCallId} cwd={cwd} - openFile={openFile} + openFile={requestOpenFile} inspectCall={inspectCall} forkAt={forkAt} renderMessageImages={renderMessageImages} @@ -431,6 +477,44 @@ export function ChatView({ )} + {fileOpenError !== null && ( + { requestOpenFile(fileOpenError.path) }} + t={t} + /> + )} ) } + +/** In-page Host open-path refusal: the wire reason plus a retry of the same path. */ +function FileOpenErrorDialog({ + path, message, busy, onClose, onRetry, t, +}: { + path: string + message: string + busy: boolean + onClose: () => void + onRetry: () => void + t: ChatViewSlotProps['t'] +}) { + return ( + + + + + )} + /> + ) +} diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx index 889b15b085..3e9e0f10b1 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatViewSlotProps } from '../contract/slots.ts' import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' import { contextBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' @@ -37,7 +38,9 @@ export function ContextInjectionRow({ content, source, provenance, form, t }: Co return ( } + icon={provenance.role === 'recall' + ? + : } chevronClassName={css.chevron} title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} collapsedContent={provenance.label === null ? undefined : ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 36824d228d..54e5f6f98d 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -28,6 +28,11 @@ color: var(--dsw-alias-label-primary); } +.referenceSummary { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} .contextRow { padding: 2px 0; } @@ -268,17 +273,19 @@ } } -/* Reference chip projection inside a user bubble (`name` model - spans render as chips; free geometry — no textarea pairing here). */ +/* Inline references use domain glyphs and business-color text without another + container inside the user bubble. */ .refChip { - display: inline-block; + display: inline-flex; + align-items: center; + gap: 4px; margin: 0 2px; - padding: 0 8px; - border-radius: 6px; - background: rgba(97, 135, 216, 0.22); - color: var(--dsw-alias-label-primary); - font-size: 0.85em; - line-height: 1.6; + color: var(--dsw-alias-state-business-primary); + font-weight: 500; white-space: nowrap; vertical-align: baseline; } + +.refIcon { + flex: none; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index ecb0d11caa..8a882a70c5 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -10,6 +10,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' +import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' @@ -152,21 +153,59 @@ function TurnMaxTokensItem({ t }: { * scan as the composer, minus the lexicon: sent tokens were validated at * compose time, so shape alone decorates). */ -function projectUserText(text: string): ReactNode { - const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g - const parts: ReactNode[] = [] - let cursor = 0 +function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode { + const ranges: { start: number; end: number; label: string; kind: 'session' | 'plain' }[] = [] + for (const rawLabel of [...new Set(sessionLabels)].sort((a, b) => b.length - a.length)) { + const label = `@${rawLabel}` + let start = text.indexOf(label) + while (start >= 0) { + ranges.push({ start, end: start + label.length, label, kind: 'session' }) + start = text.indexOf(label, start + label.length) + } + } + const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { const tokenStart = m.index + (m[1]?.length ?? 0) - const label = m[2] ?? '' + const rawLabel = m[2] ?? '' + const label = rawLabel.startsWith('@"') + ? rawLabel + : rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '') + if (label.length <= 1) continue + ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' }) + } + ranges.sort((a, b) => a.start - b.start + || (a.kind === b.kind ? b.end - a.end : a.kind === 'session' ? -1 : 1)) + const parts: ReactNode[] = [] + let cursor = 0 + for (const range of ranges) { + if (range.start < cursor) continue + const { start: tokenStart, end, label, kind } = range if (tokenStart > cursor) parts.push() + const referenceKind = kind === 'session' + ? 'session' + : label.startsWith('@') + ? label.endsWith('/') ? 'folder' : 'file' + : undefined + const displayLabel = referenceKind === undefined + ? label + : referenceKind === 'session' + ? label.slice(1) + : label.slice(1).replace(/^"|"$/gu, '').split(/[\\/]/u).filter(Boolean).at(-1) ?? label.slice(1) parts.push( - - {label} + + {referenceKind !== undefined && ( + + )} + {displayLabel} , ) - cursor = tokenStart + label.length + cursor = end } if (parts.length === 0) return if (cursor < text.length) parts.push() @@ -175,7 +214,7 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, t, + content, renderMessageImages, actions, pending = false, referenceLabels = [], t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -183,6 +222,8 @@ function UserStyleBubble({ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean + /** Exact session mention labels associated by the adjacent recall node. */ + referenceLabels?: readonly string[] t: ChatViewSlotProps['t'] }): ReactNode { const { text, images, rest } = contentParts(content) @@ -193,9 +234,14 @@ function UserStyleBubble({
{renderMessageImages({ images, align: 'end' })} {showBubble &&
- {projectUserText(text)} + {projectUserText(text, referenceLabels)} {rest.map((block, i) => )}
} + {referenceLabels.length > 0 && ( +
+ {t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })} +
+ )}
{actions?.(text)} @@ -240,6 +286,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({ ( @@ -714,9 +728,11 @@ export interface ChatViewInjected { openDetails: (target: SelectionTarget) => void /** * Open a tool-arg filesystem path with the host OS default application - * (relative paths resolve against the session cwd). + * (relative paths resolve against the session cwd). Always returns a + * promise: fulfills when the Host opens the path, rejects when it cannot + * hand the path off (the chat view shows that reason and a retry). */ - openFile: (path: string) => void + openFile: (path: string) => Promise loadOlder: () => void /** Resolve a session-authorized historical image for inline display. */ loadImage: (attachment: ImageAttachmentRef) => Promise diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 880bbafa44..df8f87ee53 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -163,6 +163,7 @@ function finalNode( firstTokenTime: state.firstTokenTime ?? null, completedTime: event.time, }, + ...event.data.interrupted === true ? { interrupted: true } : {}, } } const location = context.start?.location ?? context.matches.at(-1)?.location diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts index be4e27111a..651fc780a3 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -5,6 +5,7 @@ import type { ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-runtime/client' +import { sessionRecallLabels } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' @@ -138,6 +139,99 @@ function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConvers .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) } +function referenceMessageSeq(node: ChatConversationViewNode): number | undefined { + const candidate = node as ChatNode + return candidate.kind === 'user' || candidate.kind === 'steering' + ? candidate.data.seq + : undefined +} + +function followingRecall(node: ChatConversationViewNode): { + readonly messageSeq: number + readonly labels: readonly string[] +} | undefined { + const candidate = node as ChatNode + if (candidate.kind !== 'context') return undefined + return { + messageSeq: candidate.data.seq - 1, + labels: sessionRecallLabels(candidate.data.source), + } +} + +function withReferenceLabels( + node: ChatConversationViewNode, + labels: readonly string[], +): ChatConversationViewNode { + const candidate = node as ChatNode + if (candidate.kind !== 'user' && candidate.kind !== 'steering') return node + const current = candidate.data.referenceLabels ?? EMPTY_KEYS + const hasLabels = Object.hasOwn(candidate.data, 'referenceLabels') + if (sameReferences(current, labels) && hasLabels === (labels.length > 0)) return node + const data: Record = { ...candidate.data } + if (labels.length === 0) delete data.referenceLabels + else data.referenceLabels = labels + return { ...candidate, data } +} + +/** Associates a direct message with the sourced recall event that immediately follows it. */ +class ReferenceLabelProjector { + private readonly messagesBySeq = new Map() + private readonly labelsByMessageSeq = new Map() + + replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] { + this.messagesBySeq.clear() + this.labelsByMessageSeq.clear() + for (const node of nodes) { + const messageSeq = referenceMessageSeq(node) + if (messageSeq !== undefined) this.messagesBySeq.set(messageSeq, node.key) + const recall = followingRecall(node) + if (recall !== undefined && recall.labels.length > 0) { + this.labelsByMessageSeq.set(recall.messageSeq, recall.labels) + } + } + return nodes.map((node) => { + const messageSeq = referenceMessageSeq(node) + return messageSeq === undefined + ? node + : withReferenceLabels(node, this.labelsByMessageSeq.get(messageSeq) ?? EMPTY_KEYS) + }) + } + + apply( + upserts: readonly ChatConversationViewNode[], + store: ChatNodeStore, + ): readonly ChatConversationViewNode[] { + const byKey = new Map(upserts.map(node => [node.key, node])) + const affected = new Set() + for (const node of upserts) { + const messageSeq = referenceMessageSeq(node) + if (messageSeq !== undefined) { + this.messagesBySeq.set(messageSeq, node.key) + affected.add(messageSeq) + } + const recall = followingRecall(node) + if (recall === undefined) continue + const current = this.labelsByMessageSeq.get(recall.messageSeq) + if (recall.labels.length === 0) this.labelsByMessageSeq.delete(recall.messageSeq) + else { + this.labelsByMessageSeq.set( + recall.messageSeq, + current !== undefined && sameReferences(current, recall.labels) ? current : recall.labels, + ) + } + affected.add(recall.messageSeq) + } + for (const messageSeq of affected) { + const key = this.messagesBySeq.get(messageSeq) + if (key === undefined) continue + const node = byKey.get(key) ?? store.get(key) + if (node === undefined) continue + byKey.set(key, withReferenceLabels(node, this.labelsByMessageSeq.get(messageSeq) ?? EMPTY_KEYS)) + } + return [...byKey.values()] + } +} + interface LegacyContribution { readonly anchorSeq: number readonly nodes: readonly ConversationNode[] @@ -384,6 +478,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) + const nodes = this.referenceLabels.replace(input.nodes) + this.store.replace(nodes) + this.order = orderedVisible(nodes).map(node => node.key) this.locations.rebuild(this.order, this.store) - return this.snapshot(input.timeline, this.legacy.replace(input.nodes, input.timeline)) + return this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline)) } apply(input: { readonly upserts: readonly ChatConversationViewNode[] readonly timeline: ConversationTimelineSnapshot }): ChatSnapshot { + const upserts = this.referenceLabels.apply(input.upserts, this.store) let structural = false const contentOnly: ChatConversationViewNode[] = [] - for (const node of input.upserts) { + for (const node of upserts) { const previous = this.store.get(node.key) const nodeStructural = previous === undefined || previous.anchorSeq !== node.anchorSeq @@ -416,14 +513,14 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.order = sameReferences(this.order, next) ? this.order : next this.locations.rebuild(this.order, this.store) } this.locations.touch(contentOnly) - return this.snapshot(input.timeline, this.legacy.apply(input.upserts, input.timeline)) + return this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline)) } private snapshot( diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts index 085127f9c5..c60bbf79f1 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -8,14 +8,24 @@ import { import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' -type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode +interface ReferencedUserMessageNode extends UserMessageNode { + /** Labels cited by the immediately following session-reference context. */ + readonly referenceLabels?: readonly string[] +} + +interface ReferencedSteeringMessageNode extends SteeringMessageNode { + /** Labels cited by the immediately following session-reference context. */ + readonly referenceLabels?: readonly string[] +} + +type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { /** Ordinary turn-opening user message. */ - user: UserMessageNode + user: ReferencedUserMessageNode /** User message admitted into an active turn. */ - steering: SteeringMessageNode + steering: ReferencedSteeringMessageNode /** Non-user context injected into model history. */ context: ContextMessageNode } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 4a8b27acbb..814734411b 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -32,7 +32,7 @@ export type { ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, - ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, + ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, HeroBrandMarkOwnerProps, MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, TurnTailOwnerProps, UseChatNodeTurnData, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 91a2494a4d..b9ace8b863 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -149,9 +149,9 @@ export interface EditRange extends EditSelection { } /** - * One reference chip occurrence, backing exactly one U+FFFC placeholder in - * the draft. Identity is occurrenceId — same-named - * references stay independently addressable. label/clipboardText are the + * One reference occurrence backed by its complete inline display text in the + * draft. Identity is occurrenceId — same-named + * references stay independently addressable. label/appearance/clipboardText are the * owner's insert-time projections, cached so the chip survives owner loss * (invalid flips instead of dropping the occurrence). */ @@ -162,10 +162,14 @@ export interface Occurrence { readonly source: string /** Owner-scoped reference id. */ readonly ref: string - /** Placeholder offset in the draft; the occurrence occupies exactly [offset, offset+1). */ + /** Display-text offset in the draft. */ readonly offset: number - /** Chip display label (insert-time cache). */ + /** Display-text length; the occurrence occupies exactly [offset, offset+length). */ + readonly length: number + /** Inline display label (insert-time cache). */ readonly label: string + /** Optional domain glyph (insert-time cache). */ + readonly appearance?: ReferenceInsert['appearance'] /** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */ readonly clipboardText: string /** Owner-resolution failure flag: chip renders invalid; serialization must fail. */ @@ -215,7 +219,7 @@ export interface InputState { 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 images?: boolean } - /** Chip occurrence table, sorted by offset (one U+FFFC per entry). */ + /** Reference occurrence table, sorted by offset. */ readonly occurrences: readonly Occurrence[] /** Live paste-match attempt (absent when no paste is matchable). */ readonly paste?: PasteAttemptState @@ -232,7 +236,7 @@ export interface InputState { export interface SubmitAttempt { readonly seq: number readonly signal: AbortSignal - /** Draft at enter time; rollback restores it only while the live draft still equals it. */ + /** Draft at enter time; settlement clears it only after acceptance. */ readonly draftSnapshot: string /** Default-message delivery intent retained while slash adjudication is pending. */ readonly mode: InputSubmitMode @@ -248,7 +252,7 @@ export type InputEvent = /** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */ | { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange } | { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan } - /** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */ + /** Place one inline reference at the span and mint the occurrence (scoped insert-reference event payload). */ | { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan } /** Delete a settled command token; success is observable as a draftRev advance. */ | { readonly type: 'consume-token'; readonly guard: ConsumeTokenGuard } @@ -271,10 +275,7 @@ export type InputEvent = | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } - /** - * An ordinary (default-sink) send was accepted: clear the draft as a COMMIT — - * undo must not resurrect sent content (mirrors submit-settled's success arm). - */ + /** Commit an image-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } @@ -286,5 +287,5 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly draft: string; readonly mode: InputSubmitMode } + | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index bd3dd09040..1ae5e9404b 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -1,6 +1,6 @@ /** - * Draft decoration pure core (chips render from the occurrence - * table at placeholder offsets; the claim token renders as a mirror-layer + * Draft decoration pure core (references render from occurrence ranges; the + * claim token renders as a mirror-layer * highlight, the claim hint as ghost text). Zero React — the skeleton renders * the instructions; tests drive this directly. */ @@ -12,13 +12,19 @@ export interface TokenRange { readonly end: number } -/** One chip render instruction: the placeholder at `offset` draws as `label`. */ +/** One structured inline-reference render instruction. */ export interface ChipRender { /** Stable render key (same-labeled chips stay independent). */ readonly occurrenceId: number - /** Placeholder offset in the draft (the chip occupies [offset, offset+1)). */ + /** Display-text offset in the draft. */ readonly offset: number + /** Display-text length in the draft. */ + readonly length: number + /** Exact inline text whose native glyph metrics determine layout. */ + readonly text: string readonly label: string + /** Optional domain glyph beside the label. */ + readonly appearance?: 'session' | 'file' | 'folder' /** Owner-resolution failure styling bit. */ readonly invalid: boolean } @@ -34,6 +40,8 @@ export interface TextRefRange { readonly start: number readonly end: number readonly trigger: '/' | '@' + /** Optional icon domain for syntax-recognizable plain references. */ + readonly appearance?: 'folder' } /** Decoration product: claim token range + chip instructions + text-ref ranges + the ghost hint. */ @@ -42,7 +50,7 @@ export interface DraftDecorations { readonly token: TokenRange | null /** Chip render instructions in draft order (occurrence table is offset-sorted). */ readonly chips: readonly ChipRender[] - /** Scan-derived plain-text reference ranges (empty without a lexicon). */ + /** Scan-derived lexicon tokens and syntax-recognizable folder ranges. */ readonly textRefs: readonly TextRefRange[] /** Ghost hint shown while the claim's args are blank; null otherwise. */ readonly hint: string | null @@ -50,6 +58,7 @@ export interface DraftDecorations { /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g +const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g /** * Scan the draft for plain-text reference tokens against the hot lexicons. @@ -63,19 +72,31 @@ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g export function scanTextRefs( draft: string, lexicon: ReadonlyMap<'/' | '@', readonly string[]>, ): TextRefRange[] { - if (lexicon.size === 0 || draft === '') return [] + if (draft === '') return [] const out: TextRefRange[] = [] - TEXT_REF_RE.lastIndex = 0 - let m: RegExpExecArray | null - while ((m = TEXT_REF_RE.exec(draft)) !== null) { - const trigger = m[2] as '/' | '@' - const name = m[3] ?? '' - if (lexicon.get(trigger)?.includes(name)) { - const start = m.index + (m[1]?.length ?? 0) - out.push({ start, end: start + 1 + name.length, trigger }) + if (lexicon.size > 0) { + TEXT_REF_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = TEXT_REF_RE.exec(draft)) !== null) { + const trigger = m[2] as '/' | '@' + const name = m[3] ?? '' + if (lexicon.get(trigger)?.includes(name)) { + const start = m.index + (m[1]?.length ?? 0) + out.push({ start, end: start + 1 + name.length, trigger }) + } } } - return out + FOLDER_REF_RE.lastIndex = 0 + let folder: RegExpExecArray | null + while ((folder = FOLDER_REF_RE.exec(draft)) !== null) { + const token = folder[2] ?? '' + const start = folder.index + (folder[1]?.length ?? 0) + const end = start + token.length + if (!out.some(range => range.start < end && range.end > start)) { + out.push({ start, end, trigger: '@', appearance: 'folder' }) + } + } + return out.sort((left, right) => left.start - right.start) } /** The empty lexicon (default: zero text-ref decorations, old call sites unchanged). */ @@ -97,7 +118,10 @@ export function deriveDecorations( const chips = occurrences.map(o => ({ occurrenceId: o.occurrenceId, offset: o.offset, + length: o.length, + text: draft.slice(o.offset, o.offset + o.length), label: o.label, + ...o.appearance === undefined ? {} : { appearance: o.appearance }, invalid: o.invalid === true, })) const hint = claimActive && claim.hint !== undefined && draft.slice(claim.token.length).trim() === '' diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 2783aea854..cc42d0c1ad 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -10,14 +10,14 @@ 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, SubmitImageAttachment, TokenSpan, + ReferenceInsert, InputTriggerController, SubmitImageAttachment, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, } from './contract.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' -import { InputMachine } from './machine.ts' +import { InputMachine, projectClipboard } from './machine.ts' /** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */ export interface PopupDismissFace { @@ -45,7 +45,12 @@ 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 + defaultSink( + text: string, + imageIds: readonly DraftAttachmentId[], + mode: InputSubmitMode, + signal: AbortSignal, + ): Promise /** 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. */ @@ -93,10 +98,12 @@ export class SessionInputShell implements SessionInput { // production (the machine's no-clock default is a constant for pure tests). private readonly core = new InputMachine({ now: () => Date.now() }) private noticeSeq = 0 - private lastDraft = '' + private lastMirroredDraft = '' private imageIds: readonly DraftAttachmentId[] = [] + /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ + private imageSendInFlight = false private disposed = false - /** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */ + /** Draft persistence mirror (chat store write; receives the clipboard projection, never display-only ranges). */ private mirrorFn: ((text: string) => void) | undefined constructor(private readonly deps: SessionInputDeps) { @@ -150,16 +157,6 @@ export class SessionInputShell implements SessionInput { this.publish() } - /** - * Restore a failed attempt before any images added after its admission. - * @param ids - failed attempt image ids. - */ - restoreImages(ids: readonly DraftAttachmentId[]): void { - const current = new Set(this.imageIds) - this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds] - this.publish() - } - /** * Clear the draft as a successful-send commit: no undo unit is recorded and * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content @@ -211,7 +208,19 @@ export class SessionInputShell implements SessionInput { */ submit(mode: InputSubmitMode = 'queue'): void { if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { - if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds], mode) + if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) { + const imageIds = [...this.imageIds] + this.imageSendInFlight = true + void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => { + this.imageSendInFlight = false + if (this.disposed) return + if (outcome.kind === 'success') this.commitSend(imageIds) + else if (outcome.text !== undefined) this.notify('error', outcome.text) + }, (error: unknown) => { + this.imageSendInFlight = false + if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error)) + }) + } return } // Claimed pre-gate: a claim that does not declare image acceptance never @@ -350,13 +359,21 @@ export class SessionInputShell implements SessionInput { * a scan-derived decoration, never state. * @param text - the plain reference text to splice in (e.g. `/name `). * @param span - pick-time span snapshot (draftRev CAS). + * @param keepCompleting - re-track at the caret after the splice so an open + * token (a directory pick's trailing slash) reopens the menu. * @returns whether the text was applied. */ - insertText(text: string, span: TokenSpan): boolean { + insertText(text: string, span: TokenSpan, keepCompleting = false): boolean { const snapshot = this.core.state if (span.draftRev !== snapshot.draftRev) return false const draft = snapshot.draft this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end)) + if (keepCompleting) { + // Machine-driven draft replacement never passes through onChange, so + // re-track at the caret inside the still-open token (see space()). + const next = this.snapshot + this.deps.inputTriggers?.()?.track(next.draft, span.start + text.length, { tier: guardOf(next.phase) }, next.draftRev) + } return true } @@ -421,7 +438,7 @@ export class SessionInputShell implements SessionInput { return } case 'default-sink': { - this.sinkSerialized(fx.draft, fx.mode) + this.sinkSerialized(fx.attempt, fx.draft, fx.mode) return } default: @@ -431,42 +448,78 @@ export class SessionInputShell implements SessionInput { /** * Prompt serialization before the sink: expand each - * placeholder to its owner's model form via the session controller's + * inline reference range to its owner's model form via the session controller's * codec routing. Owner missing / serialize failure / disposal blocks the * send — notice + draft and chips retained, never a silent downgrade to * the clipboard text. Chip-free drafts skip the async detour. */ - private sinkSerialized(draft: string, mode: InputSubmitMode): void { + private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void { const imageIds = [...this.imageIds] const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.deps.defaultSink(draft.trim(), imageIds, mode) + this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds) return } const inputTriggers = this.deps.inputTriggers?.() const controller = new AbortController() void Promise.all(occurrences.map(async (o) => { if (inputTriggers === undefined) throw new Error(`no serializer for reference source "${o.source}"`) - return { offset: o.offset, text: await inputTriggers.serializeReference(o.source, o.ref, controller.signal) } + return { + offset: o.offset, + length: o.length, + text: await inputTriggers.serializeReference(o.source, o.ref, controller.signal), + } })).then( (parts) => { if (this.disposed) return - // Splice model forms over their placeholders (offsets are draft-time; + // Splice model forms over their display ranges (offsets are draft-time; // parts arrive offset-sorted since the table is). let out = '' let cursor = 0 for (const part of parts) { out += draft.slice(cursor, part.offset) + part.text - cursor = part.offset + 1 + cursor = part.offset + part.length } out += draft.slice(cursor) - this.deps.defaultSink(out.trim(), imageIds, mode) + this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds) }, (error: unknown) => { controller.abort() - if (this.disposed) return + if (this.dead(attempt)) return const message = error instanceof Error ? error.message : String(error) - this.notify('error', message) + this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) + }, + ) + } + + /** Settle one admission attempt; successful sends consume only their captured images. */ + private settleSubmit( + attempt: SubmitAttempt, + pending: Promise, + imageIds: readonly DraftAttachmentId[] = [], + ): void { + pending.then( + (outcome) => { + if (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.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: outcome.kind === 'success', + outcome, + })) + }, + (error: unknown) => { + if (this.dead(attempt)) return + this.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: false, + message: error instanceof Error ? error.message : String(error), + })) }, ) } @@ -519,6 +572,7 @@ export class SessionInputShell implements SessionInput { } this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome, + ...(outcome.kind === 'error' && outcome.text === undefined ? { message: 'command failed' } : {}), })) }, (error: unknown) => { @@ -542,9 +596,10 @@ export class SessionInputShell implements SessionInput { private publish(): void { const next = this.compose() this.state.set(next) - if (next.draft !== this.lastDraft) { - this.lastDraft = next.draft - this.mirrorFn?.(next.draft) + const mirroredDraft = projectClipboard(next) + if (mirroredDraft !== this.lastMirroredDraft) { + this.lastMirroredDraft = mirroredDraft + this.mirrorFn?.(mirroredDraft) } } } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 789f18766c..7e3b95a314 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -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, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { InputTriggerController, SubmitImageAttachment, SubmitOutcome } 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' @@ -29,7 +29,8 @@ interface ConversationAttachmentFace { text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode, - ): Promise + signal?: AbortSignal, + ): Promise serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise releaseDraftImage(id: DraftAttachmentId): void } @@ -76,7 +77,7 @@ export class InputHub implements SessionInputResolver { inputTriggers: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text, imageIds, mode) => { this.sink(session, text, imageIds, mode) }, + defaultSink: (text, imageIds, mode, signal) => this.sink(session, text, imageIds, mode, signal), steerQueue: () => { void this.steerQueue(session, shell) }, commandImages: { serialize: ids => this.conversation().serializeDraftImages(ids), @@ -105,7 +106,7 @@ export class InputHub implements SessionInputResolver { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined), actx.on('slash/input-insert-text', req => - shell.insertText(req.text, req.span) ? true : undefined), + shell.insertText(req.text, req.span, req.continue === true) ? true : undefined), ] return () => { for (const off of offs) off() @@ -166,20 +167,10 @@ export class InputHub implements SessionInputResolver { text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode, - ): void { - if (text === '' && imageIds.length === 0) return - const shell = this.shells.get(session.sessionId) - // Commit, not an editable clear: undo must not resurrect sent content. - shell?.commitSend(imageIds) - void this.conversation().sendSession(session, text, imageIds, mode).catch(() => { - if (this.shells.get(session.sessionId) === shell) { - shell?.restoreImages(imageIds) - if (shell?.snapshot.draft === '') shell.setDraft(text) - return - } - const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined - for (const id of imageIds) conversation?.releaseDraftImage(id) - }) + signal: AbortSignal, + ): Promise { + if (text === '' && imageIds.length === 0) return Promise.resolve({ kind: 'success' }) + return this.conversation().sendSession(session, text, imageIds, mode, signal) } /** diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index f42b827d52..a1a49004a4 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -4,8 +4,8 @@ * clock. Package-private — the SessionInput shell is the only caller and the * sole executor of the returned effects. * - * Draft truth: the draft string holds one U+FFFC placeholder per chip; the - * occurrence table carries identity and the owner's cached projections. Every + * Draft truth: the draft string holds each reference's complete inline display + * text; the occurrence table carries identity, range, and the owner's cached projections. Every * draft mutation is one transaction — draft edit, occurrence reconciliation, * and undo-log push are atomic inside dispatch() — and bumps draftRev, which * is what lets span CAS reduce to a revision-equality check: equal rev ⟹ @@ -20,9 +20,21 @@ import type { InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt, } from './contract.ts' -/** The object-replacement character backing every chip occurrence in the draft. */ +/** Legacy fixed-width object replacement character rejected from pasted text. */ export const PLACEHOLDER = '' +const REFERENCE_PLACEHOLDER_RE = /[\uE100-\uE11D\uFFFC]/gu + +/** + * Build the inline draft text whose leading marker is decorated as the + * reference icon in the backdrop. + * @param reference - reference insertion with its cached display projection. + * @returns display text with one marker glyph followed by the complete label. + */ +export function referenceDraftText(reference: Pick): string { + return `@${reference.label}` +} + /** The machine never writes the queue; the wiring layer overlays the queue store's projection. */ const EMPTY_QUEUE: InputState['queue'] = [] @@ -67,10 +79,9 @@ function diffEdit(prev: string, next: string): EditRange { } /** - * Expand the draft's placeholders into their occurrences' clipboard text - * (the persistence mirror and clipboard both write this - * projection — U+FFFC never leaves the machine). Table order is offset - * order, so one linear walk pairs placeholders with entries. + * Expand the draft's reference ranges into their occurrences' clipboard text + * for persistence and clipboard projection. Table order is offset order, so + * one linear walk pairs ranges with entries. * @param state - published input state. * @returns the plain-text projection of the draft. */ @@ -81,7 +92,7 @@ export function projectClipboard(state: Pick= range.end) kept.push(delta === 0 ? o : { ...o, offset: o.offset + delta }) } this.occurrences = kept @@ -225,14 +236,16 @@ export class InputMachine { } /** Mint one occurrence at a draft offset. */ - private mint(reference: ReferenceInsert, offset: number): Occurrence { + private mint(reference: ReferenceInsert, offset: number, length: number): Occurrence { this.occurrenceSeq += 1 return { occurrenceId: this.occurrenceSeq, source: reference.source, ref: reference.ref, offset, + length, label: reference.label, + ...reference.appearance === undefined ? {} : { appearance: reference.appearance }, clipboardText: reference.clipboardText, } } @@ -293,19 +306,20 @@ export class InputMachine { } /** - * Shared chip-insertion transaction: replace [span) with one placeholder + * Shared reference-insertion transaction: replace [span) with one inline * occurrence (insert-ref and paste-upgrade both land here). A separating - * space follows the chip unless one is already next. - * @returns the inserted length (placeholder plus optional gap). + * space follows the reference unless one is already next. + * @returns the inserted length (display text plus optional gap). */ private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): number { this.pushTxn() this.typingRun = undefined const tail = this.draft.slice(span.end) const gap = tail.length === 0 || tail[0] !== ' ' ? ' ' : '' - const inserted = PLACEHOLDER + gap + const displayText = referenceDraftText(reference) + const inserted = displayText + gap this.reconcile({ start: span.start, end: span.end, insertedLength: inserted.length }) - this.withMinted([this.mint(reference, span.start)]) + this.withMinted([this.mint(reference, span.start, displayText.length)]) this.adopt(this.draft.slice(0, span.start) + inserted + tail) this.watchClaim() return inserted.length @@ -392,7 +406,7 @@ export class InputMachine { // ---- paste plane ---- /** - * Paste as one transaction: the text (U+FFFC-sanitized) replaces the + * Paste as one transaction: the text (reference-placeholder-sanitized) replaces the * selection; hot-snapshot sync matches componentize inside the SAME * transaction (one undo returns to pre-paste); a match attempt opens for * the async remainder while the phase still accepts reference mutations. @@ -403,19 +417,20 @@ export class InputMachine { ): InputEffect[] { const { start, end } = selection if (start < 0 || start > end || end > this.draft.length) return [] - const text = rawText.split(PLACEHOLDER).join('') + const text = rawText.replace(REFERENCE_PLACEHOLDER_RE, '') this.pushTxn(selection) this.typingRun = undefined // Componentize: replace each matched token range (paste-text coordinates, - // disjoint by contract) with a placeholder while assembling the insert. + // disjoint by contract) with inline display text while assembling the insert. const sorted = [...components].sort((a, b) => a.start - b.start) const minted: Occurrence[] = [] let inserted = '' let cursor = 0 for (const c of sorted) { inserted += text.slice(cursor, c.start) - minted.push(this.mint(c.reference, start + inserted.length)) - inserted += PLACEHOLDER + const displayText = referenceDraftText(c.reference) + minted.push(this.mint(c.reference, start + inserted.length, displayText.length)) + inserted += displayText cursor = c.end } inserted += text.slice(cursor) @@ -481,7 +496,9 @@ export class InputMachine { this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft: this.draft }] } - return [{ type: 'default-sink', draft: this.draft, mode }] + const attempt = this.beginAttempt(mode) + this.phase = 'submitting' + return [{ type: 'default-sink', attempt, draft: this.draft, mode }] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -499,11 +516,18 @@ export class InputMachine { } // 'handled' (source dealt internally), {insert} (no enter-time span // semantics), or a miss: all land plain; only the miss flows to the sink. + if (outcome === undefined) { + this.phase = 'submitting' + return [{ + type: 'default-sink', + attempt, + draft: attempt.draftSnapshot, + mode: attempt.mode, + }] + } this.inflight = undefined this.phase = 'plain' - return outcome === undefined - ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: attempt.mode }] - : [] + return [] } private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] { @@ -522,7 +546,13 @@ export class InputMachine { this.phase = 'plain' this.claim = undefined this.occurrences = [] - this.adopt('') + // Text appended after the sent snapshot during the Host round-trip + // survives the commit; edits interleaved with committed content cannot + // be separated from it, so only a pure suffix is retained. + const snapshot = flight.attempt.draftSnapshot + this.adopt(this.draft !== snapshot && this.draft.startsWith(snapshot) + ? this.draft.slice(snapshot.length) + : '') // Committed content is gone for good: undo must not resurrect a sent draft. this.log = [] this.redoStack = [] @@ -532,24 +562,24 @@ export class InputMachine { ? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }] : [] } - const text = ev.message ?? ev.outcome?.text ?? 'command failed' - // Drift guard: keep the enter-time draft (same claim) only while the - // live draft still equals it; user input typed during flight wins. + const text = ev.message ?? ev.outcome?.text + // Keep the same command claim only while the live draft still equals the + // enter-time draft; user input typed during flight wins. // Claimed re-entry additionally requires the watch to hold — an // enter-path snapshot may carry leading whitespace the token never had. if (this.draft === flight.attempt.draftSnapshot && this.claim !== undefined && this.draft.startsWith(this.claim.token)) { this.phase = 'claimed' - return [{ type: 'notice', level: 'error', text }] + return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } this.phase = 'plain' this.claim = undefined - return [{ type: 'notice', level: 'error', text }] + return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } - /** Ordinary send accepted: clear as a commit (no undo unit; sent content - * must not be resurrectable — same discipline as submit-settled success). */ + /** Cut undo state after an accepted image-only send. */ private onSendCommitted(): InputEffect[] { + if (this.phase !== 'plain') return [] this.claim = undefined this.occurrences = [] this.adopt('') diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 6ad7fc0e7b..f4e7a7c59a 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -92,9 +92,15 @@ export const zh = { 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', + 'fileOpen.title': '无法打开文件', + 'fileOpen.unknown': '无法打开此文件', + 'fileOpen.folderTitle': '无法打开文件夹', + 'fileOpen.folderUnknown': '无法打开此文件夹', 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', + 'message.referenceSummary': '引用会话 · {labels}', + 'message.referenceSeparator': '、', 'message.context.instructions.loaded': '已载入', 'message.context.instructions.added': '已新增', 'message.context.instructions.updated': '已更新', @@ -263,9 +269,15 @@ export const en = { 'chat.loadError': 'Failed to load history: {message} ({code})', 'chat.loadOlder': 'Load earlier', 'chat.toBottom': 'Back to bottom', + 'fileOpen.title': 'Couldn’t open file', + 'fileOpen.unknown': 'Couldn’t open this file', + 'fileOpen.folderTitle': 'Couldn’t open folder', + 'fileOpen.folderUnknown': 'Couldn’t open this folder', 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', + 'message.referenceSummary': 'Referenced session · {labels}', + 'message.referenceSeparator': ', ', 'message.context.instructions.loaded': 'loaded', 'message.context.instructions.added': 'added', 'message.context.instructions.updated': 'updated', diff --git a/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx b/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx new file mode 100644 index 0000000000..fcc89fe30e --- /dev/null +++ b/packages/client/ui-conversation/src/client/reference/ReferenceIcon.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react' +import { + IconBrowseOutline16, IconFolderClose16, +} from '@deepseek-ai/dsh-client-ui-primitives' + +/** Reference domains with distinct composer and transcript glyphs. */ +export type ReferenceIconKind = 'session' | 'file' | 'folder' + +/** Props shared by inline reference glyphs. */ +export interface ReferenceIconProps { + kind: ReferenceIconKind + size?: number + className?: string | undefined +} + +/** + * Render the icon that identifies one inline reference domain. + * @param props - Reference kind, optional size, and optional CSS class. + * @returns The corresponding current-color SVG glyph. + */ +export function ReferenceIcon({ kind, size = 16, className }: ReferenceIconProps): ReactNode { + switch (kind) { + case 'session': + return ( + + + + ) + case 'file': return + case 'folder': return + } +} diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 9a25e13dc0..a37816472c 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -13,7 +13,7 @@ import type { Context } from '@deepseek-ai/cordis' // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' import type { QueueAction, QueueItemId } from './contract/queue.ts' @@ -139,22 +139,26 @@ export class ConversationController extends Service implements IConversation { * @param text - serialized prompt text. * @param imageIds - ordered draft-local attachment ids. * @param mode - queue or steer delivery selected by composer policy. + * @param signal - optional cancellation for the complete Host admission. + * @returns the Host admission outcome; local attachment preparation failures reject. */ async sendSession( session: SessionFace, text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode, - ): Promise { + signal?: AbortSignal, + ): Promise { const attachments = this.draftImages(imageIds) if (attachments.length !== imageIds.length) { throw new Error('conversation.sendSession: one or more draft images are no longer available') } const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] - const result = await session.prompt(content, mode) - if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) + const result = await session.prompt(content, mode, signal) + if (!result.ok) return { kind: 'error' } this.releaseDraftImages(attachments) + return { kind: 'success' } } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index ef1221cd99..07655d7dba 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -159,7 +159,7 @@ export function ConversationRoot({ const composerBar = (
{hero && } - {hero && } + {hero && } {hero && heroWorkspaceRow} {zone !== undefined && renderSlot('conversation.input.dock', zone)} {inputBar} diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 4865ceecfa..9db09e3141 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -102,6 +102,8 @@ export function HeroGlow({ className }: { className?: string | undefined }) { export interface HeroShellProps { /** The owner's locale seat, passed down as a plain prop. */ t: HeroTranslate + /** Authorized renderer for the hero brand-mark slot. */ + renderSlot: ConversationSlotProps['renderSlot'] /** Overlay content after the stack (modals). */ children?: ReactNode } @@ -112,14 +114,16 @@ export interface HeroShellProps { * @param props - see {@link HeroShellProps}. * @returns the centered hero element tree. */ -export function HeroShell({ t, children }: HeroShellProps) { +export function HeroShell({ t, renderSlot, children }: HeroShellProps) { return (
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} - + {renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, { + fallback: , + })} {t('hero.headline')} {t('hero.preview')} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index de3a1739fc..e5b85df3e9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -1,12 +1,3 @@ -/* One-glyph font: maps ONLY U+FFFC to a blank 4em-advance glyph (every other - codepoint falls through to the next family). Loaded first in the composer - font stack, it gives the placeholder a real cell width INSIDE the textarea, - so the backdrop chip (same char, same stack) matches it by construction — - the two layers cannot drift and the chip gets a usable label cell. */ -@font-face { - font-family: 'DshChipCell'; - src: url('data:font/ttf;base64,AAEAAAAKAIAAAwAgT1MvMkT8SmIAAAEoAAAAYGNtYXAADQBPAAABkAAAADRnbHlmAAAAAAAAAcwAAAABaGVhZCwtPGoAAACsAAAANmhoZWEDIg7bAAAA5AAAACRobXR4EZQAAAAAAYgAAAAIbG9jYQAAAAAAAAHEAAAABm1heHAAAwACAAABCAAAACBuYW1lvljk2gAAAdAAAABscG9zdNNweNQAAAI8AAAALQABAAAAAQAAdia1tV8PPPUAAwPoAAAAAOaLfcUAAAAA5ot9xQAAAAAAAAAAAAAAAwACAAAAAAAAAAEAAAMg/zgAAA+gAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAACAAEAAAACAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAwjKAZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAPz8/PwAA//z//AMg/zgAAAMgAMgAAAAAAAAAAAAAAAAAAAAgAAAB9AAAD6AAAAAAAAIAAAADAAAAFAADAAEAAAAUAAQAIAAAAAQABAABAAD//P//AAD//P//AAUAAQAAAAAAAAAAAAAAAAAAAAAAAAAEADYAAQAAAAAAAQALAAAAAQAAAAAAAgAHAAsAAwABBAkAAQAWABIAAwABBAkAAgAOAChEc2hDaGlwQ2VsbFJlZ3VsYXIARABzAGgAQwBoAGkAcABDAGUAbABsAFIAZQBnAHUAbABhAHIAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAABAgZvYmpyZXAAAAA=') format('truetype'); -} /* Floating capsule input (figma Input_Bottom 75:8208): card floats above the viewport bottom inside the centered message column; textarea on top, action @@ -145,9 +136,9 @@ position: relative; } -/* Decoration backdrop: same metrics as the textarea, transparent glyphs; only - the highlight backgrounds and the ghost hint show through the transparent - textarea background above it. */ +/* Decoration backdrop: same metrics as the transparent-text textarea. It owns + every visible glyph plus the range colors and ghost hint; the textarea above + retains the native selection and caret. */ .backdrop { position: absolute; inset: 0; @@ -156,6 +147,11 @@ pointer-events: none; } +.backdropDisabled, +.backdropDisabled :is(.hlToken, .hint, .textRef, .chip, .chipInvalid) { + color: var(--dsw-alias-label-tertiary); +} + .hlToken { background-color: transparent; color: var(--dsw-alias-state-warn-label); @@ -200,6 +196,7 @@ outline: none; background: transparent; color: transparent; + -webkit-text-fill-color: transparent; /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ caret-color: var(--dsw-alias-state-business-primary); } @@ -214,10 +211,7 @@ /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these metrics or the highlight ranges drift off the glyphs. */ padding: 4px 12px 0 16px; - /* DshChipCell first: ONLY U+FFFC resolves there (4em blank cell — the chip - slot); everything else falls through to the app stack. All three layers - share the stack, so placeholder advances agree by construction. */ - font-family: 'DshChipCell', var(--dsw-font-family); + font-family: var(--dsw-font-family); font-size: inherit; /* Three consumers, not two: the mirror sizes the stack, the layers must break lines identically, and the caret reveal parses this value to step one line @@ -242,12 +236,15 @@ /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */ .input::placeholder { color: var(--dsw-alias-label-caption); + -webkit-text-fill-color: var(--dsw-alias-label-caption); user-select: none; } -/* Running lock: grayed but the draft stays visible; the turn ending re-enables. */ +/* The backdrop owns disabled draft color; the textarea remains caret-only so + its marker glyphs cannot cover the reference icons beneath it. */ .input:disabled { - color: var(--dsw-alias-label-tertiary); + color: transparent; + -webkit-text-fill-color: transparent; cursor: not-allowed; } @@ -427,43 +424,50 @@ display: none; } -/* Reference chip: rendered in the backdrop at the placeholder offset. Hard - alignment constraint: the chip's advance must equal the textarea's U+FFFC - advance EXACTLY or every glyph after it drifts (caret/selection follow the - textarea character stream). The ::before renders the same U+FFFC through - the same font stack (DshChipCell 4em cell), so both layers agree by - construction — no measured widths. The label overlays the cell, clipped - with an ellipsis; the full name rides the title tooltip. */ -.chip { +.textRefTrigger { position: relative; - border-radius: 6px; - background: rgba(97, 135, 216, 0.22); } -.chip::before { - content: '\FFFC'; +.textRefTriggerGlyph { color: transparent; } -.chipLabel { - /* Compensated-scale centering: overflow clipping happens BEFORE transform, - so the box is laid out at 1/0.72 of the cell and scaled back down — the - clip edge then lands on the visual cell edge, not mid-glyph. */ +.textRefIcon { position: absolute; - left: 50%; top: 50%; - width: calc(100% / 0.72 - 10px); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - color: var(--dsw-alias-label-primary); - white-space: nowrap; - transform: translate(-50%, -50%) scale(0.72); + left: 50%; + transform: translate(-50%, -50%); +} + +/* Structured references use the same inline-backdrop technique as /skill: + their complete display text remains in the textarea, so wrapping and caret + geometry come from the browser's native glyph metrics. The leading marker + reserves the icon's advance while the backdrop paints the domain glyph. */ +.chip { + position: relative; + color: var(--dsw-alias-state-business-primary); + background: transparent; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +.chipTrigger { + position: relative; +} + +.chipTriggerGlyph { + color: transparent; +} + +.chipIcon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); } .chipInvalid { - background: rgba(216, 97, 97, 0.2); text-decoration: line-through; opacity: 0.7; + color: var(--dsw-alias-state-error-primary); } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index bdb5c592cd..14feaca291 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -25,6 +25,7 @@ import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' import { attachmentErrorText, imageSizeText } from '../image-labels.ts' +import { ReferenceIcon } from '../reference/ReferenceIcon.tsx' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' @@ -268,6 +269,14 @@ export function InputBar({ return () => { el.removeEventListener('wheel', onWheel) } }, []) + // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. + /* oxlint-disable typescript/no-unnecessary-condition */ + const selectionOf = (el: HTMLTextAreaElement) => ({ + start: el.selectionStart ?? 0, + end: el.selectionEnd ?? el.selectionStart ?? 0, + }) + /* oxlint-enable typescript/no-unnecessary-condition */ + const onKeyDown = (e: KeyboardEvent): void => { if (workspaceTrigger) { if (e.key === 'Enter' || e.key === ' ') { @@ -278,13 +287,31 @@ export function InputBar({ } // Absent machine without a Workspace recovery action stays disabled; the // guard narrows the faces for the paths below. - if (keyboard === undefined || inputActions === undefined) return + if (input === undefined || keyboard === undefined || inputActions === undefined) return // Shift+Enter is the native newline UNCONDITIONALLY — decided before the // IME guard so a composition-closing Shift+Enter still breaks the line. if (e.key === 'Enter' && e.shiftKey) return // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. // oxlint-disable-next-line typescript/no-deprecated const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229 + if (!composing && !machineBusy && !locked + && (e.key === 'Backspace' || e.key === 'Delete')) { + const selection = selectionOf(e.currentTarget) + if (selection.start === selection.end) { + const occurrence = input.occurrences.find(o => e.key === 'Backspace' + ? o.offset + o.length === selection.start + : o.offset === selection.start) + if (occurrence !== undefined) { + e.preventDefault() + const start = occurrence.offset + const end = occurrence.offset + occurrence.length + keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 }) + restoreCaret(e.currentTarget, start) + keyboard.track(keyboard.snapshot.draft, start) + return + } + } + } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault() return @@ -351,45 +378,32 @@ export function InputBar({ keyboard.track(next, e.target.selectionStart ?? next.length) } - // ---- chip atomicity (DOM layer; the machine sees only transactions) ---- - // Placeholders occupy exactly one char, so caret positions are always - // BETWEEN them — what needs normalizing is deletion (whole chip per - // Backspace/Delete via native single-char semantics, which U+FFFC already - // gives us) and selection endpoints: Shift-extension snapping is native - // too (one char = one step). Mouse selection of a chip is handled in the - // backdrop click handler below. Undo/redo must NOT reach the browser: the - // machine owns the transaction log. - // selectionStart/End are number|null in lib.dom; the type-aware lint program narrows them. - /* oxlint-disable typescript/no-unnecessary-condition */ - const selectionOf = (el: HTMLTextAreaElement) => ({ - start: el.selectionStart ?? 0, - end: el.selectionEnd ?? el.selectionStart ?? 0, - }) - /* oxlint-enable typescript/no-unnecessary-condition */ - const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => { if (input === undefined || keyboard === undefined) return // absent machine: no draft can be copied or cut const el = e.currentTarget const { start, end } = selectionOf(el) if (start === end) return - const slice = draft.slice(start, end) - const touched = input.occurrences.filter(o => o.offset >= start && o.offset < end) + const touched = input.occurrences.filter(o => o.offset < end && o.offset + o.length > start) if (touched.length === 0 && !cut) return // plain copy of plain text: native path is fine e.preventDefault() - // Expand placeholders to their owner clipboard projections. + const copyStart = touched.reduce((value, o) => Math.min(value, o.offset), start) + const copyEnd = touched.reduce((value, o) => Math.max(value, o.offset + o.length), end) + // Expand structured ranges to their owner clipboard projections. let text = '' - let cursor = start + let cursor = copyStart for (const o of touched) { text += draft.slice(cursor, o.offset) + o.clipboardText - cursor = o.offset + 1 + cursor = o.offset + o.length } - text += draft.slice(cursor, end) + text += draft.slice(cursor, copyEnd) e.clipboardData.setData('text/plain', text) if (cut && !machineBusy && !locked) { - keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 }) - restoreCaret(el, start) + keyboard.setDraft( + draft.slice(0, copyStart) + draft.slice(copyEnd), + { start: copyStart, end: copyEnd, insertedLength: 0 }, + ) + restoreCaret(el, copyStart) } - void slice } const onPaste = (e: React.ClipboardEvent): void => { @@ -496,16 +510,15 @@ export function InputBar({ ? null : - // Mirror-layer decorations: a visible backdrop with transparent text. The - // claim token highlights through behind the textarea glyphs; each U+FFFC - // placeholder renders as a chip (the textarea's own glyph is invisible, the - // backdrop chip supplies the visual); the claim hint is ghost text. + // Mirror-layer decorations: a visible backdrop with transparent textarea + // text. Claim tokens and references retain the draft's own glyph metrics, + // so their decoration cannot drift from wrapping, selection, or the caret. const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon) const backdrop: ReactNode[] = [] { - // Segment boundaries: the token range end, every chip offset, and every - // text-ref range — merged in draft order (the sources never - // overlap: chips sit on placeholders, text-refs on plain tokens, the + // Segment boundaries: the token range end, every structured-reference + // offset, and every text-ref range — merged in draft order (the sources never + // overlap: structured references own their ranges, text-refs own plain tokens, the // claim token only leads). let cursor = 0 const pushPlain = (upTo: number): void => { @@ -533,27 +546,44 @@ export function InputBar({ if (b.kind === 'chip') { const chip = b.chip backdrop.push( - // The cell's ::before renders U+FFFC itself so its advance equals the - // textarea's placeholder exactly (same char, same font); the label is - // a clipped overlay that never affects layout. - {chip.label} + {chip.appearance === undefined + ? chip.text[0] + : ( + + {chip.text[0]} + + + )} + {chip.text.slice(1)} , ) - cursor = chip.offset + 1 // the placeholder char the chip stands for + cursor = chip.offset + chip.length } else { // Plain-range highlight: the glyphs stay the // textarea's (advance untouched); the mark paints the chip look. + const text = draft.slice(b.ref.start, b.ref.end) backdrop.push( - {draft.slice(b.ref.start, b.ref.end)} + {b.ref.appearance === 'folder' + ? ( + <> + + {text[0]} + + + {text.slice(1)} + + ) + : text} , ) cursor = b.ref.end @@ -621,7 +651,14 @@ export function InputBar({ which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
-
{backdrop}
+
+ {backdrop} +