mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge origin/master into xtr/session-format-migration
This commit is contained in:
@@ -480,6 +480,9 @@
|
||||
"testing/2026-07-18-tui-terminal-state-snapshots.zh.md": "sha256:26750f240f6c8a7b28746f62fe161b357e9c5dd52867cc7037399f1ed6ff37fa",
|
||||
"testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml": "sha256:dd45cddb591b892739b75b0c180bde7f14008f4769227b863571475be295e1e0",
|
||||
"testing/2026-07-26-execa-for-test-subprocess-plumbing.md": "sha256:1f45a69d0a7367ec5afbf112a77b355339b35270af8ff52696bee879cdf770d3",
|
||||
"testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md": "sha256:8a24bdc8376373d7a97f65cefc07078824bf918d6a9934056a025ecfafe8634b"
|
||||
"testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md": "sha256:8a24bdc8376373d7a97f65cefc07078824bf918d6a9934056a025ecfafe8634b",
|
||||
"testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml": "sha256:741e7e58e5e8a9c82d901c4a16a70cea9bd256eac0e94179b5a24a231bb9fe1f",
|
||||
"testing/2026-08-12-required-python-runtime-pull-request-ci.md": "sha256:1f1273d7a550667533e29c76efd148aebf57581a91729c877b44a5e43a52d9ad",
|
||||
"testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md": "sha256:6b9bf126c6b83d9b21e135d38df677c0d5623168b4353c6ddb706f76762c2193"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md
|
||||
2026-08-12-required-python-runtime-pull-request-ci.md: 61b1e832be6d29eafe5cb304d2bca3f0a59e3d84
|
||||
2026-08-12-required-python-runtime-pull-request-ci.zh.md: 1702af710837c45094711cf52e88bd54d71f7171
|
||||
2026-08-12-required-python-runtime-pull-request-ci.md: e7da767f22634bd50bc4fd38b1de34677c4124e7
|
||||
2026-08-12-required-python-runtime-pull-request-ci.zh.md: 702125b0da864eb35f1fe870748cc0e314b01a39
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Required Python runtime pull-request validation
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-23
|
||||
|
||||
English | [中文](2026-08-12-required-python-runtime-pull-request-ci.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 必需的 Python 运行时拉取请求验证
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-08-23
|
||||
|
||||
[English](2026-08-12-required-python-runtime-pull-request-ci.md) | 中文
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cff72ae10eb82c65c499123cc559cc6ad7e440ab
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 2a39409c7db2bf1de75843e3642ef27051ccfb17
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 0f4bb7cf3e1914c008ae23590a3a26fdb87f0842
|
||||
|
||||
+8
-8
@@ -23,12 +23,12 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h
|
||||
|
||||
Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former.
|
||||
|
||||
### The serving interface is a plugin: the two packages sdk/server + examples/jsonrpc-demo
|
||||
### The serving interface is a plugin: the two packages sdk/server + sdk/python-runtime
|
||||
|
||||
The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin:
|
||||
|
||||
- [`packages/sdk/server`](../../../../packages/sdk/server/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-server`): the pure protocol plugin; on apply it mounts `HarnessSdkJsonRpcServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process).
|
||||
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-sdk-jsonrpc-server` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130).
|
||||
- [`packages/sdk/python-runtime`](../../../../packages/sdk/python-runtime/README.md) (`@deepseek-ai/dsh-sdk-python-runtime`): a private packaged entry — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-sdk-jsonrpc-server` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the packaged entry (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130).
|
||||
|
||||
Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic.
|
||||
|
||||
@@ -36,19 +36,19 @@ 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) 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 is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-sdk-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
|
||||
The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call.
|
||||
|
||||
### Build pipeline and artifacts
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. 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.
|
||||
[`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-sdk-python-runtime-closure 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-python-runtime/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry.
|
||||
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal.
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all three targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` and the `build-exe` label can still select a subset. 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 installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal.
|
||||
|
||||
### Python SDK distribution: two carriers, exe for production, node for development
|
||||
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe 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.
|
||||
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-python-runtime/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions.
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v<repository-version>` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and 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.
|
||||
|
||||
@@ -56,7 +56,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c
|
||||
|
||||
### Naming lineage
|
||||
|
||||
`@deepseek-ai/dsh-sdk-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`.
|
||||
`@deepseek-ai/dsh-sdk-python-runtime` (the private carrier) → `dsh-sdk-python-runtime-closure` (the deploy manifest; no scope prefix, so it is not a dsh release package) → `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`.
|
||||
|
||||
## Disposition of worker-style plugins
|
||||
|
||||
@@ -64,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 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`.
|
||||
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 installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns 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 installed run compares 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. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+8
-8
@@ -23,12 +23,12 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
|
||||
|
||||
术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。
|
||||
|
||||
### 对外服务接口也是插件:sdk/server + examples/jsonrpc-demo 两个包
|
||||
### 对外服务接口也是插件:sdk/server + sdk/python-runtime 两个包
|
||||
|
||||
确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件:
|
||||
|
||||
- [`packages/sdk/server`](../../../../packages/sdk/server/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。
|
||||
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。
|
||||
- [`packages/sdk/python-runtime`](../../../../packages/sdk/python-runtime/README.zh.md)(`@deepseek-ai/dsh-sdk-python-runtime`):私有打包入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归打包入口所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。
|
||||
|
||||
配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。
|
||||
|
||||
@@ -36,19 +36,19 @@ 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) 读取每个已发布的 `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` 覆盖。
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-sdk-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
|
||||
部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。
|
||||
|
||||
### 构建流水线与产物
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@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 不会从注册表解析这些未发布名称。
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-sdk-python-runtime-closure 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-python-runtime/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。
|
||||
|
||||
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。
|
||||
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部三个目标;`workflow_dispatch` 与 `build-exe` 标签仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。
|
||||
|
||||
### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发
|
||||
|
||||
Python SDK 位于 [`python/`](../../../../python/README.zh.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 包分发。
|
||||
Python SDK 位于 [`python/`](../../../../python/README.zh.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-python-runtime/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v<repository-version>` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-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、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。
|
||||
|
||||
@@ -56,7 +56,7 @@ exe「必须显式配置」的硬语义不变;零配置体验由包装层恢
|
||||
|
||||
### 命名血统
|
||||
|
||||
`@deepseek-ai/dsh-sdk-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。
|
||||
`@deepseek-ai/dsh-sdk-python-runtime`(私有载体)→ `dsh-sdk-python-runtime-closure`(部署 manifest;没有作用域前缀,因此不属于 dsh 发布包)→ `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。
|
||||
|
||||
## 工作线程插件
|
||||
|
||||
@@ -64,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`。文件系统搜索场景要求模型通过目标平台的 `-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` 的情况下运行。
|
||||
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 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` 保持独立,因为二者的协议和构建产物不同。
|
||||
|
||||
|
||||
手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
|
||||
2026-07-19-gui-web-client-architecture.md: 8b4f940299cbba78d403c34b1e5fc9740e44f2c2
|
||||
2026-07-19-gui-web-client-architecture.zh.md: 705b1337dd97ac37bd01bdcc5aa22484b7971908
|
||||
2026-07-19-gui-web-client-architecture.md: 4448fd5c6871d67b30b71cfe4377682639704235
|
||||
2026-07-19-gui-web-client-architecture.zh.md: e8a8121a1a495db7f5392e288f3bcada92c70495
|
||||
|
||||
@@ -48,7 +48,7 @@ There is no component registration model besides slots — the former view and t
|
||||
|
||||
**Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
|
||||
|
||||
## The data object layer (`packages/client/runtime/src/client/sessions/`)
|
||||
## The data object layer (`packages/api/session-controller/src/client/`)
|
||||
|
||||
Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable):
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ slot 之外不存在第二种组件注册模型——原视图环与工具环都
|
||||
|
||||
**scope 寻址**与 host 侧 agent(智能体)scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。
|
||||
|
||||
## 数据对象层(`packages/client/runtime/src/client/sessions/`)
|
||||
## 数据对象层(`packages/api/session-controller/src/client/`)
|
||||
|
||||
帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React import,grep 可断言):
|
||||
|
||||
|
||||
@@ -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-07-20-todo-event-ownership.md
|
||||
2026-07-20-todo-event-ownership.md: f3f7f872b24d8f20b6b9acb57710fae388c8b9d8
|
||||
2026-07-20-todo-event-ownership.zh.md: a05622dc39163c4f5b30a93190d9d56bb02cf29a
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: todo event types belong to their producer
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-todo-event-ownership.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`SessionEventMap` is merge-extensible so each plugin can add durable records without making the core session package depend on every event producer. `todo/write` and its `TodoItem` payload are produced and interpreted by the todo domain, while core session only provides the generic append, replay, surface, and invariant extension mechanisms. Declaring todo-specific types or relationships in core would make the session spine own a plugin vocabulary it cannot produce or validate completely.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-tool-todo` declares `TodoItem` and merges `todo/write` into `@deepseek-ai/dsh-session/types` from its type-only outlet. The package root and `/client` entrypoint re-export `TodoItem`, so host and browser consumers share one declaration without loading the todo plugin.
|
||||
|
||||
Consumers that inspect todo records use type-only imports plus explicit package dependencies and TypeScript project references. The emitted JavaScript has no todo import, and a composition does not need to mount the todo tool merely to search, transmit, or render a log that may contain `todo/write`.
|
||||
|
||||
The todo invariant companion owns both the payload rules and the event's relationship to an open turn. Core session's merge-extensible switch falls through for `todo/write`, while the todo companion rejects malformed snapshots and snapshots outside an open turn before append. It validates existing and newly announced sessions in one pass and advances a committed per-session turn trace for later events. Todo-specific append, replay, projection, and enclosure tests live with the todo package. The model-facing behavior remains owned by the [`todo_write` feature decision](../feature/2026-06-29-todo-write-tool.md).
|
||||
|
||||
## Verification
|
||||
|
||||
Focused todo tool, invariant, projection, integration, and Loader-composition tests exercise the producer and its companion. Session-query extraction and client runtime/connection tests prove type-only consumers retain semantic todo handling. Workspace typecheck proves declaration merging through the explicit project graph; generated event, persistence, API, and module catalogs record the declaration site and dependency edges.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the payload type in core as shared UI vocabulary** — rejected because rendering reuse does not make core the producer or semantic owner of the durable event.
|
||||
- **Narrow `todo/write` structurally in each consumer** — rejected because duplicate payload declarations can drift and bypass the merge-extensible event map.
|
||||
- **Require every consumer to mount the todo plugin** — rejected because reading a durable record is a type and data dependency, not authorization to install a model-facing tool.
|
||||
|
||||
## Consequences
|
||||
|
||||
The core session package does not export `TodoItem` or enforce todo relationships. A package that names or narrows `todo/write` declares a type-only dependency on `dsh-tool-todo`; consumers that treat unknown merged events generically need no dependency. The todo package is the single source for the event payload, client type, runtime validation, and open-turn rule.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: todo 事件类型归其生产方所有
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-todo-event-ownership.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`SessionEventMap` 可通过声明合并扩展,使每个插件都能添加持久记录,而无需让核心会话包依赖所有事件生产方。`todo/write` 及其 `TodoItem` payload 由 todo 领域生产和解释;核心会话只提供通用的追加、回放、surface 与不变量扩展机制。在核心中声明 todo 专属类型或关系,会让会话主干拥有一个它既不生产、也无法完整校验的插件词汇。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-tool-todo` 在其仅类型出口中声明 `TodoItem`,并通过 `@deepseek-ai/dsh-session/types` 的声明合并加入 `todo/write`。包根入口和 `/client` 入口重新导出 `TodoItem`,使 host 与浏览器消费方共享同一处声明,而无需加载 todo 插件。
|
||||
|
||||
检查 todo 记录的消费方使用仅类型导入,并声明显式包依赖与 TypeScript 项目引用。产出的 JavaScript 不含 todo 导入;组合仅为了搜索、传输或渲染可能含有 `todo/write` 的日志时,无需挂载 todo 工具。
|
||||
|
||||
todo 不变量配套插件同时拥有 payload 规则和事件必须位于开放轮次内的关系。核心会话的可合并扩展 switch 对 `todo/write` 走默认分支;todo 配套插件会在追加前拒绝格式错误或位于开放轮次之外的快照。它会单次校验现有会话与新发布的会话,并为后续事件推进逐会话的已提交轮次追踪状态。todo 专属的追加、回放、投影和轮次封闭测试与 todo 包放在一起。面向模型的行为仍由 [`todo_write` 功能决策](../feature/2026-06-29-todo-write-tool.zh.md)负责。
|
||||
|
||||
## 验证
|
||||
|
||||
聚焦的 todo 工具、不变量、投影、集成和 Loader 组合测试覆盖生产方及其配套插件。session-query 提取与客户端 runtime/connection 测试证明仅类型消费方仍能保留 todo 的语义处理。全工作区类型检查证明声明合并通过显式项目图生效;重新生成的事件、持久化、API 与模块目录记录声明位置和依赖边。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **把 payload 类型留在核心中作为共享 UI 词汇**——拒绝:渲染复用并不会让核心成为持久事件的生产方或语义所有方。
|
||||
- **让每个消费方各自按结构收窄 `todo/write`**——拒绝:重复的 payload 声明会漂移,并绕过可合并扩展的事件表。
|
||||
- **要求每个消费方都挂载 todo 插件**——拒绝:读取持久记录是类型和数据依赖,并不构成安装面向模型工具的授权。
|
||||
|
||||
## 后果
|
||||
|
||||
核心会话包不导出 `TodoItem`,也不强制 todo 关系。命名或收窄 `todo/write` 的包声明对 `dsh-tool-todo` 的仅类型依赖;只把未知合并事件作通用处理的消费方无需依赖它。todo 包是事件 payload、客户端类型、运行时校验和开放轮次规则的唯一来源。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md
|
||||
2026-07-30-client-locale-full-rollout.md: 6701aefa451786d3ca6ac27d7214824a6d903bab
|
||||
2026-07-30-client-locale-full-rollout.zh.md: 427c9e5ef9c544a49e70b6ba8450511072f53a6e
|
||||
2026-07-30-client-locale-full-rollout.md: aeb4deae28b0dfdb9ab75fd64fe3143958cd6910
|
||||
2026-07-30-client-locale-full-rollout.zh.md: a642b6062cb3dc7a2dfa22dd5d8cf7d9a02e3104
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Full client copy rollout onto the typed locale seat, and the non-translation boundary
|
||||
# Agent Note: Full client copy rollout onto the typed locale seat
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-07-30-client-locale-full-rollout.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms and boundary decisions the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch; how the zero-cordis ui-primitives atoms receive copy; and which strings deliberately stay untranslated — an unrecorded boundary invites a future agent to "complete" the localization.
|
||||
After the typed locale standard seat landed (`locale:` on register → framework-injected typed `t`), only four early adopters rode it; every other client package still shipped hardcoded, mixed-language literals. Migrating the rest required mechanisms the early adopters never touched: how registration-time text (nav rows, view-tab labels) refreshes on a language switch, and how the zero-Cordis ui-primitives atoms receive copy without depending on the runtime.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -14,16 +14,11 @@ After the typed locale standard seat landed (`locale:` on register → framework
|
||||
|
||||
**Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record<string, string>` is the key source and `en satisfies Record<XxxKey, string>` locks bilingual balance.
|
||||
|
||||
**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity).
|
||||
**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionBanner`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md).
|
||||
|
||||
**The non-translation boundary (deliberate decisions, not debt):**
|
||||
**Every product-authored UI phrase is translated.** Client fallbacks, design labels, trajectory inspection, accessibility names, and formatter units are dictionary-owned under the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). User/model/provider/wire text and protocol or code tokens remain verbatim data. Framework-free boot markup still runs before the locale service; the localized application replaces its product copy after activation.
|
||||
|
||||
- **Error/failure strings stay English**: client-authored fallbacks (`command failed`, plan-toggle failures), RpcError messages, and wire `error.message (code)` pass-throughs render verbatim.
|
||||
- **Design literals stay out of the dictionaries**: tool-row variant titles (Think/Bash/…), SYSTEM/USER-style kind badges, the Plan chip wordmark, the whole StatsLine — identical in both languages.
|
||||
- **ui-trajectory is deferred wholesale** (a developer inspection surface, terminology-dense, ruled separately).
|
||||
- **Boot copy stays hardcoded** (the framework-free boot page runs before the locale service exists).
|
||||
|
||||
**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure.
|
||||
**Derivation layers keep display text out of identity.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank session titles and the Ungrouped label derive from the `blank` flag / absent `workspaceId`, while internal values stay empty or stable; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter.
|
||||
|
||||
**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (an `en-US` browser) and the built-boot snapshot pins the same navigator language—goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the provisional locale follows `navigator` before an explicit Host preference arrives ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)).
|
||||
|
||||
@@ -33,7 +28,7 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels
|
||||
|
||||
- **Keep labels as strings and re-register on switch** (the early adopters' original shape): boot already registers once per package, and `locale/change` listeners re-registering amplifies into a storm; ledger version churn also busts every version-keyed projection cache. Thunks move the refresh cost to read points that already follow the revision.
|
||||
- **A locale context/injection channel for ui-primitives**: breaks the zero-cordis boundary (atoms would depend on the runtime) and drags unlocalized consumers (ui-trajectory) along. Props let each consumer decide independently.
|
||||
- **Error strings in the dictionaries**: the error surface is a debugging surface — verbatim English is what gets searched and compared in reports; wire pass-throughs are untranslatable anyway, and half-translation manufactures mixed-language text.
|
||||
- **Translate external or wire error data**: rejected because provider and protocol diagnostics are evidence searched and compared verbatim. Product-authored surrounding failure chrome is translated; externally authored data is not.
|
||||
- **`toLocaleString()`/Intl for dates**: follows the browser/OS language, not the app locale, guaranteeing mixed text after a switch; the dictionary templates are tiny and isomorphic to the message clock.
|
||||
- **Blank rows matching search (against localized or stored titles)**: either choice yields "visible but unfindable" in one language; placeholder rows carry no information, so whole-row exclusion is the stable semantic.
|
||||
|
||||
@@ -41,5 +36,5 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels
|
||||
|
||||
- A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue.
|
||||
- Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically.
|
||||
- ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo.
|
||||
- ui-primitives require localized label props, so adding a primitive render site also adds an explicit copy owner; omission fails typechecking instead of selecting a hidden language.
|
||||
- Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: client 文案全量接入 typed locale 席位与不翻译边界
|
||||
# Agent Note: client 文案全量接入 typed locale 席位
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制与边界决定:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新;zero-cordis 的 ui-primitives 原子组件如何拿到文案;哪些字符串**刻意不**本地化——没有记录的边界会诱使未来的 agent(智能体)「补完」翻译。
|
||||
typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`)落地后,只有四个先行包接入;其余 client 包的文案仍是硬编码的中英混杂字面量。全量迁移需要几个先行包没有触及的机制:注册期文本(导航行、视图 tab 的 label)在语言切换时如何刷新,以及 zero-Cordis 的 ui-primitives 原子组件如何在不依赖运行时的情况下拿到文案。
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -14,16 +14,11 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
|
||||
|
||||
**组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record<string, string>` 为 key 源、`en satisfies Record<XxxKey, string>` 锁双语平衡。
|
||||
|
||||
**zero-cordis 原子组件(ui-primitives)文案 props 化**:`HoverCard` 的 `copyLabel`/`copiedLabel`、`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费方渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。
|
||||
**zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionBanner` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责。
|
||||
|
||||
**不翻译边界(刻意决定,不是欠账):**
|
||||
**所有产品编写的 UI 短语都翻译。** client 兜底文案、设计 label、trajectory 检查面、无障碍名称和格式化单位均按 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)进入字典。用户/模型/提供方/wire 文本以及协议或代码 token 仍作为数据原样呈现。不依赖框架的 boot 标记仍早于 locale 服务运行;本地化应用激活后会替换其中的产品文案。
|
||||
|
||||
- **错误/失败类字符串一律英文**:client 自产的兜底串(`command failed`、plan 切换失败)、RpcError 消息、wire 透出的 `error.message (code)` 原样呈现。
|
||||
- **设计字面量不进字典**:工具行 variant 标题(Think/Bash/…)、SYSTEM/USER 类 kind 徽标、Plan chip 字标、整个 StatsLine——中英界面显示一致。
|
||||
- **ui-trajectory 整包缓做**(开发者检查面,术语密集,单独裁决)。
|
||||
- **boot 文案保持硬编码**(不依赖框架的启动页运行早于 locale 服务可用)。
|
||||
|
||||
**派生层保持纯函数,本地化只在渲染层**:ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板;blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。
|
||||
**派生层不让展示文本承担身份。** ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}`,由渲染组合字典模板;blank 会话标题和未分组 label 从 `blank` 标志/`workspaceId` 缺席派生,内部值保持为空或稳定;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数接收 `t` 参数。
|
||||
|
||||
**测试与 e2e 口径**:`makeTranslate(...dicts)`(dsh-client-test-runtime)镜像服务查找链(首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一通过 `newEnglishPage`(`en-US` 浏览器)打开,built-boot 快照 同样固定 navigator 语言:golden 因而不受语言迁移影响。settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为在显式 Host 偏好到达前,暂定 locale 会跟随 `navigator`([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。
|
||||
|
||||
@@ -33,7 +28,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
|
||||
|
||||
- **label 保持 string、语言切换时重注册**(先行包的旧形态):boot 已经为每个包注册一次,`locale/change` 监听者重注册会放大成风暴;ledger version 抖动还会击穿一切按 version 缓存的投影。thunk 把刷新成本移到读取点,读取点本来就跟随 revision。
|
||||
- **给 ui-primitives 造 locale 上下文/注入通道**:破坏 zero-cordis 边界(原子组件从此依赖运行时),且强迫未本地化消费方(ui-trajectory)陪跑。props 化让每个消费方独立决定。
|
||||
- **错误串进字典**:错误面是排障面,英文原样最利于搜索与上报比对;且 wire 透出串本就不可译,半译反而制造混合语言。
|
||||
- **翻译外部或 wire 错误数据**:否决。提供方与协议诊断是需要原样搜索和比对的证据。产品编写的外围失败 chrome 会翻译,外部编写的数据不会。
|
||||
- **日期用 `toLocaleString()`/Intl**:跟随浏览器/OS 语言而非应用语言,切换后必然产生混合文本;字典模板量小且与消息时钟同构。
|
||||
- **blank 行参与搜索(匹配本地化标题或存储标题)**:任一选择都在某个语言下「看得见搜不到」;占位行本无信息量,整体排除语义最稳。
|
||||
|
||||
@@ -41,5 +36,5 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
|
||||
|
||||
- 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。
|
||||
- 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。
|
||||
- ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费方传入 labels**——未迁移的 JsonTree 消费方(ui-trajectory)显示其英文默认值,恰好符合其整包英文现状。
|
||||
- ui-primitives 要求本地化 label prop,因此新增原子组件渲染点也必须新增明确的文案 owner;遗漏会在类型检查失败,而不是选择隐藏语言。
|
||||
- e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md)。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md
|
||||
2026-08-08-client-tool-presentation-ownership.md: 3feefc3cfbe538024b8610394b9f170c423556e8
|
||||
2026-08-08-client-tool-presentation-ownership.zh.md: 181c57a0da61795292d70b3d37ebd1485832795b
|
||||
2026-08-08-client-tool-presentation-ownership.md: 1daad1559a6c8ef15fadb8e7c8dfeb2874ae3f9a
|
||||
2026-08-08-client-tool-presentation-ownership.zh.md: f980db28e1174aa95b29defb8b0a36fc0ba4cf2e
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Tool is a first-class Client UI presentation concept. `@deepseek-ai/dsh-client-u
|
||||
|
||||
Conversation data assembly follows the later [Conversation business-node decision](2026-08-09-client-conversation-node-assembly.md). The `ui-conversation` Tool Definition pairs root call/result Session Events, folds Code Dispatch edges into recursive `ToolCallBlock.subCalls`, and emits one stable `tool-call` Chat Node. This data responsibility handles only official Tool identity and topology; it does not interpret presentation for concrete Tool names.
|
||||
|
||||
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists.
|
||||
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only places generic [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) entries in Chat snapshot `order`. A Seat dispatches `'conversation.chat.node'` by `node.kind`; [`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the `tool-call` entry, and [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) recursively traverses the root block. Every root or child level dispatches through the same keyed/session `'tool.call.toolview'` child slot with `entryKey: toolName`, falling back to `GenericToolCard` when no registration exists.
|
||||
|
||||
A business Tool plugin receives one standard `ToolCallBlock`, identity, workspace cwd, and host actions; it does not read Session, Context, or the Conversation assembler. Skill remains an ordinary Tool and uses the same keyed-slot registration path as other business Tools.
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Client 运行时已经按 `callId` 配对工具调用/结果事件,并能从 C
|
||||
|
||||
Conversation 数据组装遵循后续的 [Conversation 业务节点决策](2026-08-09-client-conversation-node-assembly.zh.md)。`ui-conversation` 的工具 Definition 从会话事件配对 root call/result,把 Code Dispatch edge fold 成递归 `ToolCallBlock.subCalls`,并生成一个稳定的 `tool-call` Chat Node;这里的数据职责只处理官方工具 identity 和拓扑,不解释具体工具名称的展示。
|
||||
|
||||
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只按 Chat 快照的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`;[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`。
|
||||
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只按 Chat 快照的 `order` 放置通用 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx)。Seat 以 `node.kind` 分发 `'conversation.chat.node'`;[`ui-tool`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册 `tool-call` entry,并由 [`ToolCallTree`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx) 递归遍历 root block。每一层 root 或 child 都通过同一个 keyed/session `'tool.call.toolview'` 子 slot 以 `entryKey: toolName` 分发,缺少注册时渲染 `GenericToolCard`。
|
||||
|
||||
业务工具插件接收一个标准 `ToolCallBlock`、identity、workspace cwd 和宿主动作,不读取会话、上下文或 Conversation assembler。skill(技能)仍是普通工具;它和其他业务工具使用同一 keyed slot 注册路径。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
|
||||
2026-08-09-client-conversation-node-assembly.md: 69f92b906e46ae881b7aa6b5e46e998047fca8c2
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: d075e009d9a04f20dbc8dda518e90b368b54286f
|
||||
2026-08-09-client-conversation-node-assembly.md: e6c0e790a361265870a04ee63301b9f11940c648
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: 702ddba0019e125d3976727f841db775276b3b77
|
||||
|
||||
+6
-6
@@ -33,7 +33,7 @@ Registry contributions are Cordis effects. Removing a Definition causes a low-fr
|
||||
|
||||
### Overall `ConversationNodeDefinition` contract
|
||||
|
||||
Each [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs.
|
||||
Each [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) independently owns one business object's conversion from Events to State and final view Nodes. A Definition's `kind` is its unique Registry name and the namespace for its business IDs.
|
||||
|
||||
One Event may be claimed by several ordinary Definitions. For example, an Assistant Event updates both the Assistant Node and Turn Tail, while a Retry Event updates Retry, Assistant, and Turn Tail. The Assembler asks the fallback only when every ordinary Definition returns `null`.
|
||||
|
||||
@@ -160,7 +160,7 @@ IDs are never reused. Completed Contexts remain in the current window, providing
|
||||
|
||||
### Location is a first-class engine fact
|
||||
|
||||
[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`.
|
||||
[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) maps Events to Locations from `turn/start`, `step/start`, explicit turn and step payloads, `step/end`, and `turn/end`.
|
||||
|
||||
Location has four shapes: `session`, `turn`, `step`, and `unresolved`. Turns and Steps each carry `open`, `closed`, or `unknown` status plus any loaded start and end Events.
|
||||
|
||||
@@ -302,19 +302,19 @@ Unknown fallback demonstrates Registry ownership: it handles only append-surface
|
||||
|
||||
## View Builder and React identity
|
||||
|
||||
[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches.
|
||||
[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches.
|
||||
|
||||
The Assembler calls `replace({ nodes, timeline })` on low-frequency complete replacements and `apply({ upserts, timeline })` for ordinary prepend/append flushes. Builders receive only final target Nodes already constructed by Definitions.
|
||||
|
||||
[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields.
|
||||
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields.
|
||||
|
||||
Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces only that key's value.
|
||||
|
||||
For a structural change, the Builder computes visible order from current store values and reuses unchanged index arrays by reference. Prepend may add earlier history keys, append may add a key at the tail or its business anchor, and ordering never renames existing keys.
|
||||
|
||||
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`.
|
||||
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) only traverses `order`. Each [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) remains in the same parent list under its Context key and dispatches the `'conversation.chat.node'` keyed slot by `node.kind`.
|
||||
|
||||
[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins still live in `ui-conversation`, but this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher.
|
||||
[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) is a declaration-merged renderer payload registry. Each business module registers its own Definition and keyed renderer; `registerConversationNodes()` and `registerChatNodeRenderers()` only assemble those independent contributions and do not interpret business through a closed union or central switch. Built-ins live in `ui-chat`, and this type and registration boundary allows a business to move into an independent package without changing the Chat dispatcher.
|
||||
|
||||
The Chat entry in `conversation.view` registers `ChatNodeTurnDataInjected` once when it declares the `conversation.chat.node` child slot. `ChatNodeSeat` passes only the stable Node key as `hookContext`; the Slot renderer combines that key with `useSession` from the official standard props to construct `useTurnData(businessKey)`. Every keyed Chat renderer therefore reads strongly typed, read-only data from its own Node's Turn, and the Assistant renderer has no special injection authority.
|
||||
|
||||
|
||||
+6
-6
@@ -33,7 +33,7 @@ Registry 注册是 Cordis effect,Definition 卸载会触发现有 Session 的
|
||||
|
||||
### `ConversationNodeDefinition` 总体契约
|
||||
|
||||
每个 [`ConversationNodeDefinition`](../../../../packages/client/runtime/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。
|
||||
每个 [`ConversationNodeDefinition`](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 独立拥有一种业务对象从 Event 到 State 和最终 view Node 的转换。Definition 的 `kind` 是 Registry 内唯一名称,也是业务 ID 的命名空间。
|
||||
|
||||
同一个 Event 可以被多个普通 Definition 认领。例如一条 Assistant Event 同时更新 Assistant Node 和 Turn Tail;一条 Retry Event 同时更新 Retry、Assistant 和 Turn Tail。Assembler 只有在全部普通 Definition 都返回 `null` 时才询问 fallback。
|
||||
|
||||
@@ -160,7 +160,7 @@ ID 不复用,完成的 Context 继续存在于当前窗口,既提供稳定
|
||||
|
||||
### Location 是一级引擎事实
|
||||
|
||||
[`ConversationLocationIndex`](../../../../packages/client/runtime/src/client/sessions/conversation-location-index.ts) 根据 `turn/start`、`step/start`、显式 turn/step payload、`step/end` 和 `turn/end` 建立 Event 到 Location 的映射。
|
||||
[`ConversationLocationIndex`](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) 根据 `turn/start`、`step/start`、显式 turn/step payload、`step/end` 和 `turn/end` 建立 Event 到 Location 的映射。
|
||||
|
||||
Location 有 `session`、`turn`、`step` 和 `unresolved` 四种形状。Turn/Step 各自带 `open`、`closed` 或 `unknown` 状态,以及已加载的 start/end Event。
|
||||
|
||||
@@ -302,19 +302,19 @@ Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何
|
||||
|
||||
## View Builder 与 React identity
|
||||
|
||||
[`ConversationViewRegistry`](../../../../packages/client/runtime/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。
|
||||
[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。
|
||||
|
||||
Assembler 低频完整替换时调用 `replace({ nodes, timeline })`;普通 prepend/append flush 调用 `apply({ upserts, timeline })`。Builder 只接收 Definition 已构造完成的 target Nodes。
|
||||
|
||||
[`ChatSnapshotBuilder`](../../../../packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。
|
||||
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。
|
||||
|
||||
Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value。
|
||||
|
||||
Builder 遇到结构变化时从 store 的当前 values 计算 visible order,并按未变化引用复用索引数组。Prepend 可以增加前部历史 key,append 可以增加尾部或按业务 anchor 落位,既有 key 不因排序变化而重命名。
|
||||
|
||||
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。
|
||||
[`ChatView`](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 只遍历 `order`。每个 [`ChatNodeSeat`](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) 以 Context key 固定在同一个父列表中,并按 `node.kind` 分发 `'conversation.chat.node'` keyed slot。
|
||||
|
||||
[`ChatNodeDataMap`](../../../../packages/client/ui-conversation/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;`registerConversationNodes()` 与 `registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现仍位于 `ui-conversation`,但该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。
|
||||
[`ChatNodeDataMap`](../../../../packages/client/ui-chat/src/client/contract/chat-nodes.ts) 是 declaration-merged 的 renderer payload registry。每个业务模块分别注册自己的 Definition 和 keyed renderer;`registerConversationNodes()` 与 `registerChatNodeRenderers()` 只负责装配这些独立贡献,不通过 closed union 或中心 switch 解释业务。内建实现位于 `ui-chat`,且该类型和注册边界允许业务迁入独立 package 而不修改 Chat dispatcher。
|
||||
|
||||
`conversation.view` 的 Chat entry 在声明 `conversation.chat.node` child slot 时统一注册 `ChatNodeTurnDataInjected`。`ChatNodeSeat` 只把稳定 Node key 作为 `hookContext` 传给 slot;Slot renderer 用官方 standard props 中的 `useSession` 和该 key 构造 `useTurnData(businessKey)`,因此每个 keyed Chat renderer 都能读取自己 Node 所属 Turn 的强类型只读 data,Assistant renderer 不拥有特殊注入权限。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md
|
||||
2026-08-09-headless-direct-core-entry-point.md: cf6b4a92a6e9b390c7fcaca17f56b4c652cc9319
|
||||
2026-08-09-headless-direct-core-entry-point.zh.md: 9f45fcdaf87ddcccbd331eeacce0dc7035c61f19
|
||||
2026-08-09-headless-direct-core-entry-point.md: 8ed979794afa008588d1b849f0074e8696e6e43f
|
||||
2026-08-09-headless-direct-core-entry-point.zh.md: 512d4b88c921431fe26afd9f62c34a1939ac5bdd
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ The direct entry point still needs the same deployment model state as Web-create
|
||||
|
||||
## Decision
|
||||
|
||||
The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headless bundle supplies its persona and tool mode, disables HMR, mounts the Code Mode worker explicitly, and inserts `headless-runner`. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation.
|
||||
The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The base supplies the disabled module-HMR default; the headless bundle supplies its persona and tool mode, mounts the Code Mode worker explicitly, and inserts `headless-runner` without overriding that policy. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation.
|
||||
|
||||
`headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。headless 组合包提供自身的 persona 与工具模式、禁用 HMR(热模块替换)、显式挂载 Code Mode worker,并插入 `headless-runner`。其插件树不包含任何 `@deepseek-ai/dsh-host-*` 包、ApiProxy、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。
|
||||
随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。base 提供默认禁用模块 HMR(热模块替换)的策略;headless 组合包提供自身的 persona 与工具模式、显式挂载 Code Mode worker,并在不覆盖该策略的情况下插入 `headless-runner`。其插件树不包含任何 `@deepseek-ai/dsh-host-*` 包、ApiProxy、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。
|
||||
|
||||
`headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。结束原因为 `error` 时,其持久化错误码与消息写入 stderr;驱动器的意外失败也写入 stderr 并以 1 退出。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-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
|
||||
2026-08-10-cancelled-stream-prefix-finalize.md: fd397a02663908f5984b4e1798d1b1759b140c79
|
||||
2026-08-10-cancelled-stream-prefix-finalize.zh.md: 44adb2ff4163cd1904a9a93895c99519bae2f234
|
||||
|
||||
+1
-1
@@ -36,4 +36,4 @@ Terminal provider errors still discard their streamed prefix. That asymmetry rem
|
||||
|
||||
## 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.
|
||||
`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-chat/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.
|
||||
|
||||
+1
-1
@@ -36,4 +36,4 @@ Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`
|
||||
|
||||
## 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 快照覆盖完整应用。
|
||||
`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。
|
||||
|
||||
@@ -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-remote-event-delivery.md
|
||||
2026-08-10-remote-event-delivery.md: c0bc459eb5f96dccd135417c0d5d4d2f743aad5e
|
||||
2026-08-10-remote-event-delivery.zh.md: 08e3570708c20223697a186ee79e16f5ac3bda8b
|
||||
2026-08-10-remote-event-delivery.md: 5e6e04bdf2c6b685bbf10f05ede9c96ea8104429
|
||||
2026-08-10-remote-event-delivery.zh.md: d744b92d47f5778397b519fa09d4b91f620dcd7e
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Remote event delivery (ctx.remote.$on)
|
||||
# Agent Note: Remote event delivery (`ctx.remote.$on`)
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,39 +6,51 @@ English | [中文](2026-08-10-remote-event-delivery.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[Typert Gateway targeted method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy.
|
||||
[Typert Remote method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) initially cover targeted calls with one result per request and deliberately leave Session streams and stateful interactions elsewhere. Host-to-consumer events need a delivery mechanism that is not owned by the API Proxy domain.
|
||||
|
||||
The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge.
|
||||
The Host owns one-way events such as `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. They do not depend on AgentScope, and their payloads are already JSON. Requiring every event to cross a handwritten API Proxy frame, a handwritten Client Runtime bridge, and a Client event alias adds no fact beyond the owner event declaration.
|
||||
|
||||
That duplicated declaration is also **lossy**: the Client side restates it as `settings/changed(ns: string)`, flattening a branded type into bare `string` — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol.
|
||||
That duplicate declaration is also lossy: the Client side restates an event as `settings/changed(ns: string)`, flattening a branded type to bare `string`, contrary to the Remote-method rule that consumer types point to the business package's one canonical symbol.
|
||||
|
||||
## Decision
|
||||
|
||||
The consumer Remote surface carries one one-way subscription verb, `ctx.remote.$on(event, listener)`, driven by an allowlist and forwarding verbatim:
|
||||
The consumer Remote surface has one event-subscription verb, `ctx.remote.$on(event, listener)`, with allowlist-driven, verbatim forwarding:
|
||||
|
||||
- `packages/api/remotes/src/remote-events.ts` holds the allowlist of forwardable Host events, and it is the single control point over what a consumer may subscribe to. `src/types.ts` beside it derives the type projection and fills the selection seat, staying type-only per the package convention. Both files are listed in the `files` of **both** of this package's faces, so the Host forwarding loop and the consumer key surface read one declaration.
|
||||
- The wire event name **is** the Host cordis event name (`settings/document-updated`) with no `host/` prefix, and the payload **is** the Host argument list, element for element, with no projection, redaction, or renaming.
|
||||
- The carrier reuses the existing host stream: `HostFrame` gains one wrapper variant, `host/remote-event`. No new downlink.
|
||||
- Event **signatures** get no second table. Each owner package moves its cordis `Events` declaration into its client-safe, type-only `./types` export, so both faces read the same declaration and `$on`'s listener type is `Events[Event]` itself. "Verbatim" then holds by construction rather than by proof.
|
||||
- Only cordis's *type shape* is borrowed, not its event system: delivery semantics, the subscription registry, and failure containment belong to Typert.
|
||||
- `packages/api/remotes/src/remote-events.ts` owns one list of forwardable Host events with explicit `emit`/`waterfall` modes. It is also the sole control point for what consumers may subscribe to. Adjacent `src/types.ts` derives the type projection and fills the selection seat while remaining type-only. Both files appear in the `files` of the package's Host and Client faces, so both read one declaration.
|
||||
- The event name on the wire is the original Host Cordis name (`settings/document-updated`) without a `host/` prefix. The payload is the Host argument list, element for element through JSON, without projection, redaction, or renaming.
|
||||
- `api/remotes` registers the Host source with API Gateway. Gateway reserves internal logical endpoint `$events` on the existing `/api/remote.mux`, adding no physical connection and giving API Proxy no event interpretation. Waterfall results return through HTTP unary endpoint `$events/result`.
|
||||
- Event signatures have no second table. Owner packages place their Cordis `Events` declarations in Client-safe, type-only `./types` exports so both faces read the same declaration. `$on` listener parameters, result, and `next()` derive from `Events[Event]`; verbatim correspondence holds by construction.
|
||||
- Only Cordis's type declarations are shared. Delivery semantics, registration, and failure handling belong to Typert.
|
||||
|
||||
When an `Events` entry's signature reaches a Host-only symbol (a Service, `Agent`, a Context), the answer is to **split the code until the entry lands cleanly in `./types`** — never a declaration half-left in `index.ts`, and never a structurally equivalent shadow type in `./types`. None of the five packages needs that here: their entries reach only `SettingsNamespace`, `SettingsUpdateSource`, `CredentialRef`, and `SessionId`, all pure types. The agent-presets package renames its previous vocabulary module to `preset.ts`, leaving the exported `types.ts` dedicated to the client-safe event declaration.
|
||||
When an `Events` member reaches a Host-only symbol such as a Service, `Agent`, or Context, the code is split until the declaration can live cleanly in `./types`. A declaration is never split between `index.ts` and `types.ts`, and `types.ts` does not invent a structurally equivalent shadow type. Every current owner exposes its selected event declaration from a Client-safe type export.
|
||||
|
||||
All five events ride this path, and their dedicated `HostFrame` variants or Client aliases are gone. Model consumers subscribe directly to both owner inputs, `llm/adapters-updated` and `settings/document-updated`; preset-derived consumers subscribe to `agent-preset/selected`. Frames that actually project or deduplicate data stay dedicated: `host/workspace-changed`/`-removed`/`host/archived-sessions-changed` (view derivation plus per-connection dedup state), and `host/session-added`/`-removed`/`host/session-status`/`host/agent-error` (live-object projection or frame-time derived fields).
|
||||
All allowlisted events use this path, and dedicated frames and Client aliases are removed. Model consumers subscribe directly to `llm/adapters-updated` and `settings/document-updated`; preset consumers subscribe to `agent-preset/selected`; stateless Session and dynamic-Cordis notifications use `emit`; Approval and Question use Agent-scoped `waterfall`. Data that needs a baseline, projection, or deduplication retains a dedicated Remote stream.
|
||||
|
||||
`skills/change`, `tools/change`, and `system-prompt/change` have the same shape but **no shipped consumer**; under "require a current owner and need" they stay out of the allowlist and are recorded here only as the extension seat.
|
||||
`skills/change`, `tools/change`, and `system-prompt/change` have the same pure invalidation form but no shipped consumer. The rule that every abstraction needs a current owner and need keeps them outside the allowlist; they remain only an extension point recorded here.
|
||||
|
||||
### Consumer contract (dsh-typert-protocol)
|
||||
### Consumer contract (`dsh-typert-protocol`)
|
||||
|
||||
type-meta gains one **shape predicate**, one **selection seat**, and **one** member on `TypertClientRemote`. No runtime code:
|
||||
Type metadata adds event-form predicates, mode entries, a selection seat, and one member of `TypertClientRemote`, with no runtime code:
|
||||
|
||||
```ts
|
||||
```ts ignore-check
|
||||
import type { Events } from '@deepseek-ai/cordis'
|
||||
|
||||
/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */
|
||||
type TypertForwardingMode<Event extends keyof Events> =
|
||||
unknown extends ThisParameterType<Events[Event]>
|
||||
? TypertEventResult<Event> extends void ? 'emit' : never
|
||||
: TypertWaterfallEvent<Event> extends never ? never : 'waterfall'
|
||||
|
||||
/** Cordis event names that can cross the Remote Event carrier without a second signature. */
|
||||
export type TypertForwardableEvent = {
|
||||
[Event in keyof Events]: unknown extends ThisParameterType<Events[Event]>
|
||||
? ReturnType<Events[Event]> extends void ? Event : never
|
||||
[Event in keyof Events]: TypertForwardingMode<Event> extends never ? never : Event
|
||||
}[keyof Events]
|
||||
|
||||
/** Event and dispatch mode accepted by the Remote Event source. */
|
||||
export type TypertForwardableEventEntry = {
|
||||
[Event in keyof Events]: TypertForwardingMode<Event> extends infer Mode
|
||||
? Mode extends 'emit' | 'waterfall'
|
||||
? { readonly event: Event; readonly mode: Mode }
|
||||
: never
|
||||
: never
|
||||
}[keyof Events]
|
||||
|
||||
@@ -51,126 +63,136 @@ export type TypertRemoteEvent = Extract<keyof Events, keyof TypertRemoteEventSel
|
||||
|
||||
```ts ignore-check
|
||||
/** Subscribe to one forwarded Host event; the returned disposer belongs to the calling fiber. */
|
||||
$on<Event extends TypertRemoteEvent>(event: Event, listener: Events[Event]): () => void
|
||||
$on<Event extends TypertRemoteEvent>(event: Event, listener: TypertClientEventListener<Event>): () => void
|
||||
```
|
||||
|
||||
`Events` resolves per program: the full Host vocabulary in the Host program, whatever the Client face can see in the Client program. The same predicate therefore holds on both sides without dragging Host declarations into the Client.
|
||||
`Events` resolves per program: the complete Host event vocabulary in a Host program and only declarations visible to the Client compilation face in a Client program. The same predicate therefore holds on both sides without bringing Host declarations into the Client.
|
||||
|
||||
**The surface separates the consumer verb from the carrier handoff**: consumers subscribe with `$on`, and whoever owns the Host frame sink hands each decoded frame over with `$dispatch`. It cannot be a module-level function reaching across Client plugins — the client bundle purity gate (`packages/client/tsdown.client.ts`) admits value imports only from the implicit `PLATFORM_MODULES` plus `PRELOADED_CLIENT_EXTERNALS` baseline, the package's `dsh.client.external` requests, the `INLINE_SAFE` wire layer, and generated `/remote` contributions. Inlining around it would copy `ClientRemoteService` into the runtime bundle, making `instanceof` permanently false. A cordis service method is the collaboration shape that gate prescribes:
|
||||
**The contract exposes only the consumer verb.** `ClientRemoteService` registers the one internal `$events` pump as a Connection generation source when it activates, independently of whether any `$on` subscription exists. Browsers open `$events` through the shared Remote mux; in-process compositions open the same logical stream through `connection.rpc.open`. Decoding, exact item validation, and Cordis dispatch are private Gateway Client implementation. `TypertClientRemote` exposes no producer operation, so a business plugin cannot synthesize a Host event.
|
||||
|
||||
Each time the Host opens `$events`, the API Remotes source factory installs every allowlist listener synchronously. Gateway then yields the opening `{ type: 'ready' }` before iterating the event source. `ConnectionController` waits for that ready item and `host.describe` in parallel and publishes `connected` only after both succeed, so baseline reads cannot race ahead of incremental listeners.
|
||||
|
||||
A physical mux disconnect ends the logical stream with `RemoteStreamCarrierError`. A Host Remote stream error, unexpected normal completion, non-ready opening item, or malformed event item also ends the current generation. Connection withdraws that generation's `hostDescription` and reopens `$events` and `host.describe` after backoff; Gateway mux only rebuilds the physical WebSocket. Ordinary events are not replayed. State whose correctness requires recovery must provide a query, cursor, or opening baseline and cannot treat `$on` as a reliable journal.
|
||||
|
||||
The Client dispatches on a Cordis key private to each Remote instance. Ordinary `emit` uses `parallel()` and contains listener failures; Agent-scoped `waterfall` uses `waterfall()` on the resolved Agent Context and allows a result, rejection, or `next()` delegation. Both registration kinds belong to the calling fiber, and Host events do not trigger same-named Client-local events.
|
||||
|
||||
### The allowlist: one declaration read by both faces
|
||||
|
||||
`packages/api/remotes/src/remote-events.ts` appears in both `tsconfig.host.json` and `tsconfig.client.json` and is the allowlist's sole home. `src/types.ts` derives the type face:
|
||||
|
||||
```ts ignore-check
|
||||
$dispatch(event: string, args: readonly unknown[]): void
|
||||
```
|
||||
|
||||
client/runtime — the owner of the host frame sink — calls it directly, so the frame reaches the subscription table without an intermediate event to relay it. The `event` parameter is `string`, not `TypertRemoteEvent`: this is a wire boundary, and a name nobody subscribed to is dropped silently.
|
||||
|
||||
Delivery shares no implementation with the cordis event system: one-way only, no waterfall/bail/parallel/serial modes and no `@mode` concept (`ReturnType extends void` is the static expression of that rule), no `this` binding, no `EventOptions`, `prepend`, or priority. Listeners run in registration order, and one that throws is contained and logged — it must never take down the frame pump (the same posture `ConnectionController` already applies to its sinks).
|
||||
|
||||
### The allowlist: one declaration both faces read
|
||||
|
||||
`packages/api/remotes/src/remote-events.ts` is listed in the `files` of both `tsconfig.host.json` and `tsconfig.client.json`, and is the allowlist's single home; `src/types.ts` derives its type face:
|
||||
|
||||
```ts
|
||||
// remote-events.ts — the value
|
||||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
'agent-preset/selected',
|
||||
'commands/change',
|
||||
'credentials/reference-updated',
|
||||
'llm/adapters-updated',
|
||||
'settings/document-updated',
|
||||
] as const
|
||||
{ event: 'agent-preset/selected', mode: 'emit' },
|
||||
{ event: 'approval/request', mode: 'waterfall' },
|
||||
...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })),
|
||||
{ event: 'commands/change', mode: 'emit' },
|
||||
{ event: 'credentials/reference-updated', mode: 'emit' },
|
||||
{ event: 'cordis/request-run', mode: 'emit' },
|
||||
{ event: 'cordis/request-run-resolved', mode: 'emit' },
|
||||
{ event: 'cordis/dynamic-package', mode: 'emit' },
|
||||
{ event: 'cordis/dynamic-retract', mode: 'emit' },
|
||||
{ event: 'cordis/inspect-query', mode: 'emit' },
|
||||
{ event: 'cordis/inspect-query-resolved', mode: 'emit' },
|
||||
{ event: 'llm/adapters-updated', mode: 'emit' },
|
||||
{ event: 'settings/document-updated', mode: 'emit' },
|
||||
{ event: 'user-questions/request', mode: 'waterfall' },
|
||||
] as const satisfies readonly TypertForwardableEventEntry[]
|
||||
|
||||
// types.ts — the type face, derived
|
||||
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]
|
||||
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event']
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
|
||||
}
|
||||
```
|
||||
|
||||
Forwarding one more event is therefore **one line in that array**: the type projection, `$on`'s key surface, and the Host forwarding loop all derive from it. `ctx.remote.$on('slots/changed', …)` (a Client-local event) and `$on('skills/change', …)` (declared but unselected) are both **compile errors**.
|
||||
Adding an event is therefore one array entry: type projection, the `$on` key set, Host dispatch mode, and the forwarding loop all derive from it. `ctx.remote.$on('slots/changed', …)` for a Client-local event and `$on('skills/change', …)` for a declared but unselected event are compile errors.
|
||||
|
||||
The Host face adds one shape assertion, binding the Host event vocabulary to that same array:
|
||||
The declaration's trailing `satisfies` applies Host event-vocabulary and mode constraints to the same allowlist:
|
||||
|
||||
```ts ignore-check
|
||||
API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[]
|
||||
API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEventEntry[]
|
||||
```
|
||||
|
||||
It is an expression statement rather than a named constant, which `noUnusedLocals` would reject (the underscore prefix exempts parameters only). It enforces three things: the **name is real** (the predicate is keyed on `keyof Events`), the event **binds no Scope** (`goal/changed` and kin have a `ThisParameterType` other than `unknown` and drop out — the static expression of "no AgentScope dependency"), and the event is **one-way** (a non-`void` return, i.e. a waterfall/bail shape, drops out).
|
||||
It enforces three properties: the name exists because the predicate is keyed by `keyof Events`; the selected mode matches the signature; and the signature is either an unscoped `void` notification or a waterfall with top-level Agent scope, a same-result `next()`, and a Promise return. Other Scope, bail, parallel, and serial forms are excluded.
|
||||
|
||||
**"Verbatim" is proved nowhere because it holds by construction**: `$on`'s listener type comes from the one cordis `Events` declaration in the owner package's `./types`, and Host forwarding reads that same declaration. There is no second declaration that could drift.
|
||||
Verbatim correspondence is not proved separately because it holds by construction. `$on`'s listener type and Host forwarding both read the owner package's one Cordis `Events` declaration, so no second declaration can drift.
|
||||
|
||||
JSON-safety is a runtime concern: before forwarding, apiproxy validates each argument with `dsh-session`'s `isJsonValue` and **throws loudly** when one fails, because that is an allowlist composition mistake rather than untrusted input.
|
||||
JSON safety remains a runtime concern. Before queueing, the API Remotes Host source checks every argument with `dsh-session`'s `isJsonValue` and fails loudly when one is invalid, because this is an allowlist composition error rather than untrusted input.
|
||||
|
||||
### Wire contract (apiproxy)
|
||||
### Wire protocol (API Gateway Remote mux)
|
||||
|
||||
```ts ignore-check
|
||||
| { type: 'host/remote-event'; event: string; args: JsonValue[] }
|
||||
ready { type, clientId }
|
||||
emit { type, event, args }
|
||||
waterfall { type, event, eventId, agentId, request }
|
||||
cancel { type, eventId }
|
||||
```
|
||||
|
||||
The zod branch keeps `args: z.array(z.unknown())`: the frame arrives from `JSON.parse`, so every element is already a JSON value, and the structural contract belongs to the owner package's `Events` declaration — the same posture the existing `session/projection` frame takes with its `value`.
|
||||
The Client opens internal logical stream `$events` with payload `{ args: {} }`. Gateway rejects extra parameters, a missing Host source, and duplicate source registration. Withdrawing a source aborts every stream opened by that registration. Each Client stream owns an independent queue and allowlist listener set in `api/remotes`, so disconnecting one Client neither consumes nor withdraws another Client's events.
|
||||
|
||||
`events.host()` subscribes by allowlist when the stream opens. Each stream owns its disposers, so no broadcast set or derived invalidation listener is needed.
|
||||
The Client requires an opening `ready` item with a non-empty `clientId`; every later item is checked for exact fields by discriminant. An ordinary `emit` with an unknown but structurally valid event name is dropped when there is no subscriber. Waterfalls use `eventId` to correlate `$events/result` and `agentId` to select a Client Agent Context. The Client returns only values representable as lossless JSON; transport does not reinterpret business fields.
|
||||
|
||||
`api/events.ts` is a wire contract file the browser side also compiles, so every type it references must come from an owner package's **client-safe, type-only subpath**, never the package root. Evidence: importing one type from `@deepseek-ai/dsh-session` root drags the root's `declare module 'cordis' { interface Context { sessions: SessionStore } }` into the Client compilation face and overrides the Client's `ctx.sessions: ISessions`, producing 18 errors in the unrelated `ui-input-trigger` and `ui-conversation`. `JsonValue` therefore needs a re-export from `dsh-session/src/types.ts`.
|
||||
`$events` is an internal Gateway endpoint. It does not enter a generated Typert Remote descriptor or become `ctx.remote.<namespace>`. Application selection exists only in the API Remotes allowlist and Host source; Gateway owns registration, payload validation, and physical transport only.
|
||||
|
||||
### The apps/web browser e2e belong to the Host face
|
||||
### The `apps/web` browser e2e belongs to the Host face
|
||||
|
||||
The `apps/web/tests/**` e2e type-check in the root **`tsconfig.host.json`**: they boot a real harness in-process and read `ctx.apiProxy`, the Host `SessionStore`'s `get`/`create`/`flush`, and `ctx.sessionProjectionCache`. **Driving a browser at runtime does not make a file part of the Client program** — moving them into the Client aggregate immediately produces 21 errors, because one program cannot hold both faces' merges for the same Context key.
|
||||
The `apps/web/tests/**` e2e files typecheck in root `tsconfig.host.json`: they boot a real harness in process and directly access `ctx.apiProxy`, Host `SessionStore.get/create/flush`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not place a file in the Client TypeScript program. Moving these tests to the Client aggregate produces 21 errors because one program cannot hold both faces' merges for the same Context key.
|
||||
|
||||
That yields a discipline this design depends on: **when those tests import a value or a type from a Client package, they pull that package's whole project — and every project it references — into the Host build graph**. Four consumers (`ui-settings-general`, `ui-settings-models`, `ui-permission`, `ui-commands`) reference `api/remotes`' Client face, and that face cannot compile until Host tsdown has generated `@deepseek-ai/dsh-goal/remote`. The result is a build-order deadlock: Host tsc needs the Client face, which needs the generated artifact, which Host tsdown produces after Host tsc.
|
||||
This implies one build rule needed by the design: importing a value or type from a Client package in those tests brings that package's whole project and all its project references into the Host build graph. Four consumers (`ui-settings-general`, `ui-settings-models`, `ui-permission`, and `ui-commands`) reference API Remotes' Client face, which cannot compile until Host tsdown generates `@deepseek-ai/dsh-goal/remote`. That forms a build-order cycle: Host tsc needs API Remotes Client, which needs generated `goal/remote`, which Host tsdown emits after Host tsc.
|
||||
|
||||
The few Client-owned symbols are therefore **mirrored** on the test side (`scaffold.ts` exports the mirrored welcome-notice constants; the two chat e2e keep importing `dsh-client-runtime/client` because the `runtime` project is already in the Host graph), which lets those four consumers leave the Host graph. The 15 Client project references in `apps/cli/tsconfig.json` lost their owner-map role and are gone. Each mirrored value matches its source verbatim; a drift shows up as a missed selector or an unsuppressed notice, both loud failures.
|
||||
The few required Client symbols are mirrored on the test side: `scaffold.ts` exports the mirrored welcome-notice constants, while the two chat e2e files import `dsh-client-runtime/client` directly because the Runtime project already belongs to the Host graph. This removes those four consumers from the Host graph, and the 15 Client project references in `apps/cli/tsconfig.json` no longer serve an owner-map role. Each mirror is byte-identical to its source; drift produces a selector mismatch or an unsuppressed notice and fails loudly.
|
||||
|
||||
### Change inventory
|
||||
|
||||
| Location | Change |
|
||||
|---|---|
|
||||
| `dsh-typert-protocol` | `src/types.ts` gains `TypertForwardableEvent`, `TypertRemoteEventSelection`, and `TypertRemoteEvent`; `TypertClientRemote` gains `$on` and `$dispatch`. Types only, no runtime |
|
||||
| `api/gateway` Client half | `ClientRemoteService` implements `$on` (subscriptions addressed by registration, `ctx.effect` ownership for the calling fiber) and `$dispatch` (snapshot delivery in registration order, containing a listener that throws or rejects) |
|
||||
| `api/remotes` | New `src/remote-events.ts` (the allowlist value) and `src/types.ts` (type projection, selection seat), both listed in both faces' `files`; a `./types` export with `lib/types/**/*.js` added to `files`; the Host face adds the shape assertion and `import type {}` for the five owner `./types`; the Client half re-exports those five plus `@deepseek-ai/dsh-api-gateway/client` |
|
||||
| Root `tsconfig.base.json` | Client-safe `paths` entries for settings, credentials, llm, agent-presets, and api-remotes types point at the **source** plane |
|
||||
| `dsh-commands` / `dsh-settings` / `dsh-credentials` / `dsh-llm` / `dsh-agent-presets` | Each forwarded `interface Events` member lives in the owner's client-safe `./types`; agent-presets moves its previous domain vocabulary to `preset.ts` so the exported file itself remains `types.ts` |
|
||||
| `host/apiproxy` | `HostFrame` gains `host/remote-event` and loses the five dedicated passthrough or invalidation variants with their zod branches; `events.host()` subscribes by allowlist and validates through `assertJsonArgs` |
|
||||
| `dsh-session` | `src/types.ts` re-exports `JsonValue` so wire contract files can use the client-safe subpath |
|
||||
| `client/runtime` | The five Client-event bridge branches collapse into `ctx.remote.$dispatch(frame.event, frame.args)`, adding a `remote` injection and deleting their duplicated `Events` declarations |
|
||||
| Seven consumers | ui-commands / ui-model-selection / ui-settings-models / ui-settings-general / ui-permission / ui-agent-preset / ui-skill subscribe through `ctx.remote.$on(...)`, following `ui-goal`'s precedent for the type-only facade import and the `'remote'` injection |
|
||||
| `client/connection` | The fixture's `emitHost` produces `host/remote-event` |
|
||||
| `apps/web/tests` + `apps/cli` | Client symbols mirrored on the test side (see above); `apps/cli/tsconfig.json` drops its 15 Client project references |
|
||||
| `dsh-typert-protocol` | `src/types.ts` provides forwardable-mode derivation, selection, and Client-listener projection; `TypertClientRemote` exposes only `$on`. Types only, no runtime |
|
||||
| `api/gateway` | Host provides one Remote event source, `$events`, pending-waterfall coordination, and `$events/result`; Client registers the private pump as the Connection generation source and owns frame validation and Cordis dispatch |
|
||||
| `api/remotes` | `src/remote-events.ts` (mode-bearing allowlist value) and `src/types.ts` (key projection and selection) belong to both faces; Host registers each Client source and validates JSON before queueing; Client continues to compose generated Remote contributions |
|
||||
| Root `tsconfig.base.json` | Adds source-plane `paths` entries for `dsh-settings/types`, `dsh-credentials/types`, and `dsh-api-remotes/types` |
|
||||
| `dsh-commands` / `dsh-settings` / `dsh-credentials` | Moves each `interface Events` member to the owner's Client-safe `./types`; settings and credentials add that export, move brands and pure types with it, retain constructors in index, and include `lib/types/**/*.js` in published files |
|
||||
| `host/apiproxy` | Contains no `HostFrame`, `events.host()`, or other Host downlink carrier; API Proxy does not participate in Host events or Connection generation |
|
||||
| `dsh-session` | Exposes `isJsonValue` for validation of every event argument by the API Remotes Host source |
|
||||
| `client/runtime` | Removes the bridge from Host frames to the Remote subscription table; it only publishes `connection/reset` after a Connection generation is established |
|
||||
| Consumers | Client plugins subscribe directly through `ctx.remote.$on(...)`, import owner event declarations type-only, and inject `'remote'` |
|
||||
| `client/connection` | Provides the one generation-source registration point; `ConnectionController` combines `$events` ready with `host.describe`, and the fixture emits events from the same source |
|
||||
| `apps/web/tests` + `apps/cli` | Mirrors Client symbols on the test side as described above and removes 15 Client project references from `apps/cli/tsconfig.json` |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Open a general downlink channel for Remote events** (the push counterpart of `ctx.connection.rpc`, a third WebSocket). This best matches "Connection owns the carrier, the Gateway never touches transport", but it means a new stream in the Host downlink, `WebApiClient`, `ConnectionController`, the fixture, and the web e2e — a cost out of proportion to this change. Reusing the host stream costs a temporary tenancy inside a legacy frame union; when that stream moves, the wrapper moves with it and the consumer contract does not change.
|
||||
**Continue using API Proxy's Host downlink.** This reuses Connection generation and `connection/reset` but leaves the Remote event allowlist, queue, schema, and Client Runtime bridge in API Proxy and prevents domain transports from sharing the lifecycle of other Remote streams. With API Gateway's resident `/api/remote.mux`, `$events` adds only one internal logical stream and belongs naturally in Gateway.
|
||||
|
||||
**Declare a separate `TypertRemoteEventMap` in type-meta and let owner packages merge into it.** The consumer key set would equal exactly "events declared remotely deliverable", but every signature would be written a second time outside cordis `Events`, requiring a bidirectional `extends` proof to stop the two from drifting, plus a new type-meta dependency for three owner packages. Sharing the one `Events` declaration makes that equivalence structural, so the table is not created.
|
||||
**Open a third physical WebSocket or duplex stream for Remote events.** An independent channel could own connection state but would duplicate authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect backoff already provided by Gateway mux. Internal `$events` retains an independent logical stream, while waterfall results reuse HTTP unary calls.
|
||||
|
||||
**Have the typert generator project Host `Events` declarations** (codec, `.d.ts`, declaration map, like `/remote`). The generator already analyzes Host events, but it cannot see projection or redaction intent, and it would change the generator and the build surface. Verbatim forwarding needs no projection.
|
||||
**Declare a separate `TypertRemoteEventMap` in type metadata and let owner packages declaration-merge into it.** The consumer key set would exactly equal remotely deliverable events, but every signature would be written again outside Cordis `Events`, requiring a bidirectional equivalence proof and new type-metadata dependencies for owner packages. Sharing one `Events` declaration makes equivalence structural, so the second map is not created.
|
||||
|
||||
**Give forwardable events a payload projection function** (a `{ name, project, zod }` forwarding table). This could fold the two model-directory inputs into one derived invalidation and also cover workspace view derivation, at the cost of hand-aligning projection logic with payload types — the central table the method side just removed.
|
||||
**Have the Typert generator project Host `Events` declarations.** The generator already analyzes Host events, but it cannot infer projection or redaction intent and would expand the generator and build surface. Verbatim forwarding needs no projection.
|
||||
|
||||
**Move the apps/web browser e2e into the Client aggregate.** "Client tests belong to the Client face" looks right and fails immediately with 21 errors: those tests use Host services, and in the Client program `ctx.sessions` is `ISessions`.
|
||||
**Give forwardable events a payload projection function.** A `{ event, project, zod }` table could combine model-directory inputs and derive Workspace views, but would manually align projection logic with payload types and recreate the central table removed from Remote methods.
|
||||
|
||||
**Split `directory-picker-browse`/`-native` into Host and Client faces** so no Client package reaches the Host graph. The direction is right — they are genuinely unsplit dual-half packages — but the change lands in another owner's packages and buys only a cleaner build graph; once this design mirrors the Client symbols on the test side, it no longer needs the split. **Assessed and declined.**
|
||||
**Move the `apps/web` browser e2e into the Client aggregate.** The intuition that browser tests belong to the Client face fails with 21 errors because the tests use Host services while the Client program's `ctx.sessions` is `ISessions`.
|
||||
|
||||
**Split `directory-picker-browse`/`-native` into Host and Client faces.** This would remove Client packages from the Host graph, but changes another owner's packages for only a cleaner build graph. Mirroring the required Client symbols on the test side removes the need for that split.
|
||||
|
||||
## Verification
|
||||
|
||||
What pins this behavior:
|
||||
|
||||
- A real composition test puts one `host/remote-event` frame on the real host stream per Host emit, with `event` the Host name and `args` equal element for element.
|
||||
- Type-level negatives reject three candidate classes: a name that is not an event, a Scope-bound event (`goal/changed`), and an event whose return is not `void`. `$on('slots/changed', …)` (Client-local) and `$on('skills/change', …)` (declared but unselected) both fail to compile, so `$on`'s key surface equals the allowlist.
|
||||
- On the consumer side, `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace`: the brand survives the wire.
|
||||
- `$on`'s disposer belongs to the calling fiber, and two registrations of one function object retire independently — a table keyed on listener identity would collapse them, so subscriptions are addressed by registration.
|
||||
- Delivery contains a listener that throws AND one that rejects a returned promise: the declared return is `void`, so nobody awaits an async listener, and its rejection would otherwise escape this containment entirely. Delivery iterates a snapshot, so subscribing or disposing mid-frame cannot change who receives that frame.
|
||||
- `assertJsonArgs` is unit-tested directly rather than by driving a malformed emit through the event bus: a typed `ctx.emit` cannot construct one, since every allowlisted event has a statically JSON-safe payload.
|
||||
- The five dedicated `HostFrame` variants, five Client-side aliases, and their bridge branches are absent. The model directories observe both owner inputs, while command, skill, and session-row consumers observe the preset owner's committed-selection event.
|
||||
- A real Host-source composition test proves that two Client streams each receive `{ event, args }`, disconnecting one does not affect the other, and non-JSON arguments fail loudly without poisoning later valid delivery.
|
||||
- Type negatives reject unselected events, non-`void` unscoped events, non-Agent-scoped waterfalls, and allowlist modes that disagree with signatures. `$on('slots/changed', …)` and `$on('skills/change', …)` both fail to compile, so `$on`'s key set equals the allowlist.
|
||||
- Consumer `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace`, preserving the brand across the wire.
|
||||
- A `$on` disposer belongs to the calling fiber, and registering the same function object twice produces independently removable registrations; subscriptions are addressed by registration rather than listener identity.
|
||||
- Ordinary notifications contain both a throwing listener and a listener returning a rejected Promise. Waterfall tests pin Client result, `next()`, rejection, cancellation, first claim across multiple Clients, and reconnect replay of a pending request.
|
||||
- Gateway tests cover missing, duplicate, and withdrawn sources; payload rejection; ready-before-event ordering; and browser and in-process carriers. Client tests cover generation-source registration, description/increment readiness order, reopen after physical failure, Host errors and unexpected completion, non-ready opening items, malformed event items, `$events/result` failure, and disposal quiescence.
|
||||
- `host/remote-event`, public `$dispatch`, the Client Runtime bridge, and API Proxy's allowlist dependency are absent; consumers observe owner events directly.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Tenancy inside a legacy frame union.** The contract lives in apiproxy's `HostFrame`, so a reader may assume apiproxy owns Remote events. The frame's JSDoc names `api-remotes` as the allowlist owner, and apiproxy's README records the tenancy under known limitations. When the host stream moves off that package, the wrapper moves with it and the consumer contract does not change.
|
||||
- **Two files break api/remotes' face-disjointness contract.** `src/remote-events.ts` and `src/types.ts` belong to both projects, so each emits an identical declaration into the shared `lib/types`. Content is byte-identical and the `.tsbuildinfo` files stay separate, so this is harmless in practice; the README's build-boundary section states the exception and its cause (the `paths` entry points at source).
|
||||
- **The carrier handoff is developer-visible.** Any Client plugin holding `ctx.remote` can call `$dispatch` and synthesize a forwarded event. That exposure predates the verb — `ctx.emit` was equally reachable while an internal event relayed the frame — and matches what `connection/reset` already allows for a fabricated reconnect; the Client is one trust domain. Tests pin the handoff-to-`$on` conversion and do not pretend the port authenticates its caller.
|
||||
- **A malformed argument fails in the emitter's containment, not at load.** `assertJsonArgs` throws inside the forwarding listener, so the emitting seam's listener containment logs it and drops that frame: loud in the Host log rather than at load or at the emit point.
|
||||
- **Mirrored test values can drift.** Nothing mechanically checks the Client constants mirrored in `apps/web/tests` against their source; the safety net is only that a drift misses a selector. The rule lives in `apps/web/tests/README.md` and is held by review — a grep-level gate was considered and deliberately skipped.
|
||||
- **Capabilities given up.** No projected or redacted payloads, no Scope-bound events (`agentCtx.remote.$on`), and no replay on reconnect — these are pure invalidation signals, and `connection/reset` already covers refetching after a reconnect. The mux stream's session events, answerable frames, and snapshot baselines stay out of scope.
|
||||
- **Client packages remain in the Host graph.** Twelve projects (`connection`, `runtime`, `ui-slots`, and kin) still reach it through the unsplit `directory-picker-browse`/`-native` pair and `api/gateway → client/connection`. They compile and no longer implicate api/remotes' Client face, so they did not block this change; splitting those packages would remove a few but was assessed and declined. The two chat e2e importing `dsh-client-runtime/client` rely on `runtime` already being in that graph — incidental, not a guarantee.
|
||||
- **The invariant companion holds no runtime check.** An earlier revision asserted the dispatch shape (`thisArg === null`, `mode === 'emit'`) over the live event bus, which coupled the companion to the allowlist value and made rolldown hoist it into a third bundle chunk the mechanical publication list does not carry. The Host face's `TypertForwardableEvent` assertion already refuses both deviations at compile time, so the companion is an explained empty installer.
|
||||
- **Gateway has one non-generated endpoint.** `$events` has no business namespace and does not enter the Typert descriptor. It is the internal connection point between Gateway and API Remotes and defines the Client Connection generation lifetime. Strict empty-payload validation, opening-ready validation, and single-source registration prevent it from becoming another handwritten business API.
|
||||
- **Two files break API Remotes' face-disjointness rule.** `src/remote-events.ts` and `src/types.ts` belong to both projects and emit identical declarations into shared `lib/types`. Their content is byte-identical and `.tsbuildinfo` files remain separate, so this is safe in practice; the README records why source-plane `paths` require the exception.
|
||||
- **Producer operations remain private.** Business plugins can call only `$on`. Host-source registration and Client dispatch are absent from `TypertClientRemote`; test doubles drive subscriptions through their own `emit` operations rather than impersonating a production API.
|
||||
- **Malformed arguments fail at emit.** An API Remotes listener throws before queueing, so Host `ctx.emit` immediately observes an allowlist composition error and the queue can still deliver subsequent valid events.
|
||||
- **Test-side mirrors can drift.** No mechanism compares mirrored Client constants under `apps/web/tests` with their source. Drift instead produces a selector mismatch. `apps/web/tests/README.md` records the review rule; a grep-level gate is deliberately omitted.
|
||||
- **Capabilities deliberately omitted.** Payload projection and redaction are unsupported, scopes other than Agent are unsupported, and ordinary notifications are not replayed. Recoverable state needs a query, cursor, or opening baseline; a waterfall is replayed only while its original Host invocation remains pending.
|
||||
- **Some Client packages remain in the Host graph.** Twelve projects, including `connection`, `runtime`, and `ui-slots`, remain reachable through unsplit `directory-picker-browse`/`-native` and `api/gateway → client/connection`. They compile and no longer pull in API Remotes' Client face, so this change does not split them. Direct `dsh-client-runtime/client` imports in two chat e2e files rely on Runtime's current presence in that graph rather than a general guarantee.
|
||||
- **The invariant companion intentionally has no runtime check.** A prior revision asserted delivery form on the live event bus, coupling the companion to the allowlist and causing Rolldown to emit a third bundle chunk omitted by the mechanically derived publication list. The Host-face `TypertForwardableEventEntry` assertion already rejects those mismatches at compile time, so the companion is an explained empty installer.
|
||||
|
||||
@@ -6,39 +6,51 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
[Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。
|
||||
[Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)最初只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的事件需要一个不归 API Proxy 领域所有的投递机制。
|
||||
|
||||
Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference-updated`、`llm/adapters-updated`、`settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。
|
||||
Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference-updated`、`llm/adapters-updated`、`settings/document-updated` 等单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。若每条事件都要穿过 API Proxy 手写帧、Client Runtime 手写桥和 Client 事件别名才能抵达 UI,这些层不会陈述 owner 事件之外的新事实。
|
||||
|
||||
那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`,brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。
|
||||
|
||||
## 决策
|
||||
|
||||
消费端 Remote 面持有一个单向事件订阅动词 `ctx.remote.$on(event, listener)`;**名单驱动、原样转发**:
|
||||
消费端 Remote 面持有一个事件订阅动词 `ctx.remote.$on(event, listener)`;**名单驱动、原样转发**:
|
||||
|
||||
- `packages/api/remotes/src/remote-events.ts` 持有一份可转发 host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。旁边的 `src/types.ts` 由它派生类型投影并填充 selection 座位,按包约定保持纯类型。两个文件**都同时列进本包 host 与 client 两个 face 的 `files`**,两侧读同一份。
|
||||
- `packages/api/remotes/src/remote-events.ts` 持有一份带 `emit`/`waterfall` mode 的可转发 Host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。旁边的 `src/types.ts` 由它派生类型投影并填充 selection 座位,按包约定保持纯类型。两个文件**都同时列进本包 Host 与 Client 两个 face 的 `files`**,两侧读同一份。
|
||||
- wire 上的事件名 **就是 host cordis 事件原名**(`settings/document-updated`),不加 `host/` 前缀;载荷 **就是 host 的实参列表**,逐元素原样过 JSON,无投影、无脱敏、无改名。
|
||||
- 载体**寄生现有 host 流**:`HostFrame` 加一个包裹帧 `host/remote-event`,不新开下行通道。
|
||||
- 事件**签名**不另立表:owner 包把自己的 cordis `Events` 声明搬进 client-safe 的 `./types` 纯类型出口,两侧读**同一份**——`$on` 的 listener 类型就是 `Events[Event]` 本身。「原样」不需要证明,是构造性成立的。
|
||||
- Host source 由 `api/remotes` 注册到 API Gateway;Gateway 在既有 `/api/remote.mux` 上保留内部 logical endpoint `$events`,不增加物理连接,也不让 API Proxy 解释事件。waterfall 结果通过 HTTP 一元 endpoint `$events/result` 返回。
|
||||
- 事件**签名**不另立表:owner 包把自己的 cordis `Events` 声明搬进 client-safe 的 `./types` 纯类型出口,两侧读**同一份**——`$on` 的 listener 参数、结果和 `next()` 都由 `Events[Event]` 推导。「原样」不需要证明,是构造性成立的。
|
||||
- 但**只借 cordis 的类型形状,不接 cordis 的事件系统**:投递语义、注册表、异常处置全归 Typert 自己。
|
||||
|
||||
一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。这五个包都不需要拆:它们的条目只够到纯类型。agent-presets 把原词汇模块改名为 `preset.ts`,让导出的 `types.ts` 专门承载 client-safe 事件声明。
|
||||
一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。当前名单内各 owner 都从 client-safe 类型出口提供同一份事件声明。
|
||||
|
||||
五条事件全部走这条路径,专用帧与 Client 别名都已删除。模型消费方直接订阅 `llm/adapters-updated` 和 `settings/document-updated`;preset 消费方订阅 `agent-preset/selected`。真正需要投影或去重的数据仍保留专用帧。
|
||||
名单内事件全部走这条路径,专用帧与 Client 别名都已删除。模型消费方直接订阅 `llm/adapters-updated` 和 `settings/document-updated`;preset 消费方订阅 `agent-preset/selected`;Session 与动态 Cordis 的无状态通知使用 `emit`;Approval 与 Question 使用 Agent-scoped `waterfall`。真正需要 baseline、投影或去重的数据仍保留专用 Remote stream。
|
||||
|
||||
`skills/change`、`tools/change`、`system-prompt/change` 是同形状的纯失效事件但**没有任何已交付消费者**,按「每个抽象都要有当前 owner 与需求」不进名单,只作为扩展位记录在此。
|
||||
|
||||
### 消费端契约(dsh-typert-protocol)
|
||||
|
||||
type-meta 加一个**形状谓词**、一个**选择座位**和 `TypertClientRemote` 的**一个**成员;零运行时代码:
|
||||
type-meta 加事件形状谓词、mode 条目、选择座位和 `TypertClientRemote` 的一个成员;零运行时代码:
|
||||
|
||||
```ts
|
||||
```ts ignore-check
|
||||
import type { Events } from '@deepseek-ai/cordis'
|
||||
|
||||
/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */
|
||||
type TypertForwardingMode<Event extends keyof Events> =
|
||||
unknown extends ThisParameterType<Events[Event]>
|
||||
? TypertEventResult<Event> extends void ? 'emit' : never
|
||||
: TypertWaterfallEvent<Event> extends never ? never : 'waterfall'
|
||||
|
||||
/** Cordis event names that can cross the Remote Event carrier without a second signature. */
|
||||
export type TypertForwardableEvent = {
|
||||
[Event in keyof Events]: unknown extends ThisParameterType<Events[Event]>
|
||||
? ReturnType<Events[Event]> extends void ? Event : never
|
||||
[Event in keyof Events]: TypertForwardingMode<Event> extends never ? never : Event
|
||||
}[keyof Events]
|
||||
|
||||
/** Event and dispatch mode accepted by the Remote Event source. */
|
||||
export type TypertForwardableEventEntry = {
|
||||
[Event in keyof Events]: TypertForwardingMode<Event> extends infer Mode
|
||||
? Mode extends 'emit' | 'waterfall'
|
||||
? { readonly event: Event; readonly mode: Mode }
|
||||
: never
|
||||
: never
|
||||
}[keyof Events]
|
||||
|
||||
@@ -51,69 +63,78 @@ export type TypertRemoteEvent = Extract<keyof Events, keyof TypertRemoteEventSel
|
||||
|
||||
```ts ignore-check
|
||||
/** Subscribe to one forwarded Host event; the returned disposer belongs to the calling fiber. */
|
||||
$on<Event extends TypertRemoteEvent>(event: Event, listener: Events[Event]): () => void
|
||||
$on<Event extends TypertRemoteEvent>(event: Event, listener: TypertClientEventListener<Event>): () => void
|
||||
```
|
||||
|
||||
`Events` 按程序解析:host 程序里是 host 事件全集,client 程序里是 client 编译面看得见的那些——同一个谓词在两侧各自成立,不需要把 host 声明拖进 client。
|
||||
|
||||
**契约把消费动词与载体交接分开**:消费方用 `$on` 订阅,持有 host 帧 sink 的一方用 `$dispatch` 把解码后的帧交进来。它**不能**是一个跨插件的模块级函数:client bundle 纯度门禁(`packages/client/tsdown.client.ts`)只放行隐式的 `PLATFORM_MODULES` 加 `PRELOADED_CLIENT_EXTERNALS` 基座、包自身的 `dsh.client.external` 请求、`INLINE_SAFE` wire 层与 `/remote` 生成物值导入。靠 inline 绕过会把 `ClientRemoteService` 复制一份进 runtime bundle、令 `instanceof` 恒假。cordis 服务方法正是该门禁指定的协作形态:
|
||||
**契约只公开消费动词。**`ClientRemoteService` 激活时就把内部唯一的 `$events` pump 注册为 Connection generation source,与当前有无 `$on` 订阅无关;浏览器通过共享 Remote mux 打开 `$events`,进程内组合通过 `connection.rpc.open` 打开同一 logical stream。解码、精确 item 校验和订阅表派发都是 Gateway Client 的私有实现,`TypertClientRemote` 不暴露生产方方法,因此业务插件不能伪造一条 Host 事件。
|
||||
|
||||
```ts ignore-check
|
||||
$dispatch(event: string, args: readonly unknown[]): void
|
||||
```
|
||||
每次 Host 打开 `$events` 时,API Remotes source factory 先同步挂载所有 allowlist listener,Gateway 随后产出首项 `{ type: 'ready' }`,再开始迭代事件 source。`ConnectionController` 并行等待该 ready 与 `host.describe`,只有两者都成功才发布 `connected` 并允许 baseline 读取。这个顺序保证 baseline 不会跑在增量 listener 前面。
|
||||
|
||||
持有 host 帧 sink 的 client/runtime 直接调用它,帧不经中转事件即到达订阅表。`event` 形参是 `string` 而非 `TypertRemoteEvent`:这是 wire 边界,收到无人订阅的名字即静默丢弃。
|
||||
物理 mux 断开会让 logical stream 以 `RemoteStreamCarrierError` 结束;Host 返回的 Remote stream error、意外正常结束、非 ready 首项或畸形事件项也会结束当前 generation。Connection 撤回该 generation 的 `hostDescription`,在退避后重开 `$events` 和 `host.describe`;Gateway mux 只负责重建物理 WebSocket。转发事件不重放;凡正确性依赖恢复的状态,owner 必须另有查询、cursor 或 opening baseline,不能把 `$on` 当作可靠日志。
|
||||
|
||||
投递语义与 cordis 事件系统不共用实现:只有单向投递,没有 waterfall / bail / parallel / serial 模式,也没有 `@mode` 概念(`ReturnType extends void` 是这条纪律的静态表达);不绑 `this`;没有 `EventOptions`、`prepend`、优先级;按注册顺序逐个调用,单个 listener 抛错就地隔离并记日志——它绝不能拖垮帧泵(沿用 `ConnectionController` 对 sink 异常的既有处置)。
|
||||
Client 以 Remote 实例私有 Cordis key 分发。普通 `emit` 使用 `parallel()` 并隔离 listener 失败;Agent-scoped `waterfall` 在解析出的 Agent Context 上使用 `waterfall()`,允许结果、拒绝或 `next()` 委托。两类注册都归属调用方 fiber,且 Host 事件不会触发 Client 本地同名事件。
|
||||
|
||||
### 名单:两个 face 共读的同一份声明
|
||||
|
||||
`packages/api/remotes/src/remote-events.ts` 同时列进 `tsconfig.host.json` 与 `tsconfig.client.json` 的 `files`,是名单的**唯一家**;`src/types.ts` 由它派生类型面:
|
||||
|
||||
```ts
|
||||
```ts ignore-check
|
||||
// remote-events.ts — the value
|
||||
export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
'agent-preset/selected',
|
||||
'commands/change',
|
||||
'credentials/reference-updated',
|
||||
'llm/adapters-updated',
|
||||
'settings/document-updated',
|
||||
] as const
|
||||
{ event: 'agent-preset/selected', mode: 'emit' },
|
||||
{ event: 'approval/request', mode: 'waterfall' },
|
||||
...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })),
|
||||
{ event: 'commands/change', mode: 'emit' },
|
||||
{ event: 'credentials/reference-updated', mode: 'emit' },
|
||||
{ event: 'cordis/request-run', mode: 'emit' },
|
||||
{ event: 'cordis/request-run-resolved', mode: 'emit' },
|
||||
{ event: 'cordis/dynamic-package', mode: 'emit' },
|
||||
{ event: 'cordis/dynamic-retract', mode: 'emit' },
|
||||
{ event: 'cordis/inspect-query', mode: 'emit' },
|
||||
{ event: 'cordis/inspect-query-resolved', mode: 'emit' },
|
||||
{ event: 'llm/adapters-updated', mode: 'emit' },
|
||||
{ event: 'settings/document-updated', mode: 'emit' },
|
||||
{ event: 'user-questions/request', mode: 'waterfall' },
|
||||
] as const satisfies readonly TypertForwardableEventEntry[]
|
||||
|
||||
// types.ts — the type face, derived
|
||||
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]
|
||||
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event']
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface TypertRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
|
||||
}
|
||||
```
|
||||
|
||||
于是**加一个事件只改这一行数组**:类型投影、`$on` 的键面、host 的转发循环全部从它派生。`ctx.remote.$on('slots/changed', …)`(client 本地事件)或 `$on('skills/change', …)`(名单没开)都是**编译错误**。
|
||||
于是**加一个事件只改这一行数组**:类型投影、`$on` 的键面、Host dispatch mode 与转发循环全部从它派生。`ctx.remote.$on('slots/changed', …)`(Client 本地事件)或 `$on('skills/change', …)`(名单没开)都是**编译错误**。
|
||||
|
||||
host 半再加一处形状断言,把 host 事件词汇的约束落到同一份名单上:
|
||||
数组声明末尾的 `satisfies` 把 Host 事件词汇与 mode 约束落到同一份名单上:
|
||||
|
||||
```ts ignore-check
|
||||
API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[]
|
||||
API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEventEntry[]
|
||||
```
|
||||
|
||||
写成表达式语句而不是命名常量:后者会被 `noUnusedLocals` 判为未使用(下划线前缀只豁免参数)。它卡住三件事:**名字合法**(谓词以 `keyof Events` 为基)、**不绑 Scope**(`goal/changed` 那族的 `ThisParameterType` 不是 `unknown`,被排除——「不依赖 AgentScope」的静态表达)、**单向**(非 `void` 返回的 waterfall/bail 形状被排除)。
|
||||
它卡住三件事:**名字合法**(谓词以 `keyof Events` 为基)、**mode 匹配签名**,以及只接受无 scope 的 `void` 通知或带一级 Agent scope、同结果 `next()` 和 Promise 返回的 waterfall。其他 Scope、bail、parallel 与 serial 形状都被排除。
|
||||
|
||||
**「原样」不在任何地方证明,而是构造性成立**:`$on` 的 listener 类型取自 owner 包 `./types` 里那一份 cordis `Events` 声明,host 转发读的是同一份,不存在可以彼此偏离的第二份声明。
|
||||
|
||||
载荷 JSON-safe 交给运行时:apiproxy 转发前用 `dsh-session` 的 `isJsonValue` 逐元素校验,不合格**抛错 fail loud**(这是名单配置错误,不是外部输入)。
|
||||
载荷 JSON-safe 交给运行时:`api/remotes` 的 Host source 在入队前用 `dsh-session` 的 `isJsonValue` 逐元素校验,不合格**抛错 fail loud**(这是名单配置错误,不是外部输入)。
|
||||
|
||||
### 线协议(apiproxy)
|
||||
### 线协议(API Gateway Remote mux)
|
||||
|
||||
```ts ignore-check
|
||||
| { type: 'host/remote-event'; event: string; args: JsonValue[] }
|
||||
ready { type, clientId }
|
||||
emit { type, event, args }
|
||||
waterfall { type, event, eventId, agentId, request }
|
||||
cancel { type, eventId }
|
||||
```
|
||||
|
||||
zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必然已是 JSON 值,结构契约由 owner 包的 `Events` 声明承担——与既有 `session/projection` 帧的 `value` 同 posture。
|
||||
Client 以 endpoint `$events` 和 payload `{ args: {} }` 打开 internal logical stream。Gateway 拒绝额外参数、缺失 Host source 和重复 source 注册;source 被撤回时会中止所有由该注册打开的 stream。每个 Client stream 在 `api/remotes` 中拥有独立队列与一组 allowlist listener,因此一个 Client 断开不会消费或撤销另一个 Client 的事件。
|
||||
|
||||
`events.host()` 打开时按名单挂监听;每条流自持 disposers,无需新增广播集合或派生失效 listener。
|
||||
Client 要求首项是带非空 `clientId` 的 `ready`;后续 item 按 discriminant 精确校验字段。普通 `emit` 的未知但结构合法事件名在没有订阅者时静默丢弃。waterfall 通过 `eventId` 关联 `$events/result`,并由 `agentId` 选择 Client Agent Context;Client 只回传可无损表示为 JSON 的结果,不在 transport 层重复解释业务字段。
|
||||
|
||||
|
||||
`api/events.ts` 是浏览器侧也要编译的 wire 契约文件,所以它引用的每个类型都必须走 owner 包的 **client-safe type-only 子路径**,绝不能走包根出口。实证:从 `@deepseek-ai/dsh-session` 根引一个类型,就把根出口的 `declare module 'cordis' { interface Context { sessions: SessionStore } }` 拖进 client 编译面、把 client 的 `ctx.sessions: ISessions` 顶掉,在完全无关的 `ui-input-trigger` / `ui-conversation` 里炸出 18 条错。`JsonValue` 因此需要 `dsh-session/src/types.ts` 补一条 re-export。
|
||||
`$events` 是 Gateway 内部 endpoint,不进入生成的 Typert Remote descriptor,也不成为 `ctx.remote.<namespace>`。应用选择仍只存在于 `api/remotes` 的 allowlist 和 Host source;Gateway 只拥有注册、payload 校验与物理传输。
|
||||
|
||||
### apps/web 的 browser e2e 属于 Host 面
|
||||
|
||||
@@ -127,21 +148,23 @@ zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必
|
||||
|
||||
| 位置 | 改动 |
|
||||
|---|---|
|
||||
| `dsh-typert-protocol` | `src/types.ts` 加 `TypertForwardableEvent`、`TypertRemoteEventSelection`、`TypertRemoteEvent`;`TypertClientRemote` 增 `$on` 与 `$dispatch`。纯类型,零运行时 |
|
||||
| `api/gateway` client 半 | `ClientRemoteService` 实现 `$on`(订阅按注册项寻址、`ctx.effect` 归属调用方 fiber)与 `$dispatch`(快照后按注册顺序派发,收容抛出或拒绝的 listener) |
|
||||
| `api/remotes` | 新增 `src/remote-events.ts`(名单值)与 `src/types.ts`(类型投影 + 选择座位),两者都双列进两个 face 的 `files`;`./types` 出口 + `files` 补 `lib/types/**/*.js`;host 半加形状断言并 `import type {}` 三个 owner 包的 `./types`;client 半 `export type {}` 那三个 `./types` 与 `@deepseek-ai/dsh-api-gateway/client` |
|
||||
| `dsh-typert-protocol` | `src/types.ts` 提供 forwardable mode 推导、selection 与 Client listener 投影;`TypertClientRemote` 只公开 `$on`。纯类型,零运行时 |
|
||||
| `api/gateway` | Host 半提供唯一 Remote event source、`$events` stream、pending waterfall 协调和 `$events/result`;Client 半把私有 pump 注册为 Connection generation source,负责 frame 校验和 Cordis 分发 |
|
||||
| `api/remotes` | `src/remote-events.ts`(带 mode 的名单值)与 `src/types.ts`(键投影 + selection)双列进两个 face;Host 半注册每 Client source,并在入队前校验 JSON;Client 半继续组合生成的 Remote contribution |
|
||||
| 根 `tsconfig.base.json` | 加 `dsh-settings/types`、`dsh-credentials/types`、`dsh-api-remotes/types` 三条 `paths`,全部指向**源**平面 |
|
||||
| `dsh-commands` / `dsh-settings` / `dsh-credentials` | `interface Events` 子块移入各自 client-safe 的 `./types`(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;`files` 补 `lib/types/**/*.js`) |
|
||||
| `host/apiproxy` | `HostFrame` 增 `host/remote-event`、删除五个专用变体及其 zod;`events.host()` 按名单挂监听并通过 `assertJsonArgs` 校验 |
|
||||
| `dsh-session` | `src/types.ts` 补 `export type { JsonValue }`,让 wire 契约文件能走 client-safe 子路径 |
|
||||
| `client/runtime` | 五条 Client 事件桥分支收敛为 `ctx.remote.$dispatch(frame.event, frame.args)`,并删除重复声明 |
|
||||
| 5 个消费者 | ui-commands / ui-settings-models / ui-settings-general / ui-permission / ui-agent-preset 改订 `ctx.remote.$on(...)`;照 `ui-goal` 先例 type-only 引 `@deepseek-ai/dsh-api-remotes/client` 并把 `'remote'` 加进 `inject` |
|
||||
| `client/connection` | fixture 的 `emitHost` 造 `host/remote-event` |
|
||||
| `host/apiproxy` | 不包含 `HostFrame`、`events.host()` 或其他 Host 下行 carrier;API Proxy 不参与 Host 事件或 Connection generation |
|
||||
| `dsh-session` | `isJsonValue` 供 `api/remotes` Host source 校验每个事件参数 |
|
||||
| `client/runtime` | 删除 Host frame 到 Remote subscription table 的桥;只继续在 Connection generation 建立后发布 `connection/reset` |
|
||||
| 消费方 | Client 插件直接订阅 `ctx.remote.$on(...)`,type-only 引入 owner 事件声明并把 `'remote'` 加进 `inject` |
|
||||
| `client/connection` | 提供唯一 generation source 注册位;`ConnectionController` 以 `$events` ready 与 `host.describe` 组成世代握手,fixture 也从同一 source 产生事件 |
|
||||
| `apps/web/tests` + `apps/cli` | 客户端符号镜像(见上节);`apps/cli/tsconfig.json` 删 15 条 client 工程引用 |
|
||||
|
||||
## 备选方案
|
||||
|
||||
**给 Remote 事件新开一条通用下行通道**(`ctx.connection.rpc` 的推送对偶,第三条 WebSocket)。最符合「Connection 独占载体、Gateway 不碰传输」;但要同时改 host 下行、`WebApiClient`、`ConnectionController`、fixture 与 web e2e 各一条流,代价与本次收益不匹配。寄生 host 流的代价是新契约暂时寄居在 legacy 帧联合里——host 流将来整体搬家时它随之搬走,消费端契约不变。
|
||||
**继续寄生 API Proxy 的 Host downlink。**这样可以复用 Connection generation 和 `connection/reset`,但会让 API Proxy 保留 Remote 事件 allowlist、队列、schema 和 Client Runtime bridge,领域传输也无法随其他 Remote stream 共用生命周期。API Gateway 已有常驻 `/api/remote.mux` 后,`$events` 只增加一个 internal logical stream,不需要第三条 WebSocket,因此转移到 Gateway 的成本和所有权都更合理。
|
||||
|
||||
**给 Remote 事件另开第三条物理 WebSocket 或 duplex stream。**独立通道能拥有自己的连接状态,但会重复 Gateway mux 已经提供的认证升级、复用、取消、错误映射和退避重连。内部 `$events` endpoint 保留独立 logical stream,waterfall 结果复用 HTTP 一元调用。
|
||||
|
||||
**在 type-meta 立一张独立的 `TypertRemoteEventMap`,让 owner 包 declare-merge 进去**。消费端键集会精确等于「被声明为可远程投递的事件」;代价是每条事件的签名要在 cordis `Events` 之外**再写一遍**,于是需要一条双向 `extends` 的等价性证明来防漂移,还要给三个 owner 包新增 type-meta 依赖。共用同一份 `Events` 声明让等价性变成构造性成立,这张表因此不立。
|
||||
|
||||
@@ -157,21 +180,21 @@ zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必
|
||||
|
||||
钉住该行为的东西:
|
||||
|
||||
- 一个真组合测试:host 每 emit 一次,真实 host 流就出一帧 `host/remote-event`,`event` 为 host 原名、`args` 与实参逐元素相等。
|
||||
- 类型层负例拒绝三类候选:不是事件的名字、绑 Scope 的事件(`goal/changed`)、返回值非 `void` 的事件。`$on('slots/changed', …)`(client 本地事件)与 `$on('skills/change', …)`(已声明但未选中)都编译失败——因此 `$on` 的键面恰好等于名单。
|
||||
- Host source 真组合测试:两个 Client stream 各自收到 host emit 的 `{ event, args }`,其中一个断开不会影响另一个;非 JSON 实参会响亮拒绝且不会毒化后续合法事件。
|
||||
- 类型层负例拒绝未选择事件、非 `void` 的无 scope 事件、非 Agent-scoped waterfall,以及声明 mode 与签名不符的条目。`$on('slots/changed', …)`(Client 本地事件)与 `$on('skills/change', …)`(已声明但未选中)都编译失败——因此 `$on` 的键面恰好等于名单。
|
||||
- 消费端 `$on('settings/document-updated', …)` 把 `ns` 解析为 `SettingsNamespace`:brand 穿过 wire 存活。
|
||||
- `$on` 的 disposer 归属调用方 fiber;同一个函数对象订阅两次时两条注册各自独立退订——按 listener 身份做键的表会把它们合并,所以订阅按注册项寻址。
|
||||
- 投递同时收容抛出的 listener 与拒绝所返回 promise 的 listener:声明返回值是 `void`,没人 await 异步 listener,其拒绝否则会完全逃出这层收容。投递遍历快照,因此派发中订阅或退订都不会改变本帧的接收者集合。
|
||||
- `assertJsonArgs` 直接单测,而不是从事件总线造畸形 emit:类型化的 `ctx.emit` 造不出来——名单内每条事件的载荷在静态上都是 JSON-safe 的。
|
||||
- 五个专用帧、五条 Client 别名及其桥分支都不存在;各消费方直接观察 owner 事件。
|
||||
- 普通通知同时收容抛出的 listener 与拒绝所返回 Promise 的 listener;waterfall 测试固定 Client result、`next()`、拒绝、取消、多 Client 首个 claim 和重连重放 pending request。
|
||||
- Gateway 测试覆盖 source 缺失、重复注册、撤销中止、payload 拒绝、ready 先于事件,以及浏览器与进程内两种 carrier;Client 测试覆盖 generation source 注册边界、描述与增量就绪顺序、物理失败后重开、Host 错误与意外结束、非 ready 首项、畸形事件项、`$events/result` 失败和 dispose quiescence。
|
||||
- `host/remote-event`、公开 `$dispatch`、Client Runtime bridge 和 API Proxy 的 allowlist 依赖都不存在;各消费方直接观察 owner 事件。
|
||||
|
||||
## 后果
|
||||
|
||||
- **寄居在 legacy 帧联合里**:契约住在 apiproxy 的 `HostFrame` 中,读者可能误以为 apiproxy 拥有 Remote 事件。该帧的 JSDoc 点名名单归 `api-remotes`,apiproxy README 在 known limitations 记录这项寄居。host 流将来整体搬家时,包裹帧随之搬走,消费端契约不变。
|
||||
- **Gateway 有一个非生成 endpoint**:`$events` 不对应业务 namespace,也不进入 Typert descriptor;它是 Gateway 与 `api/remotes` 之间的内部连接点,同时定义 Client Connection generation 的存活期。严格的空 payload 校验、opening ready 校验和单 source 注册限制它不会演化成第二个手写业务 API。
|
||||
- **两个文件打破了 api/remotes 的 face 互斥约定**:`src/remote-events.ts` 与 `src/types.ts` 同属两个工程,各自向共享的 `lib/types` 发射一份相同声明。内容逐字节相同、`.tsbuildinfo` 各自独立,实践上无害;README 的构建边界节陈述了这个例外及其成因(`paths` 指向源码面)。
|
||||
- **载体交接是开发者可见的**:任何持有 `ctx.remote` 的 client 插件都能调 `$dispatch` 合成一条转发事件。这个暴露面早于该动词存在——先前由内部事件中转帧时,`ctx.emit` 同样可达——与 `connection/reset` 可被伪造成重连同一量级(client 是单一信任域)。测试只钉「交接到 `$on` 的转换」,不假装该端口鉴别调用方。
|
||||
- **畸形实参在发射方的收容里失败,而非加载期**:`assertJsonArgs` 在转发监听内抛出,因此由发射 seam 自己的 listener 收容记录并丢弃该帧——响亮地出现在 host 日志里,而不是加载时或 emit 点。
|
||||
- **生产方保持私有**:业务插件只能调用 `$on`;Host source 注册和 Client 派发都不在 `TypertClientRemote` 上暴露,测试 double 以自己的 `emit` 方法驱动订阅,不伪装成生产接口。
|
||||
- **畸形实参在 emit 点失败**:`api/remotes` listener 在入队前抛出,因此调用 Host `ctx.emit` 的操作立即看到名单配置错误;队列仍可继续投递后续合法事件。
|
||||
- **测试侧镜像值可能漂移**:没有任何机制核对 `apps/web/tests` 中镜像的 client 常量与其源;安全网只是漂移会让选择器失配。规则写在 `apps/web/tests/README.md`,由 review 守;grep 级门禁经评估后刻意不做。
|
||||
- **放弃的能力**:不支持投影或脱敏载荷、不支持 Scope 化事件(`agentCtx.remote.$on`)、重连不重放——这些都是纯失效信号,且 `connection/reset` 已覆盖重连后的重新拉取。mux 流的会话事件、可应答帧与快照基线不在范围内。
|
||||
- **放弃的能力**:不支持投影或脱敏载荷,不支持 Agent 以外的 Scope,也不为普通通知提供重放。需要可靠恢复的状态必须拥有查询、cursor 或 opening baseline;waterfall 只重放仍处于同一次 Host 调用生命周期内的 pending request。
|
||||
- **仍有 client 包留在 host 图里**:12 个工程(`connection`、`runtime`、`ui-slots` 等)经未拆分的 `directory-picker-browse`/`-native` 与 `api/gateway → client/connection` 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 `dsh-client-runtime/client` 依赖 `runtime` 本来就在图里——属偶然而非保证。
|
||||
- **invariant companion 不做运行期检查**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让 companion 与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。host 面的 `TypertForwardableEvent` 断言在编译期已拒绝这两种偏离,因此该 companion 是一个带说明的空 installer。
|
||||
|
||||
@@ -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-11-pwsh-persistent-pty.md
|
||||
2026-08-11-pwsh-persistent-pty.md: 8353b3ab3cdbf20add22a55acb03312c94283602
|
||||
2026-08-11-pwsh-persistent-pty.zh.md: 95048a02416dfcf5f0ef2837d99a561008f6496f
|
||||
2026-08-11-pwsh-persistent-pty.md: 4c523d3c7c45e6d86942868df92b981576e76859
|
||||
2026-08-11-pwsh-persistent-pty.zh.md: 4f87490fd60ecc37ad9390e0ce990173bbafc3b8
|
||||
|
||||
@@ -24,7 +24,7 @@ A model-facing persistent `pwsh` tool ships on Windows with the same contract as
|
||||
|
||||
### 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).
|
||||
One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`; the bash argv and environment defaults remain unchanged). 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 accepts only the backend's `stdin_read` result; a printable prompt literal in echoed setup input is not readiness. One `timeoutMs` deadline owns the complete startup retry loop, so `inferred_idle` follow-up sends cannot restart the bound. A zero-scrollback `@xterm/headless` instance consumes raw PTY data and emits terminal-protocol replies through `SubprocessTerminalHandle`; the backend drains those writes before caller input and accepts foreground state only when protocol work stayed quiet throughout inspection, so a caller's input cannot be consumed as a cursor-position response. One parser write stays active while later raw chunks coalesce into the next batch, preventing high-volume output from creating one scheduled parse per chunk. The existing sanitizer and bounded buffers remain the output projection. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic stay shared — the marker remains a readiness signal with an unconsumed payload, and the deferred BEL event channel stays deferred.
|
||||
|
||||
### `@deepseek-ai/dsh-tool-pwsh-persistent`
|
||||
|
||||
@@ -38,7 +38,7 @@ The minimal preset gates its persistent shell stack by platform with the #2234 `
|
||||
|
||||
### 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.
|
||||
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 session suite pins split cursor-position queries, response-write ordering, and parse batching without a real shell; real-pwsh suites on macOS and Windows prove persistent cwd/env, secret scrubbing, UTF-8 output, multiline and here-string commands, large-output clipping, and exit/reset. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -46,6 +46,7 @@ The Windows test surface follows master's exemption structure: terminal-bash and
|
||||
- **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.
|
||||
- **Hand-written cursor-position replies.** Rejected: the response must reflect cursor movement, wrapping, and control sequences already emitted by the shell. Fixed coordinates amplify console redraws and can exhaust bounded output; `@xterm/headless` maintains that protocol state without replacing the line-oriented output projection.
|
||||
- **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.
|
||||
@@ -62,4 +63,6 @@ The Windows test surface follows master's exemption structure: terminal-bash and
|
||||
|
||||
**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.
|
||||
**Terminal protocol replies precede caller input.** The headless emulator retains no scrollback and contributes no model-visible text; it tracks terminal control state and emits replies through the mounted subprocess provider. This adds the maintained `@xterm/headless` runtime dependency and prevents a cursor query from consuming a later tool command.
|
||||
|
||||
**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; if marker readiness remains unavailable, startup rejects at `timeoutMs` instead of publishing a shell whose setup did not complete. A later model redefinition of the `prompt` function degrades command readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi and `@xterm/headless` add process-substrate and terminal-backend dependency review respectively.
|
||||
|
||||
@@ -24,7 +24,7 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS
|
||||
|
||||
### `@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 事件通道保持延后)。
|
||||
一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`;bash 的 argv 和环境默认值保持不变)。有效 `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 函数,并且只接受 backend 的 `stdin_read` 结果;回显引导输入中的可打印提示符字面量不代表就绪。一条 `timeoutMs` 绝对超时计时器负责限制完整启动重试循环,因此 `inferred_idle` 后续 send 无法重新计时。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 数据,并通过 `SubprocessTerminalHandle` 发出终端协议响应;backend 会在调用方输入前排空这些写入,并且只接受协议工作在整次检查期间保持静止时的前台状态,因此调用方输入不会被当作光标位置响应而消费。一个 parser 写入保持活跃,随后到达的原始 chunk 会合并为下一批,从而避免高输出量为每个 chunk 分别调度解析。现有 sanitizer 与有界缓冲区仍负责输出投影。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 `PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑保持共享——标记仍是载荷不被消费的就绪信号,延后的 BEL 事件通道也继续保持延后。
|
||||
|
||||
### `@deepseek-ai/dsh-tool-pwsh-persistent`
|
||||
|
||||
@@ -38,7 +38,7 @@ minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell
|
||||
|
||||
### 测试
|
||||
|
||||
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 与结果。
|
||||
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 模式矩阵并加回显剥离模式。session 套件无需真实 shell 即可固定拆分的光标位置查询、响应写入顺序与解析批处理;macOS 和 Windows 上的真实 pwsh 套件证明持久 cwd/env、密钥清洗、UTF-8 输出、多行与 here-string 命令、大输出裁剪及退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。
|
||||
|
||||
## 备选方案
|
||||
|
||||
@@ -46,6 +46,7 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo
|
||||
- **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。
|
||||
- **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。
|
||||
- **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。
|
||||
- **手写光标位置响应。** 拒绝:响应必须反映 shell 已经发出的光标移动、换行折叠和控制序列。固定坐标会放大控制台重绘并可能耗尽有界输出;`@xterm/headless` 会维护这份协议状态,但不取代逐行输出投影。
|
||||
- **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。
|
||||
- **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。
|
||||
- **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。
|
||||
@@ -62,4 +63,6 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo
|
||||
|
||||
**输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。
|
||||
|
||||
**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。
|
||||
**终端协议响应先于调用方输入。** headless 模拟器不保留 scrollback,也不贡献模型可见文本;它跟踪终端控制状态,并通过已挂载的进程管理提供方发出响应。这会增加受维护的 `@xterm/headless` 运行时依赖,并避免光标查询消费后续工具命令。
|
||||
|
||||
**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;若 marker 就绪持续不可用,启动会在 `timeoutMs` 到期时拒绝,而不会发布引导未完成的 shell。模型后来重定义 `prompt` 函数会使命令就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 与 `@xterm/headless` 分别增加进程基座和终端后端的依赖评审。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-repository-naming-contract-and-rename-ledger.md
|
||||
2026-08-11-repository-naming-contract-and-rename-ledger.md: bf69f40884d8fee69cede839cd9ceb7c0d926c38
|
||||
2026-08-11-repository-naming-contract-and-rename-ledger.zh.md: 373bafaaa5590ccac7d33ba9ac27ce29f6dbefe5
|
||||
2026-08-11-repository-naming-contract-and-rename-ledger.md: 2de88df5f1a5037d32daa9a8ee9cd1edfc60528a
|
||||
2026-08-11-repository-naming-contract-and-rename-ledger.zh.md: 0cbbbee561358614da0b20e49c610fbee1d4e537
|
||||
|
||||
+6
-2
@@ -274,10 +274,14 @@ Keep MCP, Todo, and the Plan Mode package, key, events, and tool names. This dec
|
||||
| `E2BSandboxService` | `E2BRuntime` | The class creates, reuses, and disposes the E2B execution environment used by filesystem and subprocess adapters. It is broader than one sandbox handle and narrower than a generic owner. Keep `@deepseek-ai/dsh-e2b`, `ctx.e2b`, and the `e2b/` group. |
|
||||
| `@deepseek-ai/dsh-frontend-static` | `@deepseek-ai/dsh-host-frontend-static` | The package is the Host plugin that serves the frontend assets. The prefix distinguishes it from frontend application code. |
|
||||
| `PluginInventoryService` | `PluginInventoryGateway` | The class is a Remote-only adapter from the live Loader tree to the `pluginInventory/list` RPC. It owns no same-process service, cache, history, or mutation path. `Gateway` states the role that exists. |
|
||||
| `@deepseek-ai/dsh-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-jsonrpc-demo` | The example demonstrates the runtime SDK over JSON-RPC. It belongs to the one SDK meaning. |
|
||||
| `@deepseek-ai/dsh-jsonrpc-demo`, `@deepseek-ai/dsh-sdk-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-python-runtime` | The private package carries only the temporarily separate Python SDK runtime; the [single dsh launcher decision](2026-08-22-single-dsh-application-launcher.md) owns its application-boundary change. |
|
||||
| `packages/examples/jsonrpc-demo/` | `packages/sdk/python-runtime/` | The carrier is production packaging infrastructure for the Python SDK, not a demo bundle. |
|
||||
| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | The direct-config runnable example belongs specifically to the Python SDK exception. |
|
||||
| `@deepseek-ai/dsh-acp-demo` | `@deepseek-ai/dsh-acp-app` | The package is the ACP profile's application bundle, not a standalone demo bin. |
|
||||
| Deploy-root manifest `dsh-jsonrpc-agent-pkg` | `dsh-sdk-python-runtime-closure` | The manifest defines the private Python runtime dependency closure. The Python-visible executable basename remains fixed until its documented profile migration. |
|
||||
| `@deepseek-ai/dsh-frontend` | `@deepseek-ai/dsh-web-frontend` | The application is the web frontend. Keep its physical `apps/web/` folder. |
|
||||
|
||||
Keep atomic-write, brand, native-command, timeout utility, directory-picker, `dsh-base`, `dsh-web-app`, app boot, CLI names, and the `headless` package, bundle, and example identity. `headless` is the intended product essence and may later support more than one-shot execution.
|
||||
Keep atomic-write, brand, native-command, timeout utility, directory-picker, `dsh-base`, `dsh-web-app`, `dsh-sdk-app`, `dsh-acp-app`, app boot, CLI names, and the `headless` package, bundle, and example identity. `headless` is the intended product essence and may later support more than one-shot execution.
|
||||
|
||||
### Client runtime and UI
|
||||
|
||||
|
||||
+6
-2
@@ -274,10 +274,14 @@ PascalCase 标识符中的首字母缩略词使用首字母大写格式:`Ui`
|
||||
| `E2BSandboxService` | `E2BRuntime` | 该类创建、复用和释放文件系统与子进程适配器所使用的 E2B 执行环境。它比单个沙箱句柄的职责更广,又比通用所有者更具体。保留 `@deepseek-ai/dsh-e2b`、`ctx.e2b` 和 `e2b/` 组。 |
|
||||
| `@deepseek-ai/dsh-frontend-static` | `@deepseek-ai/dsh-host-frontend-static` | 该包是提供前端资源的 Host 插件。此前缀可将它与前端应用代码区分开。 |
|
||||
| `PluginInventoryService` | `PluginInventoryGateway` | 该类只负责把实时 Loader 树适配到 `pluginInventory/list` RPC。它不拥有同进程服务、缓存、历史或修改路径。`Gateway` 准确说明现有角色。 |
|
||||
| `@deepseek-ai/dsh-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-jsonrpc-demo` | 该示例演示通过 JSON-RPC 使用运行时 SDK,属于 SDK 的唯一含义。 |
|
||||
| `@deepseek-ai/dsh-jsonrpc-demo`、`@deepseek-ai/dsh-sdk-jsonrpc-demo` | `@deepseek-ai/dsh-sdk-python-runtime` | 该私有包只承载暂时独立的 Python SDK 运行时;其应用边界变更由[单一 dsh 启动器决策](2026-08-22-single-dsh-application-launcher.zh.md)负责。 |
|
||||
| `packages/examples/jsonrpc-demo/` | `packages/sdk/python-runtime/` | 该载体是 Python SDK 的生产打包基础设施,不是演示组合包。 |
|
||||
| `examples/jsonrpc-agent/` | `examples/python-sdk-agent/` | 直读配置的可运行示例专属于 Python SDK 例外。 |
|
||||
| `@deepseek-ai/dsh-acp-demo` | `@deepseek-ai/dsh-acp-app` | 该包是 ACP profile 的应用组合包,不是独立 demo bin。 |
|
||||
| 部署根 manifest `dsh-jsonrpc-agent-pkg` | `dsh-sdk-python-runtime-closure` | 该 manifest 定义私有 Python 运行时依赖闭包。面向 Python 的可执行文件基本名称保持不变,直至完成已记录的 profile 迁移。 |
|
||||
| `@deepseek-ai/dsh-frontend` | `@deepseek-ai/dsh-web-frontend` | 该应用是 Web 前端。保留其物理目录 `apps/web/`。 |
|
||||
|
||||
保留 atomic-write、brand、native-command、timeout 实用工具、目录选择器、`dsh-base`、`dsh-web-app`、应用启动、CLI(命令行界面)名称,以及 `headless` 包、组合包和示例身份。`headless` 是预期的产品本质,未来也可以支持不止一次性执行。
|
||||
保留 atomic-write、brand、native-command、timeout 实用工具、目录选择器、`dsh-base`、`dsh-web-app`、`dsh-sdk-app`、`dsh-acp-app`、应用启动、CLI(命令行界面)名称,以及 `headless` 包、组合包和示例身份。`headless` 是预期的产品本质,未来也可以支持不止一次性执行。
|
||||
|
||||
### 客户端运行时与 UI
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
|
||||
2026-08-18-session-history-and-event-transport.md: 206d3d13d1f183b97b02b89644b022367be2ebfd
|
||||
2026-08-18-session-history-and-event-transport.zh.md: 3d0b5d4ab768ecd3877bfde86822245d0b63ac49
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
# Agent Note: Session history, control state, and Remote event transport
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-18-session-history-and-event-transport.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The browser consumes three kinds of data with different lifecycles: persistable, paginated Session logs; process-local state that needs an opening baseline to converge after reconnect; and immediate notifications that need no replay.
|
||||
|
||||
These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; queue, jobs, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation.
|
||||
|
||||
Observing Session history, lists, and projections must allow cold reads. If transport performs a general Typert lookup whenever an argument contains a Session or Agent, opening a page, switching tabs, or reconnecting the network implicitly resumes an Agent, so observation gains execution side effects.
|
||||
|
||||
Commands such as prompt, create, fork, and model selection do need to create or resume an Agent according to their own semantics. Activation authority must belong to each Remote method, not be decided implicitly by the carrier, parameter types, or a shared lookup.
|
||||
|
||||
The legacy API Proxy all-Session mux, `HostFrame`, and Workspace notifications encode domain data, baselines, errors, and connection lifecycle in one handwritten protocol. Each additional state duplicates frame declarations, a Client bridge, reconnect handling, and cleanup logic, while API Proxy cannot return to owning only business methods that have not yet migrated.
|
||||
|
||||
Host-to-Client Cordis events also have two invocation modes. Ordinary notifications only need broadcast delivery; Agent-scoped waterfalls such as Approval and Question must let a Client claim, delegate through `next()`, return a result, or reject while preserving one Host invocation identity across multiple Clients, disconnects, and cancellation.
|
||||
|
||||
These requirements need one general transport lifecycle without making Gateway understand Session, Workspace, Approval, or Question business data.
|
||||
|
||||
## Decision
|
||||
|
||||
API Gateway owns Remote transport, stream lifecycles, and Remote Event coordination. Session Controller and Workspace Controller own their Host APIs, wire types, and Client domain adapters. Client Runtime only composes and consumes these objects; it does not implement another carrier state machine.
|
||||
|
||||
Current ownership is:
|
||||
|
||||
```text
|
||||
[client/connection]
|
||||
|-- Host description
|
||||
|-- Connection generation
|
||||
`-- unary RPC transport
|
||||
|
||||
[api/gateway/client]
|
||||
|-- RemoteStream
|
||||
|-- RemoteSnapshotStream
|
||||
|-- RemoteJournalStream
|
||||
`-- ctx.remote.$on + $events pump
|
||||
|
||||
[api/session-controller]
|
||||
|-- ctx.remote.session unary commands
|
||||
|-- session.control snapshot stream
|
||||
|-- session.page + session.follow journal
|
||||
`-- Session Client adapters
|
||||
|
||||
[api/workspace-controller]
|
||||
|-- ctx.remote.workspace unary commands
|
||||
|-- workspace.follow snapshot stream
|
||||
`-- Workspace Client model and adapter
|
||||
|
||||
[api/remotes]
|
||||
`-- application Remote Event allowlist and Host Cordis source
|
||||
|
||||
[client/runtime]
|
||||
`-- compose Session and Workspace domain state for consumers
|
||||
```
|
||||
|
||||
API Proxy owns neither the Session or Workspace Remote namespace nor the Host downlink event carrier. `/api/events.host`, `HostFrame`, `stream/error`, `ServerRequest`, and their WebSocket/SSE branches do not participate in this data path.
|
||||
|
||||
### Connection generation and physical connections
|
||||
|
||||
The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream.
|
||||
|
||||
After an initial connection failure or the loss of a connected socket, the mux rebuilds the physical connection with capped jittered backoff. Logical streams not yet opened share that reconnect loop; streams already open end their current physical generation with `RemoteStreamCarrierError`.
|
||||
|
||||
In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux.
|
||||
|
||||
The Gateway-internal `$events` logical stream is the sole generation source for `ConnectionHandle`. It does not depend on whether any business `$on` subscription exists, so connection health does not vary with the number of UI listeners.
|
||||
|
||||
The Host event source installs incremental listeners synchronously before returning its first frame. Gateway then sends `{ type: 'ready' }` with a `clientId`; this frame proves that the current generation can receive increments.
|
||||
|
||||
`ConnectionController` waits for `$events` readiness and `host.describe` in parallel. It publishes `connected` only after both complete, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready.
|
||||
|
||||
Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws `hostDescription`, then re-establishes `$events` and `host.describe` after backoff.
|
||||
|
||||
Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state.
|
||||
|
||||
Plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers.
|
||||
|
||||
### General Remote stream model
|
||||
|
||||
Gateway Client provides three React-independent, single-consumer lifecycle objects:
|
||||
|
||||
```text
|
||||
RemoteStream<Item>
|
||||
|-- RemoteSnapshotStream<Snapshot, Delta>
|
||||
`-- RemoteJournalStream<Page, Entry, Cursor>
|
||||
```
|
||||
|
||||
Domain Controllers use them through composition or thin adapters; Session and Workspace do not inherit a common Controller base class that knows domain frames.
|
||||
|
||||
#### `RemoteStream`
|
||||
|
||||
`ctx.remote.$stream(options)` returns a `RemoteStream<Item>` responsible for reopening, cancellation, and disposal of one logical stream across physical generations.
|
||||
|
||||
Each item carries a monotonic generation, that generation's `AbortSignal`, and `accept()`. A domain consumer calls `accept()` only after validating the opening cursor or baseline.
|
||||
|
||||
Only `RemoteStreamCarrierError` permits retry. When the Host remains available, one independent reopen is allowed; otherwise the stream waits for a new Connection generation. Business errors, protocol errors, and opening failures terminate immediately.
|
||||
|
||||
`restart()` replaces only the current physical generation and preserves the logical stream. `dispose()` permanently ends the logical stream, pending retry, and iterator, then waits for quiescence.
|
||||
|
||||
`RemoteStream` does not understand baselines, deltas, pages, cursors, sequence numbers, or any domain frame.
|
||||
|
||||
#### `RemoteSnapshotStream`
|
||||
|
||||
`RemoteSnapshotStream<Snapshot, Delta>` requires each generation to start with exactly one complete snapshot, followed only by deltas.
|
||||
|
||||
An update before the snapshot or a second snapshot in the same generation is a terminal protocol error.
|
||||
|
||||
The generation is accepted only after its snapshot has been applied successfully. The previously published state remains readable while the carrier reconnects, and the new generation's snapshot replaces the old mirror atomically.
|
||||
|
||||
The domain adapter supplies frame discrimination, snapshot replacement, a delta reducer, carrier state, and a terminal failure sink. The general layer parses no Session or Workspace fields.
|
||||
|
||||
Session control and Workspace state each use an independent `RemoteSnapshotStream`.
|
||||
|
||||
#### `RemoteJournalStream`
|
||||
|
||||
`RemoteJournalStream<Page, Entry, Cursor>` combines one live follow with a page method in the same namespace. It applies to an append-only journal with a stable order, paginated history, and a live tail.
|
||||
|
||||
Initial opening establishes follow and obtains its opening cursor before reading the initial page. Live entries produced while the page request is pending already enter the follow queue, closing the race between reading history and subscribing afterward.
|
||||
|
||||
The general layer removes overlap between the page and queued entries by cursor, verifies continuity, and publishes one complete window after the page covers the opening cursor.
|
||||
|
||||
Contiguous live entries publish `append`; older history pages publish `prepend`. Reconnect, cursor jumps, or continuity that cannot be proven trigger a tail-page repair.
|
||||
|
||||
The old window remains readable during repair. The page and live entries accumulated during that read form a continuous window and publish one `replace`, never exposing a half-repaired state.
|
||||
|
||||
If a page request is canceled with its physical carrier generation, the journal waits for the next generation's opening cursor and rereads the page at that cursor. This cancellation does not leak to the domain object as a terminal page failure.
|
||||
|
||||
`RemoteJournalStream` owns the opening cursor, resume cursor, pagination, reconnect catch-up, overlap removal, and gap repair. A domain Session object does not copy these state machines.
|
||||
|
||||
### Session Controller
|
||||
|
||||
`packages/api/session-controller` provides Host `ctx.sessionController` and the generated `ctx.remote.session` namespace.
|
||||
|
||||
It owns Session list, search, create, models, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control.
|
||||
|
||||
The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner.
|
||||
|
||||
Other Host Remote namespaces reuse the same identity rules through `ctx.sessionController.inspect()` or `resolveAgent()`; they do not retain a second Session resolver.
|
||||
|
||||
#### Activation policy
|
||||
|
||||
Session Remote methods pass `SessionId` or `SessionAddress`; parameter types do not trigger a general Typert Session lookup.
|
||||
|
||||
Each method explicitly selects a cold inspection, live-only lookup, or resume-capable resolution:
|
||||
|
||||
| Operation | Source or result without a live Agent | Activation rule |
|
||||
|---|---|---|
|
||||
| `session.list`, `search` | persistence, projection cache, or cold log | Never resumes an Agent |
|
||||
| `session.page(address)` | attached Session or persistence log | Never resumes an Agent |
|
||||
| `session.follow(address)` | cold-read current cursor, then wait for future appends | Neither opening nor waiting resumes an Agent |
|
||||
| `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent |
|
||||
| `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent |
|
||||
| `session.updateQueue`, `cancel` | only the current live Agent | Does not resume vanished state |
|
||||
| `models`, `selectModel`, `rename`, `prompt` | command resolves the target Session | Resumes only when the method explicitly permits it |
|
||||
| `create` and fork target | new Session/Agent | The user command supplies creation authority |
|
||||
|
||||
Reading titles, lists, and projections does not require an Agent. An observation operation cannot inherit resume authority merely because another Remote endpoint uses Agent lookup.
|
||||
|
||||
#### Session journal
|
||||
|
||||
`session.page` returns a history window clipped on message boundaries with contiguous internal sequence numbers. Every request must carry an explicit `throughSeq`; this value comes from the corresponding `session.follow` generation's opening cursor and fixes the read at the same log cut. A tail page without `beforeSeq` must end exactly at `throughSeq`, where `-1` denotes an empty log. `beforeSeq` only selects an older page before that cut and cannot replace the synchronization cursor. `maxMessages` limits user/assistant message count without dropping chunks, tools, or state events between those messages.
|
||||
|
||||
The tail page also carries a projection baseline no later than `throughSeq`; older pages carry only historical entries. The Client merges pages and subsequent live control updates by projection watermark.
|
||||
|
||||
Ordinary Sessions and direct subagents use one `SessionAddress` protocol. A direct-subagent address carries parent Session, child Session, and mode; a cold Host read verifies durable ownership and descriptor rather than authorizing access from the child id alone.
|
||||
|
||||
`session.follow` installs `session/event` and `session/created` listeners before checking an attached Session or persistence, then reads the current cursor.
|
||||
|
||||
The first follow response is `{ type: 'opened', cursor }`. A generation with `afterSeq` first replays the missing suffix from the authoritative log, then emits commits buffered during the read in sequence order.
|
||||
|
||||
A cold Session can open history immediately and keep follow waiting. Future events appear only after another explicit command resumes the Agent.
|
||||
|
||||
Client `SessionEventStream` extends `RemoteJournalStream` and supplies only `session.follow`, `session.page`, the Session sequence algorithm, and repair requests. The general layer first obtains opening cursor `C`, then calls `session.page({ throughSeq: C })`; entries `C + 1...` received during the read remain in the follow queue, and the page must cover exactly through `C` before the layer merges and publishes a continuous sequence.
|
||||
|
||||
```text
|
||||
ctx.remote.session.follow(address, afterSeq?) --------|
|
||||
|[]> SessionEventStream
|
||||
ctx.remote.session.page(address, throughSeq, pageArgs) -| |-- replace(window)
|
||||
|-- prepend(history)
|
||||
`-- append(live entry)
|
||||
```
|
||||
|
||||
Each Client Session owns only one current `events: SessionEventStream | undefined`. The read-only `SessionEventSource` gives the materialized event window to Conversation consumers.
|
||||
|
||||
A Session's `openGeneration` only prevents an asynchronous result retired by resync, address replacement, or disposal from writing into current state. It does not participate in transport retry.
|
||||
|
||||
A terminal failure from the initial page, repair page, or follow enters the current Session's `openError`. A stale business epoch or stale stream cannot overwrite newer state.
|
||||
|
||||
#### Session live control
|
||||
|
||||
`session.control()` is a Host-wide snapshot stream. One browser can observe transient state for all current live Sessions without opening a journal for every transcript.
|
||||
|
||||
Each generation emits a complete baseline first, followed by queue, jobs, and projection deltas. The baseline reads attached Agents and process-local registries without resuming cold Agents.
|
||||
|
||||
Queue and jobs use complete replacement values and apply last-wins. Agent attach, detach, Session disposal, and owner disposal can all clear a stale mirror through an empty value or a new baseline.
|
||||
|
||||
The original `approval/request` and `user-questions/request` events are forwardable waterfalls. If an Agent-scoped Client listener claims a request, it returns directly. If all delivered Clients call `next()`, the original Cordis waterfall continues to later Host listeners. Session control neither stores nor replays these requests.
|
||||
|
||||
The projection baseline and a tail page's log cut are produced independently. The Client always retains the value with the higher sequence number. Subscribing to live projection does not start an Agent merely to obtain a value.
|
||||
|
||||
Session added, removed, activity, running status, and Agent error without a turn position do not enter the stateful control stream; they are `ctx.remote.$on` notifications that are either repairable from a list baseline or need no replay.
|
||||
|
||||
Session-list `updatedAt` is `max(header.createdAt, sessionListMetadata.lastPromptAt)`. Only a user-originated `user/message` updates `lastPromptAt`; it can be recovered from a cold projection and does not depend on whether a browser follows that Session.
|
||||
|
||||
### Workspace Controller
|
||||
|
||||
`packages/api/workspace-controller` provides Host `ctx.workspaceController` and the generated `ctx.remote.workspace` namespace.
|
||||
|
||||
It owns create, rename, delete, insertBefore, insertSessionBefore, archiveSession, and `follow`. Workspace registry remains the durable source of truth; the Controller owns Remote commands, projection, and error mapping.
|
||||
|
||||
`WorkspaceFeed` synchronously observes storage `domain/changed`, and each follow generation emits a complete baseline before `upsert`, `remove`, `order`, and `archived` deltas.
|
||||
|
||||
A complete `order` frame is authoritative for Workspace ordering. It avoids having the Client infer display order from upsert arrival order and converges after a reconnect baseline.
|
||||
|
||||
`createWorkspaceStateStream()` assembles `workspace.follow` as a `RemoteSnapshotStream`. Client Runtime only starts and owns that stream.
|
||||
|
||||
`ClientWorkspaceModel` lives on Workspace Controller's Client face. It owns baseline/increment parsing, the materialized list, the archived set, command-result echo, and merge rules for races between unary and stream arrivals.
|
||||
|
||||
A successful unary command can update the local model immediately; a later stream commit still corrects state with the Host projection and complete order. Deleted Workspace ids are recorded so a delayed result cannot reinsert them.
|
||||
|
||||
```text
|
||||
ctx.remote.workspace.follow() -|[]> RemoteSnapshotStream
|
||||
|-- replace(baseline)
|
||||
|-- upsert/remove(view)
|
||||
|-- replace(order)
|
||||
`-- replace(archived ids)
|
||||
```
|
||||
|
||||
Workspace Remote methods, state feed, and Client data model do not pass through API Proxy or depend on `host/workspace-*` notifications.
|
||||
|
||||
### Remote Event
|
||||
|
||||
Remote Event reuses owner packages' Cordis `Events` declarations. The original Host event is the sole business signature, and Client `ctx.remote.$on(event, listener)` derives its parameters, waterfall result, and `next()` from that declaration.
|
||||
|
||||
The allowlist in `packages/api/remotes` is the sole source of application selection. Each entry explicitly marks `emit` or `waterfall`; this mode determines Host listening, the legal Client key set, and the wire frame type together.
|
||||
|
||||
The system declares no `RemoteInvocationMap`, requires no second Client `@Remote`, and does not infer invocation mode by checking whether the final runtime argument is a function.
|
||||
|
||||
Remote Event downlink frames form an explicit discriminated union:
|
||||
|
||||
```text
|
||||
ready { type, clientId }
|
||||
emit { type, event, args }
|
||||
waterfall { type, event, eventId, agentId, request }
|
||||
cancel { type, eventId }
|
||||
```
|
||||
|
||||
Both WebSocket JSON and in-process carrier entry points start from `unknown` and validate the discriminant plus exact fields. Dispatch after validation accepts only the typed union. TypeScript static types do not replace wire validation.
|
||||
|
||||
Ordinary `emit` arguments must be lossless JSON. The Client calls `parallel()` on a Cordis key private to each Remote instance, preserving registration order, calling-fiber ownership, and listener-error isolation.
|
||||
|
||||
The private key prevents Host events and same-named Client-local Cordis events from triggering one another. Client Remote maintains neither its own subscription registry nor a handwritten listener chain.
|
||||
|
||||
Returning waterfalls currently support Agent scope only. The event signature must contain one request with a direct `agent` field followed by a `next()` returning the same result type, and the whole event returns a Promise.
|
||||
|
||||
The Host projects only top-level `agent` and `signal` fields from the request: `agent` becomes top-level `agentId` in the frame, `signal` becomes the delivery lifetime, and all remaining fields must be lossless JSON as a whole.
|
||||
|
||||
The Client synchronously resolves or materializes an Agent Context from `agentId`, restores the current delivery signal into the request's direct `signal` field, and invokes Cordis `waterfall()` on the target Context's private key. Before the first successful Session-list baseline, the Session-backed adapter lets transport materialize a scope; after that baseline, the list lifecycle owns scope liveness.
|
||||
|
||||
The system does not scan arbitrarily deep objects, transmit path arrays or placeholders, deep-clone/restore Context and AbortSignal, or wait for a future Agent Context.
|
||||
|
||||
When no Client adapter is registered, its resolver returns no Context, or resolution throws, that Client immediately returns `next`. It does not subscribe to a registry, recheck races after resolution, or create a temporary Fiber for one delivery.
|
||||
|
||||
Gateway Host retains `eventId`, the Host continuation, and delivered Client generations for every unfinished waterfall. A new Client generation receives a replay of the same pending event.
|
||||
|
||||
Each generation's queue guarantees one delivery, so the Client stores no `seen` set. `clientId + eventId` binds a result to the current generation; a reply from an old connection cannot complete delivery on a new one.
|
||||
|
||||
When several Clients receive a waterfall, the first result or rejection completes the Host invocation and sends `cancel` to the other Clients. Gateway continues the original Cordis chain only after every delivered Client returns `next`.
|
||||
|
||||
Host caller-signal cancellation, Agent Context disposal, Client-generation completion, and losing-Client cancellation all terminate their corresponding waits.
|
||||
|
||||
The Client returns `next`, result, or rejection through the existing HTTP unary RPC `$events/result`; downlink events continue to share the Remote WebSocket mux, with no duplex WebSocket for responses.
|
||||
|
||||
Gateway only verifies that a waterfall return value has a lossless JSON representation; it does not interpret business fields. Semantics such as whether a Question answer belongs to an offered option remain owned by the requester or UI domain and are not revalidated by transport.
|
||||
|
||||
When `UserQuestionService` observes that the caller's `AbortSignal` was canceled during a request and the provider threw an ordinary error, it normalizes that failure to `UserQuestionError` with `ASK_ABORTED` while retaining the original error as `cause`. A domain error already supplied by the provider preserves its identity.
|
||||
|
||||
A failure of `$events/result` fails the current Connection generation. Host withdraws that Client's delivery with the generation, the pending event is replayed in the next generation, and Client maintains no second result-retry queue.
|
||||
|
||||
Ordinary `$on` notifications are not replayed after disconnect. State whose correctness depends on recovery must have a query, cursor, or opening baseline and cannot rely on eventual Remote Event delivery.
|
||||
|
||||
An event is not replayed when its Client listener registers after arrival. HMR has no dedicated redelivery semantics.
|
||||
|
||||
### API Proxy's remaining boundary
|
||||
|
||||
Session Controller and Workspace Controller provide generated Remote namespaces directly; API Remotes and API Gateway provide Host-to-Client events directly.
|
||||
|
||||
Client Connection maintains only Host generation, description, and generic RPC. It does not parse domain frames.
|
||||
|
||||
Client Runtime only receives domain changes produced by Controller adapters. It recognizes no `HostFrame`, `session/subscribed`, `session/event` mux frame, or `host/workspace-*` frame.
|
||||
|
||||
API Proxy carries only independent business APIs it owns. Session, Workspace, Remote Event, and Connection generation do not depend on it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Resume an Agent whenever any Session stream opens.** Viewing history, reading a title, reconnecting a tab, or observing background state would gain execution side effects, and multiple browsers could trigger duplicate resumes. Cold logs and projections already have persistence sources.
|
||||
|
||||
**Permit `session.follow` only for live Agents.** The first transcript render would have to resume an Agent or reintroduce the race between unary history and live subscription. Following by identity before a cold read covers both history and future explicit activation.
|
||||
|
||||
**Split Session transport and Session commands into two public packages.** Both depend on Session address, Agent activation policy, subagent ownership, error mapping, and Client mount ordering. One public Controller preserves unified ownership while internal classes can evolve independently.
|
||||
|
||||
**Move queue, jobs, projection, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`.
|
||||
|
||||
**Make every domain Controller inherit a page/follow/retry base class.** Session journals and Workspace snapshots have different opening, recovery, and ordering rules. Gateway's three compositional stream objects reuse transport lifecycle while domain adapters declare only their own frame semantics.
|
||||
|
||||
**Declare a separate Client invocation map for Remote Event.** A second map or Client `@Remote` would copy owner Cordis event signatures and create a drift point. Deriving `$on` listeners and results from the same `Events` declaration preserves equivalence by construction.
|
||||
|
||||
**Project Agent scope through arbitrary object depth.** Recursive Context and AbortSignal scans need path, placeholder, clone, and restore protocols and turn incidental object structure into a wire promise. Top-level `agent` and `signal` cover current waterfalls.
|
||||
|
||||
**Wait for a Client Agent Context or adapter before dispatching.** Registry waiters, post-resolution race checks, and temporary delivery Fibers add lifecycle to a Client that can synchronously resolve or materialize its target. Returning `next` when the resolver cannot provide a target immediately preserves Cordis waterfall semantics.
|
||||
|
||||
**Use an independent physical WebSocket or duplex stream for Remote Event.** Gateway mux already provides authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect. Downlink `$events` plus HTTP `$events/result` expresses request/response without a third connection.
|
||||
|
||||
**Retain API Proxy's Host mux.** This keeps the handwritten union, schema, response envelope, and second stream lifecycle, and prevents Session and Workspace Controllers from owning their data protocols independently.
|
||||
|
||||
**Update Session list time from aggregate `session/event`.** List correctness would depend on which Sessions a browser consumes and would mistake arbitrary plugin events for user activity. The durable `lastPromptAt` projection expresses the ordering fact directly.
|
||||
|
||||
## Verification
|
||||
|
||||
Gateway mux tests pin connection without logical streams, idle residency, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal.
|
||||
|
||||
Connection tests pin missing, duplicate, and withdrawn generation sources; the race between `$events` ready and `host.describe`; and description withdrawal and rebuilding after generation failure.
|
||||
|
||||
`RemoteStream` tests pin single consumption, retry reset after opening acceptance, generation-only `restart()`, no retry for terminal errors, and disposal quiescence.
|
||||
|
||||
`RemoteSnapshotStream` tests pin exactly one opening snapshot per generation, rejection of an update before a snapshot, rejection of duplicate snapshots, and reconnect replacement.
|
||||
|
||||
`RemoteJournalStream` tests pin follow-before-page, opening-overlap removal, contiguous append, historical prepend, reconnect catch-up, gap repair, and one atomic replacement.
|
||||
|
||||
Session Host tests pin cold page/follow without increasing attached Agents, contiguous events reaching a cold follow after an explicit prompt, direct-subagent ownership, message-aligned pagination, and terminal-error projection.
|
||||
|
||||
Session control tests pin baseline-first delivery, no cold-Session resume, attach/detach cleanup, queue and jobs replacement, and the projection watermark.
|
||||
|
||||
Session Client tests pin one journal owner per Session, no writeback from stale open epochs, independent cancellation of control and journal, and retaining the published window during carrier retry.
|
||||
|
||||
Workspace Host tests pin baseline-first delivery, upsert/remove, authoritative order, archived set, and follower disposal.
|
||||
|
||||
Workspace Client tests pin snapshot replacement, unary/stream races, no resurrection after delete, stable ordering, and terminal failure.
|
||||
|
||||
Remote Event type tests reject unselected events, non-void unscoped events, non-Agent-scoped waterfalls, and modes that disagree with signatures.
|
||||
|
||||
Remote Event Host tests pin listener-before-ready, payload validation, pending replay, first result across multiple Clients, all-next delegation, rejection, Host cancellation, Context release, and losing-Client cancellation.
|
||||
|
||||
Remote Event Client tests pin instance-private keys, Cordis registration order, Agent Context resolution, `next`, result, rejection, cancellation, rejection of stale-generation replies, and Connection-generation failure when `$events/result` fails. User Question tests pin normalization of in-progress signal cancellation and preservation of its cause.
|
||||
|
||||
Missing, duplicate, and withdrawn sources; non-ready first items; unknown discriminants; extra fields; and non-JSON values all fail loudly at their respective wire entries.
|
||||
|
||||
Static checks pin that API Proxy exports no Session/Workspace Host-frame carrier and Client Runtime contains no corresponding bridge.
|
||||
|
||||
## Consequences
|
||||
|
||||
The browser can read and follow a durable Session while its Agent is stopped. Observation does not implicitly resume execution; only explicitly authorized Session commands create or resume Agents according to their own rules.
|
||||
|
||||
Durable logs repair a missing suffix by sequence number and page; Session control and Workspace state converge through opening snapshots; ordinary Remote Events promise no replay. Recovery semantics follow the data kind instead of imitating one another.
|
||||
|
||||
Gateway owns only transport, generation, pending waterfalls, and strict wire validation, not Session or Workspace business fields. A domain Controller supplies only openers, cursor rules, baseline reducers, and error presentation.
|
||||
|
||||
Session and Workspace Host APIs, stream adapters, and Client data models each have an explicit owner. API Proxy is no longer their intermediary.
|
||||
|
||||
The general stream objects add three explicit layers while deleting the retry, cancellation, generation, baseline, and gap-repair shells previously duplicated by each Controller.
|
||||
|
||||
Remote waterfalls preserve first claim across multiple Clients, continuation of the Host chain after every Client calls `next`, reconnect replay of pending calls, and end-to-end cancellation. The current protocol supports only top-level Agent scope and lossless-JSON requests and results.
|
||||
|
||||
This decision extends the allowlist and single Cordis-signature design from [Remote event delivery](2026-08-10-remote-event-delivery.md): ordinary notifications use `emit`, while Agent-scoped async waterfalls use the same `ctx.remote.$on` surface with explicit `waterfall` mode. It creates no second invocation map.
|
||||
|
||||
This decision takes over the Session, Workspace, and Host-event carriers retained by [simple unary API Proxy migration](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md) while preserving the complete jobs snapshot, process-local lifecycle, and “observation does not resume an Agent” semantics required by [background job display](../feature/2026-08-08-web-background-job-display.md).
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
# Agent Note: 会话历史、控制状态与 Remote 事件传输
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-18-session-history-and-event-transport.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
浏览器同时消费三类生命周期不同的数据:可持久化并分页的 Session 日志、需要 opening baseline 才能在重连后收敛的进程内状态,以及无需重放的即时通知。
|
||||
|
||||
这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;queue、jobs、Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。
|
||||
|
||||
观察 Session 历史、列表和投影必须允许冷读取。若 transport 因参数中出现 Session 或 Agent 就触发通用 Typert lookup,打开页面、切换标签或网络重连都会隐式恢复 Agent,观察操作因此产生执行副作用。
|
||||
|
||||
prompt、create、fork、模型选择等命令又确实需要按各自语义创建或恢复 Agent。激活权限必须属于具体 Remote 方法,而不能由 carrier、参数类型或共享 lookup 暗中决定。
|
||||
|
||||
旧 API Proxy 的全 Session mux、`HostFrame` 与 Workspace 通知把领域数据、baseline、错误和连接生命周期编码进同一手写协议。每增加一种状态都要复制帧定义、Client bridge、重连和清理逻辑,API Proxy 也无法退回只承接尚未迁移的业务方法。
|
||||
|
||||
Host 向 Client 的 Cordis 事件还有两种调用语义。普通通知只需要广播;Approval 与 Question 一类 Agent-scoped waterfall 必须允许 Client claim、调用 `next()` 委托、返回结果或拒绝,并在多 Client、断线和取消下保持一次 Host 调用的身份。
|
||||
|
||||
这些需求需要一个通用 transport 生命周期,但不能让 Gateway 理解 Session、Workspace、Approval 或 Question 的业务数据。
|
||||
|
||||
## 决定
|
||||
|
||||
API Gateway 拥有 Remote transport、stream 生命周期和 Remote Event 协调;Session Controller 与 Workspace Controller 拥有各自的 Host API、wire 类型和 Client 领域 adapter;Client Runtime 只装配并消费这些对象,不再实现另一套 carrier 状态机。
|
||||
|
||||
当前所有权如下:
|
||||
|
||||
```text
|
||||
[client/connection]
|
||||
|-- Host description
|
||||
|-- Connection generation
|
||||
`-- unary RPC transport
|
||||
|
||||
[api/gateway/client]
|
||||
|-- RemoteStream
|
||||
|-- RemoteSnapshotStream
|
||||
|-- RemoteJournalStream
|
||||
`-- ctx.remote.$on + $events pump
|
||||
|
||||
[api/session-controller]
|
||||
|-- ctx.remote.session unary commands
|
||||
|-- session.control snapshot stream
|
||||
|-- session.page + session.follow journal
|
||||
`-- Session Client adapters
|
||||
|
||||
[api/workspace-controller]
|
||||
|-- ctx.remote.workspace unary commands
|
||||
|-- workspace.follow snapshot stream
|
||||
`-- Workspace Client model and adapter
|
||||
|
||||
[api/remotes]
|
||||
`-- application Remote Event allowlist and Host Cordis source
|
||||
|
||||
[client/runtime]
|
||||
`-- compose Session and Workspace domain state for consumers
|
||||
```
|
||||
|
||||
API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host 下行事件 carrier。`/api/events.host`、`HostFrame`、`stream/error`、`ServerRequest` 及其 WebSocket/SSE 分支不参与这条数据链路。
|
||||
|
||||
### Connection generation 与物理连接
|
||||
|
||||
浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。
|
||||
|
||||
首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。
|
||||
|
||||
进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。
|
||||
|
||||
Gateway 内部 `$events` logical stream 是 `ConnectionHandle` 唯一的 generation source。它不依赖是否已有业务 `$on` 订阅,因此连接健康状态不会随 UI listener 数量变化。
|
||||
|
||||
Host event source 在返回首帧前同步安装增量 listener。Gateway 随后发送带 `clientId` 的 `{ type: 'ready' }`,该帧证明当前 generation 已经能够接收增量。
|
||||
|
||||
`ConnectionController` 并行等待 `$events` ready 与 `host.describe`。两者都完成后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。
|
||||
|
||||
`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回 `hostDescription`,退避后重新建立 `$events` 与 `host.describe`。
|
||||
|
||||
Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。
|
||||
|
||||
插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 静默退出。
|
||||
|
||||
### 通用 Remote stream 模型
|
||||
|
||||
Gateway Client 提供三个不依赖 React、只允许一个 consumer 的生命周期对象:
|
||||
|
||||
```text
|
||||
RemoteStream<Item>
|
||||
|-- RemoteSnapshotStream<Snapshot, Delta>
|
||||
`-- RemoteJournalStream<Page, Entry, Cursor>
|
||||
```
|
||||
|
||||
领域 Controller 通过组合或薄 adapter 使用它们;Session 与 Workspace 不继承一个知道领域帧的共同 Controller 基类。
|
||||
|
||||
#### `RemoteStream`
|
||||
|
||||
`ctx.remote.$stream(options)` 返回 `RemoteStream<Item>`,负责一个 logical stream 跨物理 generation 的重开、取消和 dispose。
|
||||
|
||||
每个 item 携带单调 generation、该 generation 的 `AbortSignal` 与 `accept()`。领域 consumer 只有在验证 opening cursor 或 baseline 后才调用 `accept()`。
|
||||
|
||||
只有 `RemoteStreamCarrierError` 可触发重试。Host 仍可用时允许一次独立重开;否则等待新的 Connection generation。业务错误、协议错误和 opening 失败直接终止。
|
||||
|
||||
`restart()` 只淘汰当前物理 generation,保留 logical stream;`dispose()` 永久结束 logical stream、pending retry 与 iterator,并等待 quiescence。
|
||||
|
||||
`RemoteStream` 不理解 baseline、delta、page、cursor、seq 或任何领域 frame。
|
||||
|
||||
#### `RemoteSnapshotStream`
|
||||
|
||||
`RemoteSnapshotStream<Snapshot, Delta>` 要求每个 generation 恰好以一份完整 snapshot 开始,之后只能出现 delta。
|
||||
|
||||
update 早于 snapshot 或同 generation 出现第二份 snapshot 都是 terminal protocol error。
|
||||
|
||||
snapshot 成功应用后才接受该 generation。carrier 重连期间保留上一份已发布状态,新 generation 的 snapshot 一次性替换旧镜像。
|
||||
|
||||
领域 adapter 提供 frame 判别、snapshot replacement、delta reducer、carrier 状态和 terminal failure sink;通用层不解析 Session 或 Workspace 字段。
|
||||
|
||||
Session control 与 Workspace state 各使用一个独立的 `RemoteSnapshotStream`。
|
||||
|
||||
#### `RemoteJournalStream`
|
||||
|
||||
`RemoteJournalStream<Page, Entry, Cursor>` 组合一个 live follow 与同 namespace 的 page 方法,适用于有稳定顺序、可分页历史和 live tail 的 append-only journal。
|
||||
|
||||
首次打开先建立 follow 并取得 opening cursor,再读取 initial page。page 请求期间产生的 live entries 已进入 follow 队列,因此不会落在“先读历史、后订阅”的竞态窗口中。
|
||||
|
||||
通用层按 cursor 去除 page 与 queued entries 的重叠,验证连续性,并在 page 覆盖 opening cursor 后发布一份完整 window。
|
||||
|
||||
连续 live entry 发布 `append`,更早的历史页发布 `prepend`。重连、cursor 跳跃或无法证明连续性时触发 tail page repair。
|
||||
|
||||
repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼成连续窗口后只发布一次 `replace`,不会把半修复状态暴露给消费者。
|
||||
|
||||
若 page 请求随物理 carrier generation 一起取消,journal 等待下一 generation 的 opening cursor,再以新 cursor 重读 page;该取消不会作为 terminal page failure 泄漏给领域对象。
|
||||
|
||||
`RemoteJournalStream` 拥有 opening cursor、resume cursor、分页、重连 catch-up、重叠去重和 gap repair。领域 Session 对象不复制这些状态机。
|
||||
|
||||
### Session Controller
|
||||
|
||||
`packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。
|
||||
|
||||
它拥有 Session list、search、create、models、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。
|
||||
|
||||
包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。
|
||||
|
||||
其他 Host Remote namespace 通过 `ctx.sessionController.inspect()` 或 `resolveAgent()` 复用同一身份规则,不保留第二份 Session resolver。
|
||||
|
||||
#### 激活策略
|
||||
|
||||
Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类型触发通用 Typert Session lookup。
|
||||
|
||||
每个方法显式选择冷检查、live-only 查找或允许 resume 的解析方式:
|
||||
|
||||
| 操作 | 无 live Agent 时的数据来源或结果 | 激活规则 |
|
||||
|---|---|---|
|
||||
| `session.list`、`search` | persistence、投影缓存或冷日志 | 永不恢复 Agent |
|
||||
| `session.page(address)` | attached Session 或 persistence 日志 | 永不恢复 Agent |
|
||||
| `session.follow(address)` | 冷读当前 cursor,等待将来的 append | 建联和等待都不恢复 Agent |
|
||||
| `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent |
|
||||
| `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent |
|
||||
| `session.updateQueue`、`cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent |
|
||||
| `models`、`selectModel`、`rename`、`prompt` | 命令解析目标 Session | 仅按方法约定显式恢复 |
|
||||
| `create` 与 fork 目标 | 新 Session/Agent | 用户命令提供创建授权 |
|
||||
|
||||
读取 title、列表和投影不要求 Agent。观察操作不能因为另一个 Remote endpoint 使用了 Agent lookup 而继承其恢复权限。
|
||||
|
||||
#### Session 日志
|
||||
|
||||
`session.page` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。每个请求必须显式携带 `throughSeq`;该值来自对应 `session.follow` generation 的 opening cursor,并把本次读取固定在同一个日志切点。无 `beforeSeq` 的 tail page 必须精确结束于 `throughSeq`,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。
|
||||
|
||||
tail page 同时携带不晚于 `throughSeq` 的 projection baseline;旧页只携带历史 entries。Client 以 projection watermark 合并 page 与后续 live control 更新。
|
||||
|
||||
普通 Session 与 direct subagent 使用同一个 `SessionAddress` 协议。direct subagent 地址同时携带父 Session、子 Session 与 mode,Host 冷读时验证持久 ownership 和 descriptor,不能只凭 child id 越权读取。
|
||||
|
||||
`session.follow` 在检查 attached Session 或 persistence 前先安装 `session/event` 与 `session/created` listener,再读取当前 cursor。
|
||||
|
||||
首次 follow 返回 `{ type: 'opened', cursor }`。带 `afterSeq` 的 generation 先从权威日志重放缺失后缀,再按 seq 排出读取期间缓存的 commit。
|
||||
|
||||
冷 Session 可以立即打开历史并保持 follow 等待。只有另一条显式命令恢复 Agent 后,后续事件才会出现。
|
||||
|
||||
Client 的 `SessionEventStream` 继承 `RemoteJournalStream`,只提供 `session.follow`、`session.page`、Session seq 算法与 repair request。通用层先取得 opening cursor `C`,再调用 `session.page({ throughSeq: C })`;读取期间收到的 `C + 1...` entries 留在 follow 队列中,page 精确覆盖至 `C` 后才按连续 seq 合并并发布。
|
||||
|
||||
```text
|
||||
ctx.remote.session.follow(address, afterSeq?) --------|
|
||||
|[]> SessionEventStream
|
||||
ctx.remote.session.page(address, throughSeq, pageArgs) -| |-- replace(window)
|
||||
|-- prepend(history)
|
||||
`-- append(live entry)
|
||||
```
|
||||
|
||||
每个 Client Session 只持有一个当前 `events: SessionEventStream | undefined`。只读 `SessionEventSource` 把已物化 event window 交给 Conversation consumer。
|
||||
|
||||
Session 的 `openGeneration` 只阻止被 resync、地址替换或 dispose 淘汰的异步结果写回;它不参与 transport retry。
|
||||
|
||||
initial page、repair page 或 follow 的 terminal failure 进入当前 Session 的 `openError`。旧业务 epoch 或旧 stream 的失败不能覆盖新状态。
|
||||
|
||||
#### Session live control
|
||||
|
||||
`session.control()` 是 Host 范围的 snapshot stream,一个浏览器可观察所有当前 live Session 的瞬态状态,而不必为每个 transcript 打开 journal。
|
||||
|
||||
每个 generation 先发完整 baseline,再发 queue、jobs 与 projection 增量帧。baseline 读取 attached Agent 和进程内 registry,不恢复冷 Agent。
|
||||
|
||||
queue 与 jobs 使用完整 replacement 值并按 last-wins 应用。Agent attach、detach、Session disposal 与 owner disposal 都能用空值或新 baseline 清除陈旧镜像。
|
||||
|
||||
原始 `approval/request` 与 `user-questions/request` 是可转发 waterfall。若某个 Agent-scoped Client listener claim,请求直接返回;若所有已投递 Client 都调用 `next()`,原 Cordis waterfall 继续到后续 Host listener。Session control 不保存或重放这些请求。
|
||||
|
||||
projection baseline 与 tail page 的日志切点独立产生,Client 总是保留较高 seq 的值。订阅 live projection 不会为取得值而启动 Agent。
|
||||
|
||||
Session added、removed、activity、running status 与无 turn 位置的 Agent error 不进入 stateful control stream;它们是可由列表 baseline 修复或无需重放的 `ctx.remote.$on` 通知。
|
||||
|
||||
Session 列表的 `updatedAt` 取 `max(header.createdAt, sessionListMetadata.lastPromptAt)`。`lastPromptAt` 只由用户来源的 `user/message` 更新,可从冷 projection 恢复,不依赖浏览器是否正在跟随该 Session。
|
||||
|
||||
### Workspace Controller
|
||||
|
||||
`packages/api/workspace-controller` 提供 Host `ctx.workspaceController` 与生成的 `ctx.remote.workspace` namespace。
|
||||
|
||||
它拥有 create、rename、delete、insertBefore、insertSessionBefore、archiveSession 与 `follow`。Workspace registry 仍是持久事实来源,Controller 负责 Remote 命令、投影和错误映射。
|
||||
|
||||
`WorkspaceFeed` 同步观察 storage `domain/changed`,并为每个 follow generation 先发送完整 baseline,再发送 `upsert`、`remove`、`order` 与 `archived` 增量。
|
||||
|
||||
完整 `order` frame 是 Workspace 排序的权威值。它避免 Client 根据 upsert 到达顺序猜测展示顺序,也能在重连 baseline 后收敛。
|
||||
|
||||
`createWorkspaceStateStream()` 把 `workspace.follow` 装配为 `RemoteSnapshotStream`。Client Runtime 只负责启动和持有该 stream。
|
||||
|
||||
`ClientWorkspaceModel` 位于 Workspace Controller 的 Client 面,拥有 baseline/increment 解析、已物化列表、归档集合、命令结果回显及 unary 与 stream 到达竞态的合并规则。
|
||||
|
||||
成功的 unary 命令可以立即更新本地模型;后到的 stream commit 仍以 Host projection 与完整 order 校正状态。已删除 Workspace 的 id 被记录,延迟结果不能把它重新插回列表。
|
||||
|
||||
```text
|
||||
ctx.remote.workspace.follow() -|[]> RemoteSnapshotStream
|
||||
|-- replace(baseline)
|
||||
|-- upsert/remove(view)
|
||||
|-- replace(order)
|
||||
`-- replace(archived ids)
|
||||
```
|
||||
|
||||
Workspace Remote 方法、状态 feed 和 Client 数据模型均不经过 API Proxy,也不依赖 `host/workspace-*` 通知。
|
||||
|
||||
### Remote Event
|
||||
|
||||
Remote Event 复用 owner 包的 Cordis `Events` 声明。Host 原事件是唯一业务签名,Client `ctx.remote.$on(event, listener)` 从同一声明推导参数、waterfall 结果与 `next()`。
|
||||
|
||||
`packages/api/remotes` 的 allowlist 是应用选择的唯一来源。每项显式标注 `emit` 或 `waterfall`,该 mode 同时决定 Host 监听方式、Client 合法键集和 wire frame 类型。
|
||||
|
||||
系统不声明 `RemoteInvocationMap`,不要求 Client 再写一份 `@Remote`,也不以最后一个运行时参数是否为函数来猜测调用模式。
|
||||
|
||||
Remote Event 下行帧是显式 discriminated union:
|
||||
|
||||
```text
|
||||
ready { type, clientId }
|
||||
emit { type, event, args }
|
||||
waterfall { type, event, eventId, agentId, request }
|
||||
cancel { type, eventId }
|
||||
```
|
||||
|
||||
WebSocket JSON 与进程内 carrier 的入口都从 `unknown` 开始按 `type` 和精确字段验证;验证完成后的分发只接收 typed union。TypeScript 静态类型不替代 wire 校验。
|
||||
|
||||
普通 `emit` 参数必须是无损 JSON。Client 在每个 Remote 实例私有的 Cordis key 上调用 `parallel()`,保留注册顺序、调用方 fiber 所有权和 listener 错误隔离。
|
||||
|
||||
私有 key 防止 Host 事件与 Client 本地同名 Cordis 事件互相触发。Client Remote 不维护自己的 subscription registry 或手写 listener chain。
|
||||
|
||||
可返回的 waterfall 当前只支持 Agent scope。事件签名必须是一个含直接 `agent` 字段的 request,加一个返回同类型结果的 `next()`,整体返回 Promise。
|
||||
|
||||
Host 只投影 request 一级的 `agent` 与 `signal`:`agent` 变为 frame 的一级 `agentId`,`signal` 成为 delivery lifetime,其余字段必须整体为无损 JSON。
|
||||
|
||||
Client 用 `agentId` 同步解析或物化 Agent Context,把当前 delivery signal 放回 request 的直接 `signal` 字段,再在目标 Context 的私有 key 上调用 Cordis `waterfall()`。Session-backed adapter 在首个成功 Session 列表 baseline 到达前允许 transport 先物化 scope;baseline 到达后由列表生命周期接管 scope 存活判断。
|
||||
|
||||
系统不扫描任意深度对象,不传 path array 或 placeholder,不 deep clone/restore Context 和 AbortSignal,也不等待未来出现的 Agent Context。
|
||||
|
||||
Client adapter 未注册、resolver 未返回 Context 或解析抛错时,本 Client 立即返回 `next`。它不订阅 registry、不做 resolve 后竞态复查,也不为一次 delivery 创建临时 Fiber。
|
||||
|
||||
Gateway Host 为每个未完成 waterfall 保存 `eventId`、Host continuation 与已投递 Client generation。新 Client generation 会收到同一 pending event 的重放。
|
||||
|
||||
每个 generation 的队列保证一次投递,因此 Client 不保存 `seen` 集合。`clientId + eventId` 绑定结果与当前 generation,旧连接的回包不能完成新连接上的 delivery。
|
||||
|
||||
多 Client 同时接收 waterfall 时,第一个 result 或 rejection 完成 Host 调用,并向其余 Client 发送 `cancel`。只有所有已投递 Client 都返回 `next` 时,Gateway 才继续原 Cordis chain。
|
||||
|
||||
Host caller signal 取消、Agent Context 释放、Client generation 结束和 losing-client cancellation 都会终止对应的等待。
|
||||
|
||||
Client 通过现有 HTTP unary RPC `$events/result` 回送 `next`、result 或 rejection;下行事件仍复用 Remote WebSocket mux,不为应答建立 duplex WebSocket。
|
||||
|
||||
Gateway 只验证 waterfall 返回值能无损表示为 JSON,不解释业务字段。Question 回答的 option 归属等语义由请求方或 UI 领域承担,transport 不重复校验。
|
||||
|
||||
`UserQuestionService` 在请求期间观察到调用方 `AbortSignal` 已取消、且 provider 抛出普通错误时,将其归一为 `UserQuestionError` 的 `ASK_ABORTED`,并把原错误保留为 `cause`;provider 已给出的领域错误保持不变。
|
||||
|
||||
`$events/result` 失败会令当前 Connection generation 失败。Host 随 generation 撤销该 Client 的 delivery,pending event 在下一 generation 重放,Client 不维护第二套结果重试队列。
|
||||
|
||||
普通 `$on` 通知在断线后不重放。凡正确性依赖恢复的数据必须有 query、cursor 或 opening baseline,不能依赖 Remote Event 恰好送达。
|
||||
|
||||
Client listener 晚于事件到达才注册时不补送;HMR 也没有专用补投语义。
|
||||
|
||||
### API Proxy 的剩余边界
|
||||
|
||||
Session Controller 与 Workspace Controller 直接提供生成 Remote namespace;API Remotes 与 API Gateway 直接提供 Host-to-Client 事件。
|
||||
|
||||
Client Connection 只维护 Host generation、description 与通用 RPC,不解析领域 frame。
|
||||
|
||||
Client Runtime 只接收 Controller adapter 产出的领域变更,不识别 `HostFrame`、`session/subscribed`、`session/event` mux frame 或 `host/workspace-*` frame。
|
||||
|
||||
API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace、Remote Event 或 Connection generation 的依赖。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**建立任意 Session stream 时自动恢复 Agent。** 这会让查看历史、读取 title、重连标签页或观察后台状态产生执行副作用,也会让多个浏览器触发重复恢复;冷日志和投影已有 persistence 来源。
|
||||
|
||||
**只允许 live Agent 使用 `session.follow`。** 这会迫使 transcript 首屏恢复 Agent,或重新引入 unary history 与 live subscription 之间的竞态;按 identity 先 follow 再冷读能同时覆盖历史和未来的显式激活。
|
||||
|
||||
**把 Session transport 与 Session commands 拆成两个公开包。** 两者共同依赖 Session address、Agent 激活策略、subagent ownership、错误映射和 Client 挂载顺序;一个公开 Controller 保持统一所有权,内部 class 仍可独立演化。
|
||||
|
||||
**把 queue、jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。
|
||||
|
||||
**让每个领域 Controller 继承一个 page/follow/retry 基类。** Session journal 与 Workspace snapshot 的 opening、恢复和排序规则不同;Gateway 的三个组合式 stream 对象复用 transport 生命周期,同时让领域 adapter 只声明自己的 frame 语义。
|
||||
|
||||
**给 Remote Event 新建一份 Client invocation 声明。** 第二张 map 或 Client `@Remote` 会复制 owner Cordis 事件签名并形成漂移点;从同一 `Events` 声明推导 `$on` listener 和结果类型可以构造性地保持一致。
|
||||
|
||||
**把 Agent scope 做成任意深度对象投影。** 递归扫描 Context 与 AbortSignal 需要 path、placeholder、clone 和 restore 协议,并把偶然对象结构升级成 wire 约定;一级 `agent` 与 `signal` 足以覆盖当前 waterfall。
|
||||
|
||||
**等待 Client Agent Context 或 adapter 后再分发。** registry waiter、竞态复查和临时 delivery Fiber 会为一个可同步解析或物化目标的 Client 增加额外生命周期;resolver 当下不能提供目标时立即 `next` 保持 Cordis waterfall 语义。
|
||||
|
||||
**给 Remote Event 使用独立物理 WebSocket 或 duplex stream。** Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 `$events` 加上 HTTP `$events/result` 足以表达 request/response,不需要第三条连接。
|
||||
|
||||
**继续保留 API Proxy 的 Host mux。** 这会保留手写 union、schema、响应 envelope 和第二套 stream 生命周期,并使 Session 与 Workspace Controller 不能独立拥有自己的数据协议。
|
||||
|
||||
**从聚合 `session/event` 更新 Session 列表时间。** 列表正确性会依赖浏览器正在消费哪些 Session,并把任意插件事件误判为用户活跃;持久 `lastPromptAt` 投影直接表达排序事实。
|
||||
|
||||
## 验证
|
||||
|
||||
Gateway mux 测试固定无 logical stream 时建连、空闲常驻、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。
|
||||
|
||||
Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。
|
||||
|
||||
`RemoteStream` 测试固定单 consumer、opening acceptance 后清零 retry、`restart()` 只替换 generation、terminal error 不重试和 dispose quiescence。
|
||||
|
||||
`RemoteSnapshotStream` 测试固定每 generation 恰好一份 opening snapshot、update-before-snapshot 拒绝、重复 snapshot 拒绝和重连 replacement。
|
||||
|
||||
`RemoteJournalStream` 测试固定 follow-before-page、opening overlap 去重、连续 append、历史 prepend、重连 catch-up、gap repair 与一次性 replacement。
|
||||
|
||||
Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。
|
||||
|
||||
Session control 测试固定 baseline-first、冷 Session 不恢复、attach/detach 清理、queue 与 jobs replacement,以及 projection watermark。
|
||||
|
||||
Session Client 测试固定每 Session 单一 journal owner、旧 open epoch 不写回、control 与 journal 独立取消,以及 carrier retry 期间保留已发布窗口。
|
||||
|
||||
Workspace Host 测试固定 baseline-first、upsert/remove、权威 order、archived set 和 follower disposal。
|
||||
|
||||
Workspace Client 测试固定 snapshot replacement、unary/stream 竞态、删除不复活、稳定排序和 terminal failure。
|
||||
|
||||
Remote Event 类型测试拒绝未选择事件、非 void 的 unscoped 事件、非 Agent-scoped waterfall 和签名不匹配的 mode。
|
||||
|
||||
Remote Event Host 测试固定 listener-before-ready、payload 校验、pending replay、多 Client first-result、all-next delegation、rejection、Host cancellation、Context release 和 losing-client cancel。
|
||||
|
||||
Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent Context 解析、`next`、result、rejection、cancel、旧 generation 回包拒绝和 `$events/result` 失败导致 generation 结束;User Question 测试固定进行中 signal 取消的错误归一化及 cause 保留。
|
||||
|
||||
缺失 source、重复 source、撤回 source、非 ready 首项、未知 discriminant、额外字段与非 JSON 值都在各自 wire 入口响亮失败。
|
||||
|
||||
静态检查固定 API Proxy 不再导出 Session/Workspace Host frame carrier,Client Runtime 不再包含对应 bridge。
|
||||
|
||||
## 后果
|
||||
|
||||
浏览器可以在 Agent 停止时读取并跟随持久 Session。观察不隐式恢复执行,只有明确获得授权的 Session 命令按各自约定创建或恢复 Agent。
|
||||
|
||||
持久日志用 seq 与 page 修复缺失后缀;Session control 和 Workspace state 用 opening snapshot 收敛;普通 Remote Event 不承诺重放。恢复语义由数据类型决定,不再互相模拟。
|
||||
|
||||
Gateway 只拥有 transport、generation、pending waterfall 和严格 wire 校验,不拥有 Session 或 Workspace 业务字段。领域 Controller 只提供 opener、cursor 规则、baseline reducer 和错误呈现。
|
||||
|
||||
Session 与 Workspace 的 Host API、stream adapter 和 Client 数据模型各有明确 owner;API Proxy 不再是它们之间的中介。
|
||||
|
||||
通用 stream 对象增加了三个明确层级,但删除了每个 Controller 各自复制的 retry、cancel、generation、baseline 和 gap-repair 外壳。
|
||||
|
||||
Remote waterfall 保留多 Client 首个 claim、全体 `next` 后继续 Host chain、断线重放 pending 和端到端取消;代价是当前协议只支持一级 Agent scope 与无损 JSON 请求/结果。
|
||||
|
||||
本决定扩展[Remote 事件投递](2026-08-10-remote-event-delivery.zh.md)的 allowlist 与单一 Cordis 签名设计:普通通知继续使用 `emit`,Agent-scoped async waterfall 使用同一 `ctx.remote.$on` 面和显式 `waterfall` mode;不建立第二套 invocation map。
|
||||
|
||||
本决定接管[简单一元 API Proxy 迁移](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md)中保留的 Session、Workspace 与 Host event carrier,并保留[后台任务展示](../feature/2026-08-08-web-background-job-display.zh.md)所要求的完整 jobs snapshot、进程内生命周期和“观察不恢复 Agent”语义。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md
|
||||
2026-08-20-client-session-conversation-ownership.md: 8e5521ff1981d83ab72db00dea556b4b2acc97fa
|
||||
2026-08-20-client-session-conversation-ownership.zh.md: a007a42b3d10ceeced8a2a64696521a96382f4e5
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
# Agent Note: Client Session, Conversation, and UI ownership layers
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-20-client-session-conversation-ownership.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web Client once placed Session and Workspace objects, event windows, Conversation assembly, React hooks, the Slot registry, and the Store engine in one general Runtime. Protocol state, business projections, React bindings, and page presentation shared one dependency hub, so a change in any layer could spread across the entire frontend.
|
||||
|
||||
Session snapshots could also accumulate data they did not own, including event arrays, Conversation Views, Chat Nodes, and pending interactions. Ordinary consumers then had to understand event replay and concrete views, while adding a Conversation target could require changes to Session, Runtime, and the renderer.
|
||||
|
||||
Without an explicit interface between React and Session lifetimes, binding release, Hook source replacement, and Slot store cleanup became dedicated callback protocols. Approval and Question both affect sidebar state and composer takeover; independently maintained state could make those surfaces select different pending requests.
|
||||
|
||||
The Client needs one-way dependencies between data owners, React adapters, generic rendering machinery, and concrete views while preserving application behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
The Client uses the layering “Controller and domain object → UI adapter → renderer → Slot component.” Controllers and domain objects publish React-free observable sources; their `ui-*` packages declare standard props and register sources; `ui-renderer` creates selector hooks at Slot binding points; components read data and actions only from Slot props.
|
||||
|
||||
```text
|
||||
[Remote / Controller / domain object]
|
||||
|
|
||||
| bare observable source
|
||||
v
|
||||
[ui-* adapter]
|
||||
|
|
||||
| standard source registration
|
||||
v
|
||||
[ui-renderer]
|
||||
|
|
||||
| selector hook binding
|
||||
v
|
||||
[Slot component]
|
||||
```
|
||||
|
||||
Client Session and Workspace objects belong to `api/session-controller/client` and `api/workspace-controller/client`, respectively. Target-neutral Conversation data structures and assembly belong to `client/ui-conversation`; Chat and Trajectory belong to `client/ui-chat` and `client/ui-trajectory`, respectively.
|
||||
|
||||
The React adapters for Session and Workspace belong to `client/ui-session` and `client/ui-workspace`. The Store engine belongs to `client/store`; the Slot registry, scope materialization, and observable-to-hook binding belong to `client/ui-renderer`.
|
||||
|
||||
The system has no aggregate `client/runtime` package and no replacement central facade. [Session history and event transport](2026-08-18-session-history-and-event-transport.md) defines Session history, Remote streams, pagination cursors, and reconnect continuity; this note starts from the Client objects and sources published by Controllers.
|
||||
|
||||
## Layering principles
|
||||
|
||||
### Controllers are React-free logic owners
|
||||
|
||||
A Controller may be installed as a Cordis service, but it does not own React Contexts, React hooks, Slot props, or components. A Controller snapshot contains only facts that it owns, and its commands change only Host or domain-object state.
|
||||
|
||||
The UI layer may read multiple Controllers for one navigation decision, but it does not write the combined result back into any Controller snapshot. A UI adapter does not duplicate a Controller command's business implementation.
|
||||
|
||||
### UI adapters own React integration
|
||||
|
||||
Each standard hook belongs to the `ui-*` package closest to its data semantics.
|
||||
|
||||
| Hook | Owner | Source |
|
||||
| --- | --- | --- |
|
||||
| `useSessions` | `client/ui-session` | Session Controller global list |
|
||||
| `useSession` | `client/ui-session` | Current Session snapshot |
|
||||
| `useProjection` | `client/ui-session` | Current Session keyed projection |
|
||||
| `useSessionPendingInteraction` | `client/ui-session` | Aggregated pending domains |
|
||||
| `useWorkspaces` | `client/ui-workspace` | Workspace Controller list |
|
||||
| `useConversation` | `client/ui-conversation` | Conversation binding snapshot |
|
||||
| `useChat` | `client/ui-chat` | `chat` target source |
|
||||
| `useTrajectory` | `client/ui-trajectory` | `trajectory` target source |
|
||||
|
||||
`ui-renderer` implements only generic binding. It does not import Session, Workspace, Conversation, Chat, or Trajectory business types or values.
|
||||
|
||||
### Slot scopes and standard props are separate
|
||||
|
||||
`ui-slots` declares root, session, and session-maybe scopes plus declaration-merge-extensible standard prop types. It does not decide which hooks each scope installs.
|
||||
|
||||
`ui-renderer` implements generic scope adapters and source materialization. `ui-session` installs the Session scope and supplies its built-in sources; other domain packages register only their own sources and the Slot entries that consume them.
|
||||
|
||||
Adding a target does not add a branch to the renderer or Session Controller. The data owner handles state identity, updates, errors, and release; the UI adapter owns the hook; the presentation owner owns target-specific projections and interaction state.
|
||||
|
||||
## Package ownership
|
||||
|
||||
| Package | Owns | Explicitly does not own |
|
||||
| --- | --- | --- |
|
||||
| `api/session-controller/client` | Session objects, list, selection, commands, projections, queue, event windows, and Agent Contexts | Conversation targets, React, Slots, Workspace |
|
||||
| `api/workspace-controller/client` | Workspace objects, ordering, archive state, commands, and snapshots | React, Session navigation policy, directory UI |
|
||||
| `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results |
|
||||
| `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data |
|
||||
| `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots |
|
||||
| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, locale, and historical images | Session lifecycle, generic View navigation, Trajectory |
|
||||
| `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots |
|
||||
| `client/ui-approval` | Pending Approval, Remote listener, composer, and approval UI | Session control, generic composer election |
|
||||
| `client/ui-user-questions` | Pending Question, Remote listener, composer, and question UI | Session control, generic composer election |
|
||||
| `client/store` | React-free Store contract and implementation | Domain objects, React hooks, Slot lifetimes |
|
||||
| `client/ui-renderer` | SlotRegistry, scope binding, selector hooks, outlets, and React root | Session, Workspace, and Conversation business logic |
|
||||
|
||||
## Overall data flow
|
||||
|
||||
Session data reaches the UI through this path:
|
||||
|
||||
```text
|
||||
[ctx.remote.session]
|
||||
|
|
||||
v
|
||||
[api/session-controller/client]
|
||||
|-- SessionListState --------------------------> [ui-session] -> useSessions
|
||||
|-- SessionSnapshot ----------------------------> [ui-session] -> useSession
|
||||
|-- ProjectionValueSource ----------------------> [ui-session] -> useProjection
|
||||
`-- per-Session SessionEventSource
|
||||
|
|
||||
v
|
||||
[client/ui-conversation]
|
||||
|
|
||||
| assemble
|
||||
v
|
||||
ConversationSnapshot ----------------> useConversation
|
||||
|
|
||||
|---------+----------|
|
||||
v v
|
||||
[ui-chat] [ui-trajectory]
|
||||
| |
|
||||
useChat useTrajectory
|
||||
```
|
||||
|
||||
Workspace data enters the Workspace Controller from `ctx.remote.workspace`, then `ui-workspace` exposes it as `useWorkspaces`. For cross-domain navigation, `ui-workspace` temporarily reads the Session Controller and issues a selection or command.
|
||||
|
||||
Approval and Question arrive from the Host waterfall through `ctx.remote.$on` at their respective UI owners. Each owner publishes a Pending object; `ui-session.pendingInteractions` then supplies that same object to Session navigation state and Conversation composer selection.
|
||||
|
||||
## Session Controller Client
|
||||
|
||||
### Scope of SessionSnapshot
|
||||
|
||||
`SessionSnapshot` represents control and lifecycle facts belonging to a Session. It may contain identity, running, removed, blank, subagent address, open phase, history phase, prompt error, agent error, and queue state.
|
||||
|
||||
It does not contain:
|
||||
|
||||
- a raw event array;
|
||||
- Conversation Views;
|
||||
- Chat Nodes;
|
||||
- Trajectory rows;
|
||||
- pending Approval or Question objects;
|
||||
- presentation state that requires callers to traverse events.
|
||||
|
||||
Whether a field derives from an event, control frame, or local command does not automatically determine its owner; consumption semantics determine ownership. `composerPhase` depends on both Session lifecycle and Conversation target activity, so `ui-conversation` composes it instead of placing it in `SessionSnapshot`.
|
||||
|
||||
### Three read faces
|
||||
|
||||
The Session Controller exposes three distinct read faces:
|
||||
|
||||
1. The global Session list and current-selection source, used by navigation and `useSessions`.
|
||||
2. A logical binding for each Session containing `sessionId`, a `SessionSnapshot` source, commands, and projection sources.
|
||||
3. A Conversation-facing `SessionEventSource` used only by the Conversation assembly core.
|
||||
|
||||
Ordinary UI components do not read `SessionEventSource` directly. `ui-session` does not read private event windows, and the `ui-conversation` core receives neither React bindings nor Slot APIs.
|
||||
|
||||
### SessionEventSource
|
||||
|
||||
`SessionEventSource` exposes a materialized event window, not a transport.
|
||||
|
||||
The window carries ordered `entries`, `hasMore`, a monotonic `revision`, and a `replace | prepend | append` change description. Append links an immutable segment in constant time; a consumer that needs the complete `entries` array materializes and caches it for that snapshot.
|
||||
|
||||
Initial open, reconnect, gap repair, and updates whose continuity cannot be proven publish `replace`; history pagination publishes `prepend`; a continuous live event publishes `append`. The Conversation core selects incremental update or complete rebuild from the revision and change.
|
||||
|
||||
`MutableSessionEventSource` is the Session Controller's internal write face. Consumers depend only on the read-only `SessionEventSource`.
|
||||
|
||||
### Session binding lifecycle
|
||||
|
||||
Each Session binding owns a Cordis Context and Fiber. The Session Controller creates and releases the binding.
|
||||
|
||||
Objects that depend on a Session register cleanup through `binding.ctx.effect()`. Releasing a binding cleans up Conversation bindings, UI materializations, and scoped Slot stores without a dedicated `onBindingRelease` or `onRelease` callback protocol.
|
||||
|
||||
This cleanup does not require the Session Controller to know the roster of upper-layer consumers.
|
||||
|
||||
## UI Session
|
||||
|
||||
### Service responsibilities
|
||||
|
||||
`client/ui-session` is the sole Session adapter between the Session Controller and the React/Slot system. It provides `ctx.uiSession` and:
|
||||
|
||||
- observes the Session list, current selection, and per-Session bindings;
|
||||
- installs the session and session-maybe scope adapters;
|
||||
- supplies `SessionProvider` rendering semantics;
|
||||
- supplies built-in Session snapshot, projection, and sessionId sources;
|
||||
- accepts Session-scoped source contributions from other domain packages;
|
||||
- aggregates pending interactions registered by business packages.
|
||||
|
||||
It does not own Session transport, event folding, Conversation targets, or concrete business results.
|
||||
|
||||
### Standard source registration
|
||||
|
||||
A domain package calls `ctx.uiSession.provide()` to register a bare source. The descriptor statically declares its hook, keyed-hook, and prop rosters; `resolve(binding)` returns exactly those values for one Session binding. For example, `ui-conversation` registers each binding's snapshot as the `conversation` hook source.
|
||||
|
||||
The renderer converts an ordinary source into `use<Name>`. Open key spaces such as projections use a keyed-hook resolver, while stable values use props.
|
||||
|
||||
The runtime rejects undeclared, missing, or duplicate standard props. `ui-session` materializes its own built-ins through the same mechanism, so the renderer has no Session-specific name branches.
|
||||
|
||||
### Scope binding
|
||||
|
||||
session and session-maybe use the same adapter with different binding semantics:
|
||||
|
||||
- a strict session scope refuses to render without a current binding;
|
||||
- session-maybe uses a stable absent binding to preserve hook call order;
|
||||
- changing the current Session rebuilds the strict Session subtree under the `sessionId` key;
|
||||
- root and session-maybe entries may remain mounted across Session changes.
|
||||
|
||||
Each real materialized binding retains the Controller binding's Context. `ui-session` removes the cache entry and withdraws the current binding through `binding.ctx.effect()`.
|
||||
|
||||
Changing the contribution roster rematerializes existing bindings and publishes a new source set. Source identity remains stable within one binding lifetime, as required by `useSyncExternalStore` caching.
|
||||
|
||||
### SessionProvider
|
||||
|
||||
`SessionProvider` is a standard seat derived by `PropsRenderSlots` from a session-scoped child declaration, not a React Context imported directly by business components.
|
||||
|
||||
It accepts ordinary `ReactNode` children rather than a `(sessionId) => ReactNode` render function; callers wrap `renderSlot('details', {})` directly.
|
||||
|
||||
Session identity comes from the scope binding and standard `sessionId` prop. The Provider handles only the absent branch and subtree isolation by Session identity; components do not obtain Session data through a Provider callback.
|
||||
|
||||
### Pending interactions
|
||||
|
||||
Business packages extend `SessionPendingInteractionMap` through declaration merging. Every pending object carries at least a stable `key`, domain `kind`, and `sessionId`; `ui-session` does not import concrete Approval or Question types.
|
||||
|
||||
A business plugin calls `registerPendingInteraction(precedence)` in `apply()` to create a stable registration for its pending domain. The returned per-request publication function publishes one exact object together with its waterfall-delegation callback and returns an idempotent disposer for that object. Plugin teardown removes all published objects before invoking and awaiting their delegation callbacks, so active Host requests cannot remain suspended after their Client answerer unloads.
|
||||
|
||||
Concurrent objects with the same key are rejected; replacement requests use a new key. One Session may hold multiple domains or requests at once.
|
||||
|
||||
`ui-session` selects each Session's effective object using domain precedence. Higher precedence wins; at equal precedence, the later valid object in traversal order wins.
|
||||
|
||||
The aggregate is published as `pendingInteractions: ObservableSnapshot<ReadonlyMap<SessionId, SessionPendingInteraction>>`; `useSessionPendingInteraction` is its React read face.
|
||||
|
||||
Session navigation state and composer takeover read the same effective object. They do not maintain separate status maps or takeover rosters.
|
||||
|
||||
## Workspace Controller and UI Workspace
|
||||
|
||||
### Scope of WorkspaceSnapshot
|
||||
|
||||
`WorkspaceSnapshot` contains only Host-authoritative data owned by the Workspace Controller, including Workspace rows, order, archive set, follow phase, and errors. A Workspace row's `sessionIds` is an association field, not a copy of Session objects in the Workspace snapshot.
|
||||
|
||||
These combined facts do not enter `WorkspaceSnapshot`:
|
||||
|
||||
- whether the Workspace and Session baselines are both ready;
|
||||
- the most recent Workspace derived from Session update times;
|
||||
- whether the current Session is cleared because it was archived;
|
||||
- which blank Session New Session should reuse;
|
||||
- which Session initial startup should select.
|
||||
|
||||
### UI Workspace composition responsibilities
|
||||
|
||||
`client/ui-workspace` registers the Workspace list source as the root standard source `workspaces`, from which the renderer provides `useWorkspaces`.
|
||||
|
||||
Initial selection, blank-Session reuse, new-session navigation, concurrent-create coalescing, and navigation after archival are UI navigation policy. That policy may read both `ctx.workspaces` and `ctx.sessions` at decision time, but it issues only Controller commands and selection actions and does not publish a combined snapshot.
|
||||
|
||||
Directory pickers, directory browsing, and `openPath` are separate directory capabilities and do not enter the Workspace Controller.
|
||||
|
||||
## UI Conversation
|
||||
|
||||
### Assembly core
|
||||
|
||||
`client/ui-conversation` contains both the React-free Conversation assembly core and the React adapter for the same domain.
|
||||
|
||||
The core owns `ConversationSnapshot`, the Definition registry, the View registry, the event assembler, the location index, per-Session bindings, target sources, and target activity.
|
||||
|
||||
The core obtains `SessionEventSource` from a Session binding. Append and prepend changes with continuous revisions use incremental assembly; replace changes or revision gaps rebuild from the complete window.
|
||||
|
||||
Definition or View roster changes rebuild only the Conversation binding; they do not rebuild a Session or reopen a Remote stream. The core does not import React and can test event folding, incremental updates, and registry lifetimes independently.
|
||||
|
||||
`ConversationSnapshot` does not copy `SessionSnapshot` or expose raw events. It publishes only the target-neutral View roster, target activity, and target-source lookup.
|
||||
|
||||
`useSession` and `useConversation` come from separate sources and are not guaranteed to publish atomically in one React commit. Components that read both compute purely from their current snapshots and do not treat notification order as business causality.
|
||||
|
||||
### Definition and View registries
|
||||
|
||||
`UiConversation.events` is the sole registry for event Definitions, and `UiConversation.views` is the sole registry for target snapshot builders.
|
||||
|
||||
The registries reject duplicate keys, preserve registration order, and return idempotent disposers. Existing Conversation bindings rebuild from their current event windows when a roster changes; changes in one synchronous registration turn are coalesced into one microtask rebuild.
|
||||
|
||||
A target package extends snapshot and location-data maps through declaration merging, then registers its Definitions, builder, and View. Registrations follow Cordis effect disposal.
|
||||
|
||||
`ui-conversation` does not import concrete target packages.
|
||||
|
||||
### Conversation React adapter
|
||||
|
||||
The React adapter registers each Conversation binding snapshot as the Session standard source `conversation`, from which the renderer provides `useConversation`.
|
||||
|
||||
The package also owns the shell, input, composer chain, queue UI, drafts, View navigation, and phase composition. The core reads no React Context, Slot props, or component state.
|
||||
|
||||
View selection order is a valid persisted selection, registered `chat`, then no View. An invalid selection does not overwrite the persisted value, and the system does not fall back to the first registered View.
|
||||
|
||||
Without `ui-chat`, the shell can still activate and mount but does not implicitly select Trajectory or another target.
|
||||
|
||||
The shell phase is a pure composition of Session lifecycle and Conversation target activity. An active Session or any target reporting visible content produces active; a failed first prompt remains engaging.
|
||||
|
||||
### Input and composer
|
||||
|
||||
The composer chain belongs to `ui-conversation`; a concrete takeover belongs to its business package. `ConversationRoot` reads the current Session's effective object through `useSessionPendingInteraction` and supplies it to chain selectors as `ComposerChainProps.pendingInteraction`.
|
||||
|
||||
A selector is a pure function of owner currency. Its non-null result reaches the selected component as `matched`. A stable composer entry and the default composer remain mounted together, while the chain selects one effective presentation.
|
||||
|
||||
Draft and input state belong to Conversation UI and do not enter the Session snapshot. Queue commands use a Session-scoped service for addressing and do not write queue UI into the Conversation core.
|
||||
|
||||
## Chat and Trajectory targets
|
||||
|
||||
### Chat owner
|
||||
|
||||
`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, Tool-inspection collaboration, and historical-image cache.
|
||||
|
||||
It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`.
|
||||
|
||||
Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation.
|
||||
|
||||
The historical-image cache's Session key, pending promise, generation guard, blob URL, and disposer all belong to `ui-chat`; draft images remain part of Conversation input.
|
||||
|
||||
### Trajectory owner
|
||||
|
||||
`client/ui-trajectory` registers `trajectory` through the same target protocol. It owns event-record projection, timelines, virtual rows, selection, and the inspection view, and exposes `useTrajectory` through a standard source.
|
||||
|
||||
Session lifecycle reads `useSession`, while Trajectory data reads `useTrajectory`. Trajectory does not obtain its own data through a Session or Chat snapshot.
|
||||
|
||||
Other targets use the same registration flow without modifying the renderer, Session Controller, or ui-session.
|
||||
|
||||
## Approval and Question
|
||||
|
||||
### Stable registration
|
||||
|
||||
Approval and Question plugin installation separates stable registrations from per-request handling. `apply()` registers locale data, calls `registerPendingInteraction()` once for its pending domain, and registers one stable entry in `conversation.composer`.
|
||||
|
||||
The stable Approval entry also declares its detail child Slot. Concurrent requests and Session count do not add composer entries or redeclare Slots, and every registration follows plugin-fiber disposal.
|
||||
|
||||
### One waterfall request
|
||||
|
||||
A Remote Event listener resolves the Session from its own Agent Context. Without a Session scope it calls `next()` to continue the waterfall; with a Session scope it creates a `PendingApproval` or `PendingQuestion`.
|
||||
|
||||
The listener publishes the object through the registered domain publication function, waits for user completion, cancellation, or request-signal abortion, and removes the exact object in `finally`.
|
||||
|
||||
One request does not register a Slot, create another lifecycle effect, or mutate the Session snapshot.
|
||||
|
||||
Approval exposes allow and reject; Question exposes answer and cancel. User cancellation of a Question returns `ASK_CANCELLED`; interruption of a pending request by `AbortSignal` returns `UserQuestionError(ASK_ABORTED)` rather than leaking the carrier's `AbortError` or an ordinary `Error`.
|
||||
|
||||
The Gateway requires only that Remote Event arguments and results are valid JSON transport values. It does not duplicate domain validation of Question options.
|
||||
|
||||
### One pending projection
|
||||
|
||||
The Sidebar and composer consume the same `pendingInteractions` snapshot. Navigation displays approval, plan-review, or question state from the effective object's `kind`; each composer entry selects its own panel by object identity.
|
||||
|
||||
The same request identity drives both UI surfaces. A request that replaces another request of the same type uses a new key, so selectors and subscribers observe the identity change.
|
||||
|
||||
`ui-session` implements only cross-domain precedence and does not interpret Approval or Question fields.
|
||||
|
||||
## UI Renderer and Store
|
||||
|
||||
### UI Renderer
|
||||
|
||||
`client/ui-renderer` owns the `SlotRegistry` service and React renderer. It is responsible for:
|
||||
|
||||
- `ctx.slots.register()`, `inject()`, `renderSlot()`, and declaration lifetimes;
|
||||
- root, session, and session-maybe scope adapters;
|
||||
- binding standard observable sources to selector hooks;
|
||||
- Slot outlets, error isolation, root mount, and hydration;
|
||||
- managing Slot store instance lifetimes by scope key.
|
||||
|
||||
The renderer may know generic scope names and binding protocols but does not read domain services. Rendering Session scope without an installed adapter is an assembly error that fails immediately.
|
||||
|
||||
### Store
|
||||
|
||||
`client/store` is a plain React-free library owning `ObservableSnapshot`, `SnapshotStore`, `defineStore`, `createSnapshotStore`, and `shallowEqual`.
|
||||
|
||||
`ui-slots` references the Store contract; `ui-renderer` manages Store instances and supplies `useStore`.
|
||||
|
||||
Stores hold viewing and interaction state such as drafts, View selection, Chat selection, inspection requests, and panel size. Session, Workspace, Conversation, Remote streams, and connection generations do not enter Stores.
|
||||
|
||||
### Registration and release order
|
||||
|
||||
When one plugin provides both a source and a Slot entry, it registers the source first and the entry second. Reverse Cordis disposal then removes the entry before the source, so a mounted entry never briefly loses a required hook.
|
||||
|
||||
Releasing a Session binding cleans up UI materialization and scoped Stores through `binding.ctx.effect()`. Releasing a plugin fiber cleans up sources, listeners, and Slot entries through registration disposers.
|
||||
|
||||
Every disposer is idempotent and depends on no implicit callback outside the Cordis lifecycle.
|
||||
|
||||
## Composition and dependency direction
|
||||
|
||||
The application bundle explicitly installs the required Controller, adapter, target, and renderer plugins. Each owner's `apply()` installs only its own service, listener, and contributions.
|
||||
|
||||
Runtime consumption flows as `session-controller → ui-session → ui-conversation → target UI`, `workspace-controller → ui-workspace`, and `store → ui-slots → ui-renderer`; Approval and Question depend only on the pending-registration point exposed by `ui-session`.
|
||||
|
||||
Arrows in this description represent runtime consumption and do not include type-only declaration-merge edges. Controllers do not depend back on UI adapters, the renderer does not depend back on domain packages, and the Conversation core does not depend on a concrete target.
|
||||
|
||||
UI components do not receive `ctx`. Cross-package collaboration uses Cordis services, standard sources, or Slot registrations without introducing an aggregate facade.
|
||||
|
||||
## Developer guidance
|
||||
|
||||
### Choose the data owner first
|
||||
|
||||
Before adding state, choose its sole owner from its consumption semantics: Host communication, commands, and entity lifecycle belong to an API Controller; data assembled from Session events but independent of a target belongs to the Conversation core; projections serving only one View belong to that target package; drafts, selections, and panel state belong to the UI package that owns the interaction.
|
||||
|
||||
The same fact must not be retained simultaneously in a Controller snapshot, Conversation snapshot, and Store. A cross-domain decision reads multiple sources and immediately issues a command; it does not create a joined snapshot or cache another domain's object.
|
||||
|
||||
These are signs of incorrect ownership: a Controller imports React; the renderer branches on business types; a component traverses Session events; a Store holds Session or Workspace entities; changing one target requires changing the Session Controller.
|
||||
|
||||
### Add Session-scoped data
|
||||
|
||||
1. Provide a React-free observable source in the domain owner.
|
||||
2. Declaration-merge the standard prop type in the owning UI adapter.
|
||||
3. Declare a fixed roster through `ctx.uiSession.provide()` and resolve its source from a Session binding.
|
||||
4. Let the Slot component receive the generated hook through `PropsRuntime`; do not pass `ctx` to a component.
|
||||
5. Attach each binding resource's cleanup to `binding.ctx.effect()` and leave registration cleanup to the plugin fiber.
|
||||
6. Test missing values, duplicate names, roster replacement, Session changes, and binding disposal.
|
||||
|
||||
Only open key spaces use keyed hooks. Finite stable sources use ordinary hooks, and immutable identifiers use props. Do not hard-code business names in the renderer to save one registration.
|
||||
|
||||
### Add a Conversation target
|
||||
|
||||
1. Extend the Conversation snapshot or location-data map in the target package.
|
||||
2. Register the required event Definitions with `UiConversation.events`.
|
||||
3. Register the snapshot builder, target id, View, and activity rule with `UiConversation.views`.
|
||||
4. Expose the target's standard selector hook through `ctx.uiSession.provide()`.
|
||||
5. Register the renderer, locale, and target-specific Slot entries in the same package.
|
||||
6. Verify that unloading the target rebuilds only the Conversation binding without changing the Session, other targets, or Remote stream.
|
||||
|
||||
A target must not use another target's snapshot as its data source. Optional collaboration uses a narrow port or Slot; when a target is absent, the shell remains bootable and does not guess a fallback.
|
||||
|
||||
### Add a pending-interaction domain
|
||||
|
||||
1. Define the Pending object and its completion, cancellation, and interruption semantics in the business package.
|
||||
2. Add the object to `SessionPendingInteractionMap` through declaration merging.
|
||||
3. Call `registerPendingInteraction()` once in `apply()` and register one stable composer entry.
|
||||
4. Resolve the Session from the Agent Context in the Remote waterfall listener; call `next()` when the listener cannot handle the request.
|
||||
5. When it can handle the request, create the Pending object, publish it through the publication function, await its result, and remove it in `finally`.
|
||||
6. Test concurrent keys, precedence, user cancellation, transport abort, plugin disposal, and delegation without a Session.
|
||||
|
||||
A request does not register Slots, declare child Slots, mutate the Session snapshot, or create a separate state index. Sidebar and composer both read one effective object from `useSessionPendingInteraction`.
|
||||
|
||||
### Review checks
|
||||
|
||||
- Every new source, registry contribution, listener, and cache has an explicit Cordis-fiber or Session-binding owner.
|
||||
- Every public hook traces to one React-free source; no selector is forwarded through layers only to pass arguments.
|
||||
- Every component obtains data and actions from standard props or the owning Slot's inject face.
|
||||
- Every target has defined behavior when absent, dynamically registered, and unloaded.
|
||||
- Every cross-layer import advances in the one-way Controller, adapter, renderer, component direction.
|
||||
- Each error is classified by the earliest owner that can explain its semantics; carrier errors do not leak directly as business errors.
|
||||
|
||||
## Verification
|
||||
|
||||
Tests owned by each layer pin Controller bindings and event sources, UI scopes and pending precedence, incremental Conversation assembly and View fallback, target projections, waterfall results, and renderer scope/Store lifetimes. Application-composition tests cover both the complete roster and startup without a concrete target; component tests do not replace object-layer, replay, and lifecycle tests.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep a Runtime facade.** One entry point would retain the dependency hub and let new code bypass domain owners, so the system provides neither the facade nor a compatibility export.
|
||||
- **Put all Client state in API Controllers.** Protocol objects would then own React, Views, and presentation policy, so Controllers retain only React-free domain state.
|
||||
- **Let Controllers provide React hooks directly.** Non-React consumers could not reuse the same objects, and transport and renderer lifetimes would become interdependent.
|
||||
- **Put Conversation in SessionSnapshot.** This would expand the Session API and force ordinary Session consumers to understand event folding and target rosters.
|
||||
- **Let Chat and Trajectory replay Session events independently.** Ordering, locations, and registry rebuild would be duplicated, so the shared assembly core stays in `ui-conversation`.
|
||||
- **Extract the Conversation core into another non-UI package.** The core and adapter currently evolve together and have no other non-UI package consumer; directory separation within one package keeps the core React-free.
|
||||
- **Combine Workspace and Session into one snapshot.** This would create another cross-domain owner, so cross-domain logic remains an immediate decision in `ui-workspace`.
|
||||
- **Build every standard hook into the renderer.** Generic infrastructure would need to know every domain, so standard-source registration keeps the renderer independent from business types.
|
||||
- **Dynamically register a composer entry for every pending request.** This would redeclare child Slots and make concurrent requests compete through registration order, so stable entries are separate from request publication.
|
||||
- **Write pending interactions into a Session projection.** An unanswered waterfall is not a committed durable Session fact; Remote Event replay restores it after refresh, so it remains in a business UI source.
|
||||
- **Add a dedicated release callback to bindings.** This would duplicate the Cordis lifecycle; `binding.ctx.effect()` already attaches consumer cleanup to the same owner.
|
||||
- **Pass the Session id from SessionProvider through a render function.** This would create another data-injection path; ordinary children and the standard `sessionId` prop retain one entry for scoped data.
|
||||
- **Keep Store in the renderer.** The Store contract does not depend on React and is reused by objects and test infrastructure, so `client/store` keeps the engine separate from rendering lifetimes.
|
||||
|
||||
## Consequences
|
||||
|
||||
Session, Workspace, Conversation, and each concrete target own one authoritative state. Non-React consumers can reuse Controllers and the assembly core directly. A new Conversation target registers its Definition, builder, View, standard source, and Slot entries; a new pending-interaction domain declares its type, registers its domain, and provides one stable composer entry.
|
||||
|
||||
The renderer and Session Controller gain no branch for a new business domain, while Session bindings and plugin fibers provide two explicit, composable release paths. The UI can observe independent Session and Conversation source publications, and consumers cannot depend on their notification order.
|
||||
|
||||
Composition packages must explicitly load the required adapter and target plugins. The shell remains operational without a concrete target but neither creates nor guesses that target's View. More packages and explicit registrations add assembly work, while dependency direction, test scope, and failure ownership become locally identifiable.
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
# Agent Note: Client Session、Conversation 与 UI 所有权分层
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-20-client-session-conversation-ownership.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web Client 曾由一个通用 Runtime 同时承载 Session 与 Workspace 对象、事件窗口、Conversation 组装、React hooks、Slot 注册表和 Store 引擎。协议状态、业务投影、React 绑定和页面呈现共享同一个依赖汇点,任何一层的变化都可能扩大到完整前端。
|
||||
|
||||
Session 快照也容易混入事件数组、Conversation View、Chat Node 和待处理交互等并非 Session 自身拥有的数据。普通消费者由此需要理解事件重放与具体视图,新增一个 Conversation target 也可能要求修改 Session、Runtime 和 renderer。
|
||||
|
||||
React 生命周期与 Session 生命周期之间缺少明确接口时,binding 释放、Hook source 替换和 Slot store 清理会演变为互相回调的专用协议。Approval 与 Question 同时影响侧边栏状态和 composer takeover;若两处各自维护状态,它们还可能选择不同的待处理请求。
|
||||
|
||||
需要把数据 owner、React adapter、通用渲染机制和具体视图拆成单向依赖,同时保持既有应用行为。
|
||||
|
||||
## 决定
|
||||
|
||||
Client 采用“Controller 与领域对象 → UI adapter → renderer → Slot component”的分层。Controller 和领域对象发布不依赖 React 的 observable source;所属 `ui-*` package 声明标准 props 并注册 source;`ui-renderer` 在 Slot binding 点生成 selector hook;组件只从 Slot props 读取数据与操作。
|
||||
|
||||
```text
|
||||
[Remote / Controller / domain object]
|
||||
|
|
||||
| bare observable source
|
||||
v
|
||||
[ui-* adapter]
|
||||
|
|
||||
| standard source registration
|
||||
v
|
||||
[ui-renderer]
|
||||
|
|
||||
| selector hook binding
|
||||
v
|
||||
[Slot component]
|
||||
```
|
||||
|
||||
Session 与 Workspace 的 Client 对象分别归 `api/session-controller/client` 和 `api/workspace-controller/client`。Conversation 的 target-neutral 数据结构和组装归 `client/ui-conversation`,Chat 与 Trajectory 分别归 `client/ui-chat` 和 `client/ui-trajectory`。
|
||||
|
||||
Session 与 Workspace 的 React 适配分别归 `client/ui-session` 和 `client/ui-workspace`。Store engine 归 `client/store`,Slot registry、scope materialization 和 observable-to-hook 绑定归 `client/ui-renderer`。
|
||||
|
||||
系统不提供聚合式 `client/runtime` package,也不设置替代它的总控 facade。Session history、Remote stream、分页 cursor 和重连连续性由 [Session 历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md) 定义;本 Note 从 Controller 发布的 Client 对象与 source 开始。
|
||||
|
||||
## 分层原则
|
||||
|
||||
### Controller 是无 React 的逻辑 owner
|
||||
|
||||
Controller 可以作为 Cordis service 安装,但不拥有 React Context、React hook、Slot props 或组件。Controller snapshot 只包含自身拥有的事实,命令只改变 Host 或领域对象状态。
|
||||
|
||||
UI 层可以同时读取多个 Controller 做一次导航决定,但不得把组合结果写回任一 Controller snapshot。UI adapter 也不复制 Controller 命令的业务实现。
|
||||
|
||||
### UI adapter 拥有 React 接入
|
||||
|
||||
每个标准 hook 归最接近其数据语义的 `ui-*` package。
|
||||
|
||||
| Hook | Owner | Source |
|
||||
| --- | --- | --- |
|
||||
| `useSessions` | `client/ui-session` | Session Controller 全局列表 |
|
||||
| `useSession` | `client/ui-session` | 当前 Session snapshot |
|
||||
| `useProjection` | `client/ui-session` | 当前 Session keyed projection |
|
||||
| `useSessionPendingInteraction` | `client/ui-session` | pending domain 聚合结果 |
|
||||
| `useWorkspaces` | `client/ui-workspace` | Workspace Controller 列表 |
|
||||
| `useConversation` | `client/ui-conversation` | Conversation binding snapshot |
|
||||
| `useChat` | `client/ui-chat` | `chat` target source |
|
||||
| `useTrajectory` | `client/ui-trajectory` | `trajectory` target source |
|
||||
|
||||
`ui-renderer` 只实现通用绑定,不 import Session、Workspace、Conversation、Chat 或 Trajectory 的业务类型和值。
|
||||
|
||||
### Slot scope 与标准 props 分离
|
||||
|
||||
`ui-slots` 声明 root、session 和 session-maybe scope,以及可通过 declaration merge 扩展的标准 props 类型;它不决定每个 scope 安装哪些 hook。
|
||||
|
||||
`ui-renderer` 实现通用 scope adapter 与 source materialization。`ui-session` 安装 Session scope 并提供内建 source,其他领域 package 只注册自己的 source 和消费它的 Slot entry。
|
||||
|
||||
新增 target 不要求 renderer 或 Session Controller 增加分支。数据 owner 负责状态身份、更新、错误和释放;UI adapter 负责 hook;显示 owner 负责 target-specific projection 与交互状态。
|
||||
|
||||
## Package 所有权
|
||||
|
||||
| Package | 拥有内容 | 明确不拥有 |
|
||||
| --- | --- | --- |
|
||||
| `api/session-controller/client` | Session 对象、列表、选择、命令、projection、queue、事件窗口和 Agent Context | Conversation target、React、Slot、Workspace |
|
||||
| `api/workspace-controller/client` | Workspace 对象、顺序、归档、命令和 snapshot | React、Session 导航策略、目录 UI |
|
||||
| `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 |
|
||||
| `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 |
|
||||
| `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot |
|
||||
| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details、locale 和历史图片 | Session 生命周期、通用 View 导航、Trajectory |
|
||||
| `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot |
|
||||
| `client/ui-approval` | Pending Approval、Remote listener、composer 和审批 UI | Session control、通用 composer election |
|
||||
| `client/ui-user-questions` | Pending Question、Remote listener、composer 和问题 UI | Session control、通用 composer election |
|
||||
| `client/store` | React-free store contract 与实现 | 领域对象、React hook、Slot 生命周期 |
|
||||
| `client/ui-renderer` | SlotRegistry、scope binding、selector hook、outlet 和 React root | Session、Workspace 与 Conversation 业务逻辑 |
|
||||
|
||||
## 总体数据流
|
||||
|
||||
Session 数据按以下路径进入 UI:
|
||||
|
||||
```text
|
||||
[ctx.remote.session]
|
||||
|
|
||||
v
|
||||
[api/session-controller/client]
|
||||
|-- SessionListState --------------------------> [ui-session] -> useSessions
|
||||
|-- SessionSnapshot ----------------------------> [ui-session] -> useSession
|
||||
|-- ProjectionValueSource ----------------------> [ui-session] -> useProjection
|
||||
`-- per-Session SessionEventSource
|
||||
|
|
||||
v
|
||||
[client/ui-conversation]
|
||||
|
|
||||
| assemble
|
||||
v
|
||||
ConversationSnapshot ----------------> useConversation
|
||||
|
|
||||
|---------+----------|
|
||||
v v
|
||||
[ui-chat] [ui-trajectory]
|
||||
| |
|
||||
useChat useTrajectory
|
||||
```
|
||||
|
||||
Workspace 数据从 `ctx.remote.workspace` 进入 Workspace Controller,再由 `ui-workspace` 暴露为 `useWorkspaces`;需要跨域导航时,`ui-workspace` 临时读取 Session Controller 并发出选择或命令。
|
||||
|
||||
Approval 与 Question 从 Host waterfall 经 `ctx.remote.$on` 到达各自 UI owner。Owner 发布 Pending 对象,`ui-session.pendingInteractions` 再把同一对象送往 Session 导航状态和 Conversation composer selection。
|
||||
|
||||
## Session Controller Client
|
||||
|
||||
### SessionSnapshot 的范围
|
||||
|
||||
`SessionSnapshot` 表示 Session 自身的控制与生命周期事实。它可以包含 identity、running、removed、blank、subagent address、open phase、history phase、prompt error、agent error 和 queue 状态。
|
||||
|
||||
它不包含以下数据:
|
||||
|
||||
- raw event array;
|
||||
- Conversation View;
|
||||
- Chat Node;
|
||||
- Trajectory row;
|
||||
- Approval 或 Question 的待处理对象;
|
||||
- 要求调用者遍历 event 才能解释的呈现状态。
|
||||
|
||||
字段由 event、control frame 或本地命令推导,并不自动决定其 owner;消费语义决定 owner。`composerPhase` 同时依赖 Session lifecycle 与 Conversation target activity,因此由 `ui-conversation` 合成,不进入 `SessionSnapshot`。
|
||||
|
||||
### 三个读取面
|
||||
|
||||
Session Controller 对外提供三个互不替代的读取面:
|
||||
|
||||
1. 全局 Session list 与 current selection source,供导航和 `useSessions` 使用。
|
||||
2. 每个 Session 的逻辑 binding,包含 `sessionId`、`SessionSnapshot` source、commands 与 projection sources。
|
||||
3. Conversation-facing `SessionEventSource`,只供 Conversation assemble core 使用。
|
||||
|
||||
普通 UI component 不直接读取 `SessionEventSource`。`ui-session` 不读取私有 event window,`ui-conversation` core 也不接收 React binding 或 Slot API。
|
||||
|
||||
### SessionEventSource
|
||||
|
||||
`SessionEventSource` 暴露已经物化的事件窗口,而不是 transport。
|
||||
|
||||
窗口携带有序 `entries`、`hasMore`、单调 `revision`,以及 `replace | prepend | append` 变更描述。Append 以常数时间连接不可变片段;需要完整 `entries` 数组的消费者才为该 snapshot 物化并缓存数组。
|
||||
|
||||
首次打开、重连、gap repair 和无法证明连续性的更新发布 `replace`;历史分页发布 `prepend`;连续 live event 发布 `append`。Conversation core 依据 revision 与 change 选择增量更新或完整 rebuild。
|
||||
|
||||
`MutableSessionEventSource` 是 Session Controller 内部写端,消费者只依赖只读的 `SessionEventSource`。
|
||||
|
||||
### Session binding 生命周期
|
||||
|
||||
每个 Session binding 持有自己的 Cordis Context 与 Fiber。Session Controller 创建 binding,也负责释放它。
|
||||
|
||||
依赖 Session 的对象把清理注册到 `binding.ctx.effect()`。Binding 释放会触发 Conversation binding、UI materialization 和 scoped Slot store 的清理,不存在额外的 `onBindingRelease` 或 `onRelease` 回调协议。
|
||||
|
||||
这种清理方式不要求 Session Controller 了解上层消费者名册。
|
||||
|
||||
## UI Session
|
||||
|
||||
### 服务职责
|
||||
|
||||
`client/ui-session` 是 Session Controller 与 React/Slot 系统之间唯一的 Session adapter。它提供 `ctx.uiSession`,并负责:
|
||||
|
||||
- 观察 Session list、current selection 和 per-Session binding;
|
||||
- 安装 session 与 session-maybe scope adapter;
|
||||
- 提供 `SessionProvider` 的呈现语义;
|
||||
- 内建 session snapshot、projection 和 sessionId source;
|
||||
- 接收其他领域 package 的 Session-scoped source contribution;
|
||||
- 聚合业务 package 注册的 pending interaction。
|
||||
|
||||
它不拥有 Session transport、event folding、Conversation target 或具体业务结果。
|
||||
|
||||
### 标准 source 注册
|
||||
|
||||
领域 package 调用 `ctx.uiSession.provide()` 注册 bare source。Descriptor 静态声明 hooks、keyedHooks 和 props 名册,`resolve(binding)` 为一个 Session binding 返回完全对应的值;例如 `ui-conversation` 把每个 binding 的 snapshot 注册为 `conversation` hook source。
|
||||
|
||||
普通 source 被 renderer 转换成 `use<Name>`,Projection 等开放 key 空间通过 keyed hook resolver 暴露,稳定值通过 props 暴露。
|
||||
|
||||
运行时拒绝未声明、缺失或重复的标准 prop。`ui-session` 自身也走相同 materialization,renderer 不为 Session 名字写特殊分支。
|
||||
|
||||
### Scope binding
|
||||
|
||||
session 与 session-maybe 使用同一个 adapter,但绑定语义不同:
|
||||
|
||||
- strict session scope 在没有 current binding 时拒绝渲染;
|
||||
- session-maybe 使用稳定 absent binding,保持 hook 调用顺序;
|
||||
- current Session 切换以 `sessionId` 为 key 重建严格 Session subtree;
|
||||
- root 与 session-maybe entry 可以跨 Session 切换常驻。
|
||||
|
||||
每个真实 materialized binding 保留 Controller binding 的 Context。`ui-session` 通过 `binding.ctx.effect()` 删除缓存项并撤销 current binding。
|
||||
|
||||
Contribution roster 变化会重建已 materialize 的 binding 并发布新的 source 集合。同一 binding 生命周期内,source identity 保持稳定,以满足 `useSyncExternalStore` 的缓存要求。
|
||||
|
||||
### SessionProvider
|
||||
|
||||
`SessionProvider` 是 `PropsRenderSlots` 根据 session-scoped child 声明派生的标准席,不是业务 component 直接 import 的 React Context。
|
||||
|
||||
它接收普通 `ReactNode` children,不接收 `(sessionId) => ReactNode` render function;调用方直接用它包裹 `renderSlot('details', {})`。
|
||||
|
||||
Session identity 通过 scope binding 和标准 `sessionId` prop 提供。Provider 只负责 absent branch 与按 Session identity 隔离 subtree,组件不得借助 Provider 回调取得 Session 数据。
|
||||
|
||||
### Pending interaction
|
||||
|
||||
`SessionPendingInteractionMap` 由业务 package declaration merge 扩展。每个 pending object 至少携带稳定 `key`、领域 `kind` 和 `sessionId`;`ui-session` 不 import Approval 或 Question 的具体类型。
|
||||
|
||||
业务 plugin 在 `apply()` 中调用 `registerPendingInteraction(precedence)`,为自己的 pending domain 建立稳定注册。该调用返回逐请求 publication function;publication function 同时发布精确对象及其 waterfall 委托回调,并返回移除该对象的幂等 disposer。Plugin teardown 会先移除所有已发布对象,再调用并等待其委托回调,避免 Client 回答者卸载后 Host 请求继续悬挂。
|
||||
|
||||
相同 key 的并发对象被拒绝,替换请求必须使用新 key。同一 Session 可以同时存在多个领域或多个请求。
|
||||
|
||||
`ui-session` 使用各 domain 的 precedence 选出每个 Session 当前生效的对象。较高 precedence 胜出,相同 precedence 下后遍历到的有效对象胜出。
|
||||
|
||||
聚合结果发布为 `pendingInteractions: ObservableSnapshot<ReadonlyMap<SessionId, SessionPendingInteraction>>`,`useSessionPendingInteraction` 是其 React 读取面。
|
||||
|
||||
Session 导航状态和 composer takeover 必须读取同一个 effective object,不得分别维护 status map 或 takeover roster。
|
||||
|
||||
## Workspace Controller 与 UI Workspace
|
||||
|
||||
### WorkspaceSnapshot 的范围
|
||||
|
||||
`WorkspaceSnapshot` 只包含 Workspace Controller 拥有的 Host-authoritative 数据,包括 Workspace rows、顺序、archive set、follow phase 和错误。Workspace row 的 `sessionIds` 是关联字段,不等于把 Session 对象复制进 Workspace snapshot。
|
||||
|
||||
以下组合事实不进入 `WorkspaceSnapshot`:
|
||||
|
||||
- Workspace 与 Session 两条 baseline 是否同时 ready;
|
||||
- 根据 Session 更新时间推导的最近 Workspace;
|
||||
- 当前 Session 是否因归档而清除;
|
||||
- New Session 应复用哪个 blank Session;
|
||||
- 首次启动应选择哪个 Session。
|
||||
|
||||
### UI Workspace 的组合职责
|
||||
|
||||
`client/ui-workspace` 把 Workspace list source 注册为 root 标准 source `workspaces`,renderer 由此提供 `useWorkspaces`。
|
||||
|
||||
初始选择、blank Session 复用、新建导航、并发创建合并和归档后导航属于 UI navigation policy。该 policy 可以在决定时同时读取 `ctx.workspaces` 与 `ctx.sessions`,但只调用 Controller command 和 selection action,不发布联合 snapshot。
|
||||
|
||||
目录 picker、目录浏览和 `openPath` 属于独立目录能力,不进入 Workspace Controller。
|
||||
|
||||
## UI Conversation
|
||||
|
||||
### Assemble core
|
||||
|
||||
`client/ui-conversation` 同时包含不依赖 React 的 Conversation assemble core 和同领域的 React adapter。
|
||||
|
||||
Core 拥有 `ConversationSnapshot`、Definition registry、View registry、event assembler、location index、每 Session binding、target source 和 target activity。
|
||||
|
||||
Core 从 Session binding 取得 `SessionEventSource`。连续 revision 的 append 与 prepend 使用增量组装;replace 或 revision 断档从完整窗口 rebuild。
|
||||
|
||||
Definition 或 View roster 变化只重建 Conversation binding,不重建 Session 或重开 Remote stream。Core 不 import React,可独立测试事件折叠、增量更新和 registry lifecycle。
|
||||
|
||||
`ConversationSnapshot` 不复制 `SessionSnapshot`,也不暴露 raw events;它只发布 target-neutral 的 View 名册、target activity 和 target source lookup。
|
||||
|
||||
`useSession` 与 `useConversation` 来自两个 source,不承诺在同一个 React commit 原子发布。同时读取两者的组件按当前 snapshot 纯计算,不把通知顺序解释为业务因果。
|
||||
|
||||
### Definition 与 View registry
|
||||
|
||||
`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。
|
||||
|
||||
Registry 拒绝重复 key,保持注册顺序并返回幂等 disposer。Roster 变化时,现有 Conversation binding 使用当前 event window 重建;同一同步注册轮次中的变化会合并为一次 microtask 重建。
|
||||
|
||||
Target package 通过 declaration merge 扩展 snapshot 与 location data map,再向 registry 注册自己的 Definition、builder 和 View。注册随 Cordis effect 释放。
|
||||
|
||||
`ui-conversation` 不 import 具体 target package。
|
||||
|
||||
### Conversation React adapter
|
||||
|
||||
React adapter 把每个 Conversation binding 的 snapshot 注册为 Session 标准 source `conversation`,renderer 由此提供 `useConversation`。
|
||||
|
||||
同包还拥有 shell、input、composer chain、queue UI、draft、View navigation 和 phase 合成;Core 不读取 React Context、Slot props 或 component state。
|
||||
|
||||
View 选择顺序固定为:有效的持久化 selection、已注册的 `chat`、无 View。无效 selection 不覆盖持久化值,系统不 fallback 到第一个已注册 View。
|
||||
|
||||
没有 `ui-chat` 时 shell 仍能激活和 mount,但不会隐式选择 Trajectory 或其他 target。
|
||||
|
||||
Shell phase 由 Session lifecycle 与 Conversation target activity 纯合成。Session 已 active 或任一 target 报告可见内容时显示 active;首条 prompt 失败仍保持 engaging。
|
||||
|
||||
### Input 与 composer
|
||||
|
||||
Composer chain 属于 `ui-conversation`,具体 takeover 属于业务 package。`ConversationRoot` 从 `useSessionPendingInteraction` 读取当前 Session 的 effective object,并作为 `ComposerChainProps.pendingInteraction` 交给 chain selector。
|
||||
|
||||
Selector 是 owner currency 的纯函数,非 null 结果作为 `matched` 传给获选 component。Stable composer entry 与默认 composer 可以同时常驻,chain 只选择一个有效呈现。
|
||||
|
||||
Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue command 通过 Session-scoped service 寻址,不把 queue UI 写入 Conversation core。
|
||||
|
||||
## Chat 与 Trajectory target
|
||||
|
||||
### Chat owner
|
||||
|
||||
`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale、tool inspection 协作和历史图片 cache。
|
||||
|
||||
它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。
|
||||
|
||||
Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。
|
||||
|
||||
历史图片 cache 的 Session key、pending promise、generation guard、blob URL 和 disposer 同属 `ui-chat`;Draft 图片仍属于 Conversation input。
|
||||
|
||||
### Trajectory owner
|
||||
|
||||
`client/ui-trajectory` 通过相同 target 协议注册 `trajectory`。它拥有事件记录、时间线、虚拟行、selection 和 inspection view,并通过标准 source 提供 `useTrajectory`。
|
||||
|
||||
Session 生命周期读取 `useSession`,Trajectory 数据读取 `useTrajectory`。Trajectory 不通过 Session snapshot 或 Chat snapshot 取得自己的数据。
|
||||
|
||||
其他 target 使用同一注册流程,不修改 renderer、Session Controller 或 ui-session。
|
||||
|
||||
## Approval 与 Question
|
||||
|
||||
### 稳定注册
|
||||
|
||||
Approval 和 Question 的 plugin 安装分为稳定注册与单次请求处理。`apply()` 注册 locale、调用 `registerPendingInteraction()` 注册本领域 pending domain,并向 `conversation.composer` 注册唯一稳定 entry。
|
||||
|
||||
Approval 的 detail child Slot 也由稳定 entry 声明。并发请求和 Session 数量不会增加 composer entry 或重复声明 Slot,所有注册随 plugin fiber 释放。
|
||||
|
||||
### 单次 waterfall 请求
|
||||
|
||||
Remote Event listener 从自身 Agent Context 解析 Session。没有 Session scope 时调用 `next()` 继续 waterfall;存在 Session scope 时创建 `PendingApproval` 或 `PendingQuestion`。
|
||||
|
||||
Listener 通过已注册 domain 的 publication function 发布对象,等待用户完成、取消或请求 signal 中止,并在 `finally` 中精确移除对象。
|
||||
|
||||
单次请求不注册 Slot,不创建第二套 lifecycle effect,也不修改 Session snapshot。
|
||||
|
||||
Approval 暴露 allow 与 reject,Question 暴露 answer 与 cancel。用户主动取消 Question 返回 `ASK_CANCELLED`;等待中的请求被 `AbortSignal` 中止时返回 `UserQuestionError(ASK_ABORTED)`,不泄漏载体的 `AbortError` 或普通 `Error`。
|
||||
|
||||
Gateway 只要求 Remote Event 参数和结果是合法 JSON 传输值,不复制 Question 选项的领域校验。
|
||||
|
||||
### 单一 pending 投影
|
||||
|
||||
Sidebar 与 composer 使用相同 `pendingInteractions` snapshot。导航根据 effective object 的 `kind` 显示审批、计划审阅或问题状态,composer entry 根据对象实例选择自己的面板。
|
||||
|
||||
同一请求 identity 同时驱动两处 UI。新请求替换同类型旧请求时使用新 key,因此 selector 与订阅者都观察到身份变化。
|
||||
|
||||
`ui-session` 只实现跨领域 precedence,不解释 Approval 或 Question 的字段。
|
||||
|
||||
## UI Renderer 与 Store
|
||||
|
||||
### UI Renderer
|
||||
|
||||
`client/ui-renderer` 拥有 `SlotRegistry` service 和 React renderer。它负责:
|
||||
|
||||
- `ctx.slots.register()`、`inject()`、`renderSlot()` 与声明生命周期;
|
||||
- root、session 和 session-maybe scope adapter;
|
||||
- 标准 observable source 到 selector hook 的绑定;
|
||||
- Slot outlet、错误隔离、root mount 与 hydration;
|
||||
- 按 scope key 管理 Slot store instance 生命周期。
|
||||
|
||||
Renderer 可以认识通用 scope 名称和 binding 协议,但不读取领域 service。渲染 Session scope 而没有安装 adapter 是装配错误,并立即失败。
|
||||
|
||||
### Store
|
||||
|
||||
`client/store` 是 React-free 普通库,拥有 `ObservableSnapshot`、`SnapshotStore`、`defineStore`、`createSnapshotStore` 和 `shallowEqual`。
|
||||
|
||||
`ui-slots` 引用 store contract,`ui-renderer` 管理 store instance 并提供 `useStore`。
|
||||
|
||||
Store 只承载 draft、View selection、Chat selection、inspection request 和面板尺寸等观看或交互状态。Session、Workspace、Conversation、Remote stream 和 connection generation 不进入 Store。
|
||||
|
||||
### 注册与释放顺序
|
||||
|
||||
一个 plugin 同时提供 source 与 Slot entry 时,先注册 source,再注册 entry。Cordis 反向 disposal 先移除 entry,再移除 source,仍挂载的 entry 因而不会短暂失去必需 hook。
|
||||
|
||||
Session binding 释放通过 `binding.ctx.effect()` 清理 UI materialization 与 scoped store。Plugin fiber 释放通过 registration disposer 清理 source、listener 和 Slot entry。
|
||||
|
||||
所有 disposer 都可重复调用,不依赖 Cordis 生命周期以外的隐式回调。
|
||||
|
||||
## 组合与依赖方向
|
||||
|
||||
应用 bundle 显式安装所需 Controller、adapter、target 和 renderer plugin。每个 owner 的 `apply()` 只安装自己的 service、listener 和 contribution。
|
||||
|
||||
运行时消费方向是 `session-controller → ui-session → ui-conversation → target UI`、`workspace-controller → ui-workspace` 和 `store → ui-slots → ui-renderer`;Approval 与 Question 只依赖 `ui-session` 提供的 pending 注册点。
|
||||
|
||||
图中的箭头表示运行时消费关系,不覆盖 type-only declaration merge 边。Controller 不反向依赖 UI adapter,renderer 不反向依赖领域 package,Conversation core 不依赖具体 target。
|
||||
|
||||
UI component 不接收 `ctx`。跨 package 协作使用 Cordis service、standard source 或 Slot registration,不新增聚合 facade。
|
||||
|
||||
## 开发者遵循方式
|
||||
|
||||
### 先确定数据 owner
|
||||
|
||||
新增状态前先按消费语义确定唯一 owner:Host 通信、命令和实体生命周期归 API Controller;由 Session events 形成且与 target 无关的数据归 Conversation core;只服务一种 View 的投影归对应 target package;草稿、选择和面板状态归拥有该交互的 UI package。
|
||||
|
||||
同一事实不得同时保存在 Controller snapshot、Conversation snapshot 和 Store。需要跨域决策时读取多个 source 并立即发出 command,不创建联合 snapshot,也不缓存另一领域的对象副本。
|
||||
|
||||
以下信号表示 owner 选择错误:Controller 开始 import React;renderer 出现业务类型分支;组件遍历 Session events;Store 保存 Session 或 Workspace 实体;一个 target 的变化要求修改 Session Controller。
|
||||
|
||||
### 新增 Session-scoped 数据
|
||||
|
||||
1. 在领域 owner 中提供 React-free observable source。
|
||||
2. 在所属 UI adapter 中 declaration-merge 标准 prop 类型。
|
||||
3. 通过 `ctx.uiSession.provide()` 声明固定 roster,并从 Session binding 解析 source。
|
||||
4. 让 Slot component 从 `PropsRuntime` 获得生成的 hook,不向组件传 `ctx`。
|
||||
5. 把每个 binding 的资源清理挂到 `binding.ctx.effect()`,把 registration 清理留给 plugin fiber。
|
||||
6. 测试缺失值、重复名字、roster 替换、Session 切换和 binding disposal。
|
||||
|
||||
只有开放 key 空间使用 keyed hook;有限且稳定的 source 使用普通 hook;不会变化的标识使用 prop。不得为了减少一次注册而把业务名称硬编码进 renderer。
|
||||
|
||||
### 新增 Conversation target
|
||||
|
||||
1. 在 target package 中扩展 Conversation snapshot 或 location data map。
|
||||
2. 向 `UiConversation.events` 注册所需 event Definition。
|
||||
3. 向 `UiConversation.views` 注册 snapshot builder、target id、View 与 activity 规则。
|
||||
4. 通过 `ctx.uiSession.provide()` 暴露该 target 的标准 selector hook。
|
||||
5. 在同一 package 中注册 renderer、locale 和 target-specific Slot entry。
|
||||
6. 验证 target 卸载只重建 Conversation binding,不改变 Session、其他 target 或 Remote stream。
|
||||
|
||||
Target 不得读取另一个 target 的 snapshot 作为自己的数据源。可选协作通过窄 port 或 Slot 完成;缺失 target 时,shell 必须保持可启动且不得猜测 fallback。
|
||||
|
||||
### 新增 pending-interaction 业务
|
||||
|
||||
1. 业务 package 定义 Pending 对象及其完成、取消和中止语义。
|
||||
2. 通过 declaration merge 把对象加入 `SessionPendingInteractionMap`。
|
||||
3. 在 `apply()` 中调用 `registerPendingInteraction()` 一次,并注册唯一稳定的 composer entry。
|
||||
4. Remote waterfall listener 从 Agent Context 解析 Session;无法处理时调用 `next()`。
|
||||
5. 可处理时创建 Pending 对象,使用 publication function 发布,等待结果,并在 `finally` 中移除。
|
||||
6. 测试并发 key、precedence、用户取消、transport abort、plugin disposal 和无 Session delegation。
|
||||
|
||||
单次请求不得注册 Slot、声明 child Slot、修改 Session snapshot 或另建状态索引。Sidebar 与 composer 都从 `useSessionPendingInteraction` 读取同一个 effective object。
|
||||
|
||||
### Review 检查点
|
||||
|
||||
- 每个新 source、registry contribution、listener 和 cache 都有明确 Cordis fiber 或 Session binding owner。
|
||||
- 每个公共 hook 能追溯到唯一 React-free source;不存在只为传参而层层转发的 selector。
|
||||
- 每个 component 的数据与 action 都来自标准 props 或所属 Slot inject face。
|
||||
- 每个 target 在缺席、动态注册和卸载时都有定义明确的结果。
|
||||
- 每个跨层 import 都沿 Controller、adapter、renderer、component 的单向关系前进。
|
||||
- 每个错误由最早能解释其语义的 owner 归类;载体错误不直接泄漏成业务错误。
|
||||
|
||||
## 验证
|
||||
|
||||
各 owner 的测试分别固定 Controller binding 与 event source、UI scope 与 pending precedence、Conversation 增量组装与 View fallback、target projection、waterfall 结果以及 renderer 的 scope/store 生命周期。应用组装测试同时覆盖完整 roster 和缺少具体 target 的启动;组件测试不替代对象层、重放和生命周期测试。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **保留 Runtime facade。** 它维持单一入口,却继续形成依赖汇点并允许新代码绕过领域 owner;系统因此不保留 facade 或兼容出口。
|
||||
- **把所有 Client 状态放进 API Controller。** 这会让协议对象承担 React、View 和 presentation policy;Controller 因而只保留无 React 的领域状态。
|
||||
- **让 Controller 直接提供 React hooks。** 这会阻止非 React 消费者复用同一对象,也使 transport 与 renderer 生命周期相互依赖。
|
||||
- **把 Conversation 放进 SessionSnapshot。** 这会扩大 Session API,并迫使普通 Session 消费者理解 event folding 与 target roster。
|
||||
- **让 Chat 和 Trajectory 各自重放 Session events。** 这会重复维护顺序、location 和 registry rebuild;共享 assemble core 因而留在 `ui-conversation`。
|
||||
- **把 Conversation core 拆成额外的非 UI package。** Core 与 adapter 当前共同演化且没有其他非 UI package 消费者;同包目录隔离足以保持 React-free core。
|
||||
- **把 Workspace 与 Session 合成联合 snapshot。** 这会制造新的跨域 owner;跨域逻辑保留为 `ui-workspace` 的即时决策。
|
||||
- **让 renderer 内建所有标准 hook。** 这会要求通用基础设施认识每个领域;standard source registration 保持 renderer 与业务类型解耦。
|
||||
- **让每个 pending 请求动态注册 composer entry。** 这会重复声明 child Slot,并让并发请求竞争注册顺序;稳定 entry 与请求期对象发布保持分离。
|
||||
- **把 pending interaction 写回 Session projection。** 待回答 waterfall 不是已提交的持久 Session 事实,刷新恢复由 Remote Event replay 负责,因此它留在业务 UI source。
|
||||
- **为 binding 增加专用 release callback。** 这会重复 Cordis 生命周期;`binding.ctx.effect()` 已能把消费者清理挂到同一 owner。
|
||||
- **让 SessionProvider 通过 render function 传 Session id。** 这会产生另一条数据注入路径;普通 children 与标准 `sessionId` prop 保持 scope 数据只有一个入口。
|
||||
- **把 Store 留在 renderer。** Store contract 不依赖 React,并被对象与测试基础设施复用;独立 `client/store` 保持 engine 与渲染生命周期分离。
|
||||
|
||||
## 后果
|
||||
|
||||
Session、Workspace、Conversation 与具体 target 各自拥有一份权威状态,非 React consumer 可以直接复用 Controller 和 assemble core。新增 Conversation target 只需注册 Definition、builder、View、标准 source 和 Slot entry;新增 pending-interaction 业务只需声明类型、注册 domain 并提供稳定 composer entry。
|
||||
|
||||
Renderer 和 Session Controller 不因新增业务领域而增加分支,Session binding 与 plugin fiber 则提供两条明确且可组合的释放路径。UI 可以观察到 Session 与 Conversation source 的独立发布,消费者不得依赖二者的通知顺序。
|
||||
|
||||
组合包必须显式装载所需 adapter 与 target plugin。缺失具体 target 时 shell 仍可运行,但不会生成或猜测该 target 的 View。更多 package 和显式注册增加了装配工作,但依赖方向、测试范围与故障 owner 均可局部确定。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md
|
||||
2026-08-21-deepseek-llm-api-request-extensions.md: d83b53adcfbf41f9addde0b7d4ac9ab1a8572d2f
|
||||
2026-08-21-deepseek-llm-api-request-extensions.zh.md: 47d7ce574c189f8d71d28963910e948c86169198
|
||||
2026-08-21-deepseek-llm-api-request-extensions.md: 018b93115f5376affd86a4da3c76f0f367ba9ed0
|
||||
2026-08-21-deepseek-llm-api-request-extensions.zh.md: 4bc0f0c992445c5897069b68efa47fdba46dfdb4
|
||||
|
||||
+40
-11
@@ -1,4 +1,4 @@
|
||||
# Agent Note: DeepSeek LLM API request extensions for plugin package metadata
|
||||
# Agent Note: DeepSeek LLM API request extensions for session logs and plugin packages
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,17 +6,25 @@ English | [中文](2026-08-21-deepseek-llm-api-request-extensions.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Provider-side diagnosis needs the exact active plugin package versions that produced an official DeepSeek request. The existing browser-facing plugin inventory reports configured Loader rows and lifecycle phases but owns neither package-manifest resolution nor the requesting agent's standing preset composition.
|
||||
The canonical Session log contains request boundaries, raw response chunks, assembled messages, tool activity, plugin events, and failure facts that the model message list does not preserve. The OTel session-telemetry path projects and batches that log independently of model requests, uses deployment-selected sharing modes, and intentionally drops most assistant chunks. DeepSeek's official API therefore cannot reconstruct the complete harness trajectory from its ordinary request messages or the telemetry feed.
|
||||
|
||||
This metadata belongs only on the official DeepSeek adapter path. Adding it to `GenerateOptions` or the provider-neutral LLM seam would expose a DeepSeek wire concept to pi-ai and every future adapter.
|
||||
Provider-side diagnosis also needs the exact active plugin package versions that produced a request. The existing browser-facing plugin inventory reports configured Loader rows and lifecycle phases but owns neither package-manifest resolution nor the requesting agent's standing preset composition.
|
||||
|
||||
The adapter also needs one plugin-owned extension point. Importing Loader, preset, and package-manifest logic directly into `llm-deepseek` would make the transport own metadata discovery and prevent independent request fields from evolving as plugins.
|
||||
Both values belong only on the official DeepSeek adapter path. Adding them to `GenerateOptions` or the provider-neutral LLM seam would expose DeepSeek wire concepts to pi-ai and every future adapter.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects preparation or base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Acceptance failures remain request failures under `REQUEST_EXTENSION`; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter.
|
||||
`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects preparation or base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Acceptance failures remain request failures under `REQUEST_EXTENSION`; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter. Shipped compositions mount the registry and both contributors: package metadata is enabled by default, while Session-log upload is disabled by default and requires `session-log-deepseek.enabled: true`. Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes.
|
||||
|
||||
Shipped compositions mount the registry and the default-on plugin-package contributor. Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes. The provider-neutral `llm` package and `llm-pi-ai` contain no extension type, service lookup, field merge, or acceptance call.
|
||||
The provider-neutral `llm` package and `llm-pi-ai` contain no extension type, service lookup, field merge, or acceptance call.
|
||||
|
||||
## Incremental session-log field
|
||||
|
||||
`@deepseek-ai/dsh-session-log-deepseek` owns `dsh_session_log` as an explicit opt-in. When enabled, each request carrying a live Session id sends the contiguous canonical event suffix after the greatest durable `session-log-deepseek/delivery-accepted` watermark for that same Session identity. The field includes the immutable Session header and complete event envelopes. A 2xx appends a new watermark for the transmitted `throughSeq`; that event enters the following request's suffix. Forked logs retain parent watermark ids, so a child starts from sequence zero under its own identity. Concurrent acceptances may arrive out of order, and the maximum watermark remains authoritative. A process-local fold scans each Session event once and incrementally consumes later appends; a new Session object or HMR generation rebuilds the fold from durable history.
|
||||
|
||||
The failure direction is at least once. A transport or provider rejection records no watermark. A crash after remote acceptance but before the watermark persists causes replay after resume, never a skipped sequence. Existing session checkpoints persist the event; the upload plugin owns no second store.
|
||||
|
||||
The `events` array contains complete canonical `SessionEvent` objects directly. The sender copies every present event member without projection or redaction; the field is self-contained and requires no reconstruction against `messages`.
|
||||
|
||||
## Plugin package field
|
||||
|
||||
@@ -42,13 +50,32 @@ The process-lifetime manifest-identity cache remains separate because in-process
|
||||
|
||||
## Verification
|
||||
|
||||
Registry tests pin duplicate ownership, effect-scoped disposal, detached field values, concurrent and abortable preparation, receiver-preserving acceptance, one acceptance settlement, and failure aggregation. Package-inventory tests pin default-on and explicit-off policies, host and standing-preset discovery, conflicting Loader resolution bases, manifest resolution, lifecycle filtering, and exact name/version ordering. The direct adapter mock proves pre-HTTP preparation failure, cancellation, non-2xx non-acceptance, 2xx acceptance before a later stream failure, and field collision. Keyless replay pins post-2xx extension acceptance, real Loader composition inspects the default metadata field, one credentialed real-API request mounts the production contributor, and pi-ai tests retain their unchanged wire requests.
|
||||
Registry tests pin duplicate ownership, effect-scoped disposal, detached field values, concurrent and abortable preparation, receiver-preserving acceptance, one acceptance settlement, and failure aggregation. Session tests pin the default-off policy, explicit full-first/suffix-later delivery, direct complete event envelopes independent of base-body messages, incremental watermark folding, persisted restart recovery, fork identity fencing, out-of-order acceptance, and late invariant loading. Package-inventory tests pin default-on and explicit-off policies, host and standing-preset discovery, conflicting Loader resolution bases, manifest resolution, lifecycle filtering, and exact name/version ordering. The direct adapter mock proves pre-HTTP preparation failure, cancellation, non-2xx non-acceptance, 2xx acceptance before a later stream failure, and field collision. Keyless replay pins post-2xx extension acceptance, and the TypeScript JSON-RPC plus Python packaged-runtime snapshots project the acceptance event through both SDKs. Real Loader composition pins default package metadata plus opt-in Session upload, one real-API request mounts both shipped extensions and proves the official endpoint accepts them, and pi-ai tests retain their unchanged wire requests.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Add generic metadata to `GenerateOptions` or `ctx.llm`.** Rejected because the value and acceptance timing are DeepSeek wire semantics; a provider-neutral request would make every adapter understand or ignore a foreign field.
|
||||
**Add generic metadata to `GenerateOptions` or `ctx.llm`.** Rejected because the values and acceptance timing are DeepSeek wire semantics; a provider-neutral request would make every adapter understand or ignore foreign fields.
|
||||
|
||||
**Hard-wire package discovery into `llm-deepseek`.** Rejected because the adapter would import Loader, preset, and package-manifest logic. The registry keeps transport responsible only for field merge and HTTP acceptance.
|
||||
**Hard-wire the two producers into `llm-deepseek`.** Rejected because the adapter would import Session, Loader, preset, package-manifest, and cursor logic. The registry keeps transport responsible only for field merge and HTTP acceptance.
|
||||
|
||||
### Why not request-relative message references?
|
||||
|
||||
A recursive tagged representation could replace exact event-string ranges with paths and UTF-8 byte offsets into the containing request's `messages`. Measurement used Node v24.16.0 on macOS arm64 and the three largest available local Zstandard Session artifacts, whose compressed artifact sizes were 2,437,052, 572,602, and 118,811 bytes. Late-enable replay used each final completed request boundary; steady replay covered 411 completed boundaries. The byte counts cover complete minified DeepSeek requests.
|
||||
|
||||
| Replay | Raw JSON | Referenced JSON | Saving | Synchronous encoder time |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Late enable | 29,668,725 B | 27,645,825 B | 6.82% | 500.1 s total |
|
||||
| Steady state | 389,295,815 B | 387,180,848 B | 0.54% | 285.0 s total |
|
||||
|
||||
The three late-enable calls took 470.5, 29.4, and 0.158 seconds. Only 701 of 115,071 events (0.61%) selected references. A hypothetical level-6 whole-request gzip comparison reduced raw request bytes by 89.38% for late enable and 73.42% for steady state; message references added 21.68% and 0.59% respectively after gzip.
|
||||
|
||||
The receiver would also need to traverse the tagged tree, resolve paths into the exact request messages, validate UTF-8 ranges, and reconstruct every referenced event. Even treating that receiver cost as zero, the steady-state byte saving, synchronous sender cost, and dependence on another request field do not justify a versioned wire format.
|
||||
|
||||
### Why not omit assistant chunks or overlapping event data?
|
||||
|
||||
About 98% of the measured real-session events were `assistant/chunk`. Omitting chunks after reference encoding reduced the complete identity JSON by another 84.79% for late enable and 6.49% for steady state, but it prevents lossless canonical-log reconstruction and leaves `assistant/message.sourceEventSeqs` pointing to absent events. Fuzzy or normalized substitutions have the same reconstruction defect.
|
||||
|
||||
**Keep the upload cursor only in memory.** Rejected because a normal process restart would resend the entire Session. A canonical acceptance event makes restart recovery best-effort durable without another storage backend; the remaining crash window produces allowed duplicates.
|
||||
|
||||
**Inventory every live Cordis fiber.** Rejected because programmatic and in-memory fibers have no authoritative npm package provenance. Loader-backed host and preset entries provide exact resolvable package identity.
|
||||
|
||||
@@ -58,6 +85,8 @@ Registry tests pin duplicate ownership, effect-scoped disposal, detached field v
|
||||
|
||||
## Consequences
|
||||
|
||||
Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. The field is model-hidden and adds no prompt tokens or KV-cache changes. Manifest resolution, field collision, acceptance handling, or provider schema rejection fails the model request rather than silently dropping metadata.
|
||||
Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. An explicit Session-log opt-in also carries the complete newly unaccepted Session suffix. The fields are model-hidden and add no prompt tokens or KV-cache changes, but can substantially increase HTTP body size. Manifest resolution, field collision, acceptance logging, or provider schema rejection fails the model request rather than silently dropping metadata.
|
||||
|
||||
Direct calls without a live Agent still carry the host package inventory. The [DeepSeek request-identity decision](../feature/2026-08-11-deepseek-request-user-id-header.md) continues to own user/session headers, which remain outside the body.
|
||||
The `delivery-accepted` event becomes part of the canonical log and is itself delivered on a later request. Crash recovery can duplicate a suffix but does not infer acceptance from assistant output or create a second local cursor store. Direct calls without a live Session omit the session field; host package inventory remains available.
|
||||
|
||||
The [DeepSeek request-identity decision](../feature/2026-08-11-deepseek-request-user-id-header.md) continues to own user/session headers, which remain outside the body. The [session-telemetry decision](../feature/2026-07-23-session-telemetry-otel-revival.md) remains current until a separate change removes that seam and backend; this request path does not alter OTel capture or sharing modes.
|
||||
|
||||
+41
-12
@@ -1,4 +1,4 @@
|
||||
# Agent Note: DeepSeek LLM API 插件包元数据请求扩展
|
||||
# Agent Note: DeepSeek LLM API 会话日志与插件包请求扩展
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,17 +6,25 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
提供方侧诊断需要产生一条 DeepSeek 官方请求的确切存活插件包版本。现有面向浏览器的插件清单会报告已配置 Loader 配置项与生命周期阶段,但既不拥有包 manifest(元数据清单)解析,也不拥有请求 Agent 的 standing preset 组合。
|
||||
权威会话日志包含请求边界、原始响应分片、组装后消息、工具活动、插件事件与失败事实,模型消息列表无法保留全部内容。OTel 会话遥测路径独立于模型请求投影和批处理该日志,使用部署方选择的共享模式,并刻意丢弃大多数 assistant 分片。因此,DeepSeek 官方 API 无法从普通请求消息或遥测流重建完整 harness 轨迹。
|
||||
|
||||
该元数据只属于 DeepSeek 官方适配器路径。把它加入 `GenerateOptions` 或提供方无关的 LLM seam,会让 pi-ai 与未来每个适配器接触 DeepSeek 协议概念。
|
||||
提供方侧诊断还需要产生当前请求的确切存活插件包版本。现有面向浏览器的插件清单会报告已配置 Loader 配置项与生命周期阶段,但既不拥有包 manifest(元数据清单)解析,也不拥有请求 Agent 的 standing preset 组合。
|
||||
|
||||
适配器还需要一个由插件拥有的扩展点。若 `llm-deepseek` 直接导入 Loader、preset 与包 manifest 逻辑,传输层就会拥有元数据发现,并阻止独立请求字段作为插件分别演进。
|
||||
两个值都只属于 DeepSeek 官方适配器路径。把它们加入 `GenerateOptions` 或提供方无关的 LLM seam,会让 pi-ai 与未来每个适配器接触 DeepSeek 协议概念。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝准备失败或基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。即使贡献方忽略信号,注册表也会在取消后停止等待准备。接受失败仍以 `REQUEST_EXTENSION` 使请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。
|
||||
`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝准备失败或基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。即使贡献方忽略信号,注册表也会在取消后停止等待准备。接受失败仍以 `REQUEST_EXTENSION` 使请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。随附组合会挂载注册表与两个贡献方:插件包元数据默认开启,会话日志上传默认关闭,需要设置 `session-log-deepseek.enabled: true`。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。
|
||||
|
||||
随附组合会挂载注册表与默认开启的插件包贡献方。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。提供方无关的 `llm` 包与 `llm-pi-ai` 不包含任何扩展类型、服务查找、字段合并或接受调用。
|
||||
提供方无关的 `llm` 包与 `llm-pi-ai` 不包含任何扩展类型、服务查找、字段合并或接受调用。
|
||||
|
||||
## 增量会话日志字段
|
||||
|
||||
`@deepseek-ai/dsh-session-log-deepseek` 以显式选择启用的方式拥有 `dsh_session_log`。启用后,每个携带存活会话 id 的请求都会发送该确切会话身份最大持久 `session-log-deepseek/delivery-accepted` 水位之后的连续权威事件后缀。该字段包含不可变会话 header 与完整事件信封。2xx 会为已发送的 `throughSeq` 追加新水位;该事件会进入下一次请求的后缀。Fork 日志会保留父级水位 id,因此子会话会在自己的身份下从序列零开始。并发接受可能乱序到达,最大水位仍保持权威。进程内 fold 会让每条会话事件只被扫描一次,并增量消费后续追加;新的会话对象或 HMR generation 会从持久历史重建该 fold。
|
||||
|
||||
失败方向为至少一次。传输失败或提供方拒绝不会记录水位。远端接受后、水位持久化前发生崩溃,会在恢复后触发重放,绝不会跳过序列。现有会话检查点会持久化该事件;上传插件不拥有第二份存储。
|
||||
|
||||
`events` 数组会直接包含完整的权威 `SessionEvent` 对象。发送方会复制事件的每个已有成员,不执行投影或脱敏;该字段自包含,无需根据 `messages` 重建内容。
|
||||
|
||||
## 插件包字段
|
||||
|
||||
@@ -42,15 +50,34 @@ Status: implemented
|
||||
|
||||
## 验证
|
||||
|
||||
注册表测试固定重复所有权、effect 作用域 dispose(资源释放)、分离字段值、并发且可取消的准备、保留接收者的接受操作、单次接受结算与失败聚合。插件包清单测试固定默认开启与显式关闭策略、宿主与 standing preset 发现、冲突的 Loader 解析基址、manifest 解析、生命周期过滤及确切名称/版本排序。直接适配器 mock 测试证明 HTTP 前准备失败、取消、非 2xx 不接受、2xx 在后续流失败前接受,以及字段冲突。无密钥回放固定 2xx 后扩展接受,真实 Loader 组合检查默认元数据字段,一个带凭据的真实 API 请求会挂载生产贡献方;pi-ai 测试保持其协议请求不变。
|
||||
注册表测试固定重复所有权、effect 作用域 dispose(资源释放)、分离字段值、并发且可取消的准备、保留接收者的接受操作、单次接受结算与失败聚合。会话测试固定默认关闭策略、显式启用后的首次完整/后续后缀交付、与基础正文消息无关的直接完整事件信封、增量水位 fold、持久化重启恢复、fork 身份围栏、乱序接受与 invariant 延迟加载。插件包清单测试固定默认开启与显式关闭策略、宿主与 standing preset 发现、冲突的 Loader 解析基址、manifest 解析、生命周期过滤及确切名称/版本排序。直接适配器 mock 测试证明 HTTP 前准备失败、取消、非 2xx 不接受、2xx 在后续流失败前接受,以及字段冲突。无密钥回放会固定 2xx 后扩展接受,TypeScript JSON-RPC 与 Python 打包运行时快照则通过两套 SDK 投影接受事件。真实 Loader 组合会固定默认包元数据与显式启用的会话上传,一个真实 API 请求会挂载两个随附扩展并证明官方端点接受它们;pi-ai 测试保持其协议请求不变。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**向 `GenerateOptions` 或 `ctx.llm` 添加通用元数据。** 已否决,因为该值与接受时点属于 DeepSeek 协议语义;提供方无关请求会迫使每个适配器理解或忽略外来字段。
|
||||
**向 `GenerateOptions` 或 `ctx.llm` 添加通用元数据。** 已否决,因为这些值与接受时点属于 DeepSeek 协议语义;提供方无关请求会迫使每个适配器理解或忽略外来字段。
|
||||
|
||||
**把包发现硬编码进 `llm-deepseek`。** 已否决,因为适配器将导入 Loader、preset 与包 manifest 逻辑。注册表让传输只负责字段合并与 HTTP 接受。
|
||||
**把两个提供方硬编码进 `llm-deepseek`。** 已否决,因为适配器将导入会话、Loader、preset、包 manifest 与游标逻辑。注册表让传输只负责字段合并与 HTTP 接受。
|
||||
|
||||
**清点每个存活 Cordis fiber。** 已否决,因为编程式与内存 fiber 没有权威 npm 包来源。Loader 支撑的宿主与 preset 配置项能提供可精确解析的包身份。
|
||||
### 为什么不使用请求相对消息引用?
|
||||
|
||||
一种递归的带标签表示可以用所属请求 `messages` 中的路径与 UTF-8 字节偏移,替换事件字符串的确切范围。测量使用 Node v24.16.0、macOS arm64 与可用的三份最大本地 Zstandard 会话产物;其压缩产物大小分别为 2,437,052、572,602 与 118,811 字节。延迟启用回放使用各会话最后一个已完成请求边界;稳态回放覆盖 411 个已完成边界。字节数覆盖完整且最小化的 DeepSeek 请求。
|
||||
|
||||
| 回放方式 | 原始 JSON | 引用 JSON | 节省比例 | 同步编码器耗时 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 延迟启用 | 29,668,725 B | 27,645,825 B | 6.82% | 合计 500.1 s |
|
||||
| 稳态 | 389,295,815 B | 387,180,848 B | 0.54% | 合计 285.0 s |
|
||||
|
||||
三次延迟启用调用分别耗时 470.5、29.4 与 0.158 秒。115,071 个事件中只有 701 个(0.61%)选择引用。一项假设采用 level-6 整请求 gzip 的对照,使原始请求字节在延迟启用场景减少 89.38%,在稳态场景减少 73.42%;加入消息引用后,gzip 结果分别额外减少 21.68% 与 0.59%。
|
||||
|
||||
接收方还需要遍历带标签树、解析通向确切请求消息的路径、校验 UTF-8 范围,并重建每个引用事件。即使把接收方成本视为零,稳态字节节省、发送方同步成本以及对另一请求字段的依赖,也不足以支撑带版本的协议格式。
|
||||
|
||||
### 为什么不省略 assistant 分片或重叠事件数据?
|
||||
|
||||
实测真实会话事件中约 98% 为 `assistant/chunk`。在引用编码后省略分片,会让完整未压缩 JSON 在延迟启用场景进一步减少 84.79%,在稳态场景进一步减少 6.49%,但这会阻止权威日志的无损重建,并让 `assistant/message.sourceEventSeqs` 指向缺失事件。模糊替换或规范化替换也存在同一重建缺陷。
|
||||
|
||||
**只在内存中保留上传游标。** 已否决,因为普通进程重启会重发完整会话。权威接受事件让重启恢复获得尽力而为的持久性,无需另一存储后端;剩余崩溃窗口只会产生允许的重复。
|
||||
|
||||
**清点每个存活 Cordis fiber。** 已否决,因为编程式与内存 fiber 没有权威 NPM 包来源。Loader 支撑的宿主与 preset 配置项能提供可精确解析的包身份。
|
||||
|
||||
**cache 一份全进程清单,或按 TTL 使其过期。** 已否决,因为单份不可变清单无法正确反映 Loader 生命周期与逐会话 preset,TTL 则允许元数据在过期边界之间保持陈旧。暂缓的 epoch 设计会根据权威存活状态转换执行失效。
|
||||
|
||||
@@ -58,6 +85,8 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。该字段对模型不可见,不增加提示词 token,也不改变 KV Cache。manifest 解析、字段冲突、接受处理或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。
|
||||
DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。显式选择启用会话日志后,请求还会携带完整的未接受会话新后缀。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。Manifest 解析、字段冲突、接受记录或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。
|
||||
|
||||
缺少存活 Agent 的直接调用仍会携带宿主包清单。[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.zh.md)继续拥有 user/session header,且这些 header 仍位于正文之外。
|
||||
`delivery-accepted` 事件会成为权威日志的一部分,并在后续请求中自行交付。崩溃恢复可能重复后缀,但不会根据 assistant 输出推断接受,也不会创建第二份本地游标存储。缺少存活会话的直接调用会省略会话字段;宿主包清单仍然可用。
|
||||
|
||||
[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.zh.md)继续拥有 user/session header,且这些 header 仍位于正文之外。[会话遥测决策](../feature/2026-07-23-session-telemetry-otel-revival.zh.md)在另一项变更删除该 seam 与后端之前仍保持当前有效;本请求路径不改变 OTel 捕获或共享模式。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md
|
||||
2026-08-22-single-dsh-application-launcher.md: 69188e806d9192d230d1f1b52daf27a3b30481be
|
||||
2026-08-22-single-dsh-application-launcher.zh.md: a6bb1909b019ea0d386bd2f4a1bb8f5199ef1974
|
||||
@@ -0,0 +1,101 @@
|
||||
# Agent Note: One dsh launcher for application profiles
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-22-single-dsh-application-launcher.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
DeepSeek Harness application processes need one owner for composition, plugin resolution, environment discovery, shutdown, and user customization. A dedicated app bin with a complete `cordis.yml` creates a second lifecycle beside profile launch: plugins installed into a profile do not reach it, behavior drifts from `dsh-base`, and SDK callers learn arbitrary process argv instead of the product's composition model.
|
||||
|
||||
The Python SDK distributes a native executable and three platform wheels whose embedded direct-config runtime cannot change launch architecture without rebuilding and validating the complete VFS closure. That distribution needs an explicit temporary exception, not a second general Node application pattern.
|
||||
|
||||
## Decision
|
||||
|
||||
### Launch scope
|
||||
|
||||
Every supported Node application starts through the `dsh` CLI and one named profile. The shipped application commands are `dsh web`, `dsh --profile headless`, `dsh --profile sdk`, and `dsh --profile acp`; `dsh web` is the deliberate convenience alias for `--profile web`, not another application entry.
|
||||
|
||||
Vendor CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are outside the application-launch inventory. A package app bin or root demo that launches a package entry is not an accepted extension point.
|
||||
|
||||
### Profile applications
|
||||
|
||||
`@deepseek-ai/dsh-sdk-app` and `@deepseek-ai/dsh-acp-app` compose the protocol applications over `@deepseek-ai/dsh-base`. The SDK bundle adds the JSON-RPC server plus app-owned help and stdio lifetime; the ACP bundle adds the automation-only ACP server plus the same application responsibilities. Both adopt the base model, tools, persistence, settings, credentials, policy, and environment behavior.
|
||||
|
||||
Profile manifests own patch reload:
|
||||
|
||||
| Profile | `patchReload` |
|
||||
|---|---|
|
||||
| `web` | `live` |
|
||||
| `headless` | `startup` |
|
||||
| `sdk` | `startup` |
|
||||
| `acp` | `startup` |
|
||||
|
||||
Custom profiles default to `live`. A startup profile still applies its bundle, profile, home-level, and invocation `--patch` layers, but it does not watch them after boot. `dsh-base` inserts the module-HMR row disabled; a profile with a tested source-module reload lifecycle must enable it explicitly. None of the shipped profiles enable server module HMR: `patchReload: live` uses the launcher's config-only watcher while the startup profiles install no watcher. SDK and ACP cannot safely replace their server, agents, persistence, or tool registry inside one owned stdio connection.
|
||||
|
||||
The shipped protocol profiles reserve stdout for protocol frames, expose help without starting transport, and route stdin EOF and signals through bounded root disposal. ACP remains automation-only. The SDK JSON-RPC methods, notification fields, and `initialize.serverInfo.name` remain stable. Model-visible tool and persistence defaults come from `dsh-base`, and runnable snapshots own those assembled application outputs.
|
||||
|
||||
### TypeScript SDK customization
|
||||
|
||||
`@deepseek-ai/dsh-sdk-client` depends on the same-version `@deepseek-ai/dsh` package, resolves its installed CLI module, runs it through the current Node executable, and selects `sdk` by default. Both client layers expose `dshBin`, `profile`, ordered `patches`, `dshHome`, process cwd, environment, and timeouts; arbitrary command/argv launch remains an internal fake-runtime adapter.
|
||||
|
||||
SDK users customize plugins through profiles. `dsh plugin --profile <name> ...` manages persistent dependencies and bundle order, the profile's `cordis.patch.yml` owns persistent row changes, and launch `patches` supply ordered ephemeral overrides. A custom profile must retain `@deepseek-ai/dsh-sdk-app` or another SDK server row. Relative CLI-module, patch, explicit home, and process-cwd paths become absolute before spawn, and initialization has a finite bound whose diagnostic names the selected profile.
|
||||
|
||||
Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inherited `DSH_HOME`, then `~/.dsh`. `subagent-dsh-sdk` instead requires an explicit absolute home, so a nested runtime cannot discover a person's profiles, installed plugins, credentials, or sessions through the operating-system home. DSH-specific ACP child examples also pass an isolated home; the ACP backend itself remains generic for non-DSH agents.
|
||||
|
||||
### Python exception and names
|
||||
|
||||
The Python SDK's direct-config application lives in the private `packages/sdk/python-runtime` package named `@deepseek-ai/dsh-sdk-python-runtime`. Its only packaged executable entry is `lib/packaged-bin.js`, consumed by the private `dsh-sdk-python-runtime-closure` deploy root. It has no public npm bin. The runnable direct Python example is `examples/python-sdk-agent`.
|
||||
|
||||
Python-observable behavior remains fixed: Python API, SDK wire, default `cordis.yml`, environment variables, wheel distribution names, packaged executable names, sidecar names, explicit runtime options, zero-config behavior, and supported platforms. The stable SDK family remains `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, `@deepseek-ai/dsh-sdk-jsonrpc-server`, and wire identity `deepseek-harness-sdk-runtime`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias.
|
||||
|
||||
### Enforcement
|
||||
|
||||
`verify-application-entrypoints` scans application/package manifests, executable sources, and root demo scripts. The allowlist classifies the `dsh` product bin, vendor-excluded scope, the private WebWorker build tool, test support, and the private Python carrier. An unclassified shebang, a new package bin, or a demo wrapper that bypasses `apps/cli/src/bin.ts` fails hygiene and the primary/static CI aggregates.
|
||||
|
||||
## Deferred Python migration
|
||||
|
||||
The Python runtime follow-up must move the packaged process through `dsh --profile sdk`, preserve the wheel's closed dependency and native sidecar behavior, and delete `@deepseek-ai/dsh-sdk-python-runtime`. Only after those conditions pass on Linux x64, Linux arm64, and macOS arm64 does the executable family change from `dsh-jsonrpc-agent-pkg-<platform>-<arch>` to `deepseek-harness-sdk-runtime-<platform>-<arch>`. The temporary carrier and current artifact names make that obligation visible without weakening current Python compatibility.
|
||||
|
||||
## Existing decisions and supersession
|
||||
|
||||
This decision supersedes the application-launch and package-name facts in [profile plugin bundles](2026-08-05-profile-plugin-bundles.md), [TypeScript SDK client and subagent backend](../feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md), [remove the SDK project toolchain](../simplification/2026-08-11-remove-sdk-project-toolchain.md), and [single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md). Those notes retain independent authority for profile layering, client/wire semantics, deleted project tooling, and native packaging.
|
||||
|
||||
The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) remains authoritative for ACP wire and interaction scope. The [repository naming contract](2026-08-11-repository-naming-contract-and-rename-ledger.md) remains authoritative for role-based package names. No active note is fully superseded or eligible for archival.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep direct bins and state that profiles are preferred.** Rejected: documentation cannot make profiles own plugin installation, environment loading, shutdown, and tests while a supported executable bypasses them.
|
||||
|
||||
**Keep forwarding compatibility bins.** Rejected: a forwarding executable remains another public launch name and compatibility promise. The pre-release repository can move callers directly to profiles.
|
||||
|
||||
**Put complete standalone Cordis trees behind profile wrappers.** Rejected: that centralizes argv without centralizing application composition. `dsh-base` plus thin app bundles gives shared policy one owner while retaining protocol-specific negative guarantees.
|
||||
|
||||
**Accept inline plugins or a complete `cordis.yml` in the TypeScript constructor.** Rejected: the SDK would become another package installer and application composer. Named profiles and patch files already provide persistent and per-launch customization through one resolution model.
|
||||
|
||||
**Resolve `dsh` only from `PATH`.** Rejected: ordinary Node processes do not reliably inherit a project-local `.bin` path. A same-version package dependency provides a deterministic runtime.
|
||||
|
||||
**Enable module HMR in `dsh-base` and make unsafe profiles disable it.** Rejected: the shared base also underlies custom profiles, so an enabled default makes every new application remember to opt out of source-module replacement. A disabled base makes module HMR an explicit profile capability while leaving `patchReload: live` config watching available.
|
||||
|
||||
**Hot-reload protocol profiles.** Rejected: replacing a protocol server or its dependencies can invalidate pending frames and SDK-owned agents. Process restart is the adoption boundary for SDK and ACP configuration changes.
|
||||
|
||||
**Move the Python executable through profiles without a separate packaging proof.** Rejected: the native VFS closure, three platform wheels, ripgrep and spawn-helper sidecars, default config discovery, and clean-install behavior require their own migration evidence.
|
||||
|
||||
## Verification
|
||||
|
||||
- Source and built CLI acceptance cover `sdk` and `acp` help, transport startup, stdout purity, EOF, signals, and root disposal.
|
||||
- Bundle configuration tests pin module HMR disabled in `dsh-base` and absent from shipped mode overrides; the custom live-profile e2e pins config reload through the launcher's watch-only fallback.
|
||||
- Focused unit suites cover profile launch resolution, initialization bounds, SDK retries, server readiness, and nested isolated homes with 100% coverage on the changed runtime sources.
|
||||
- Keyless ACP and SDK snapshots boot real `dsh` profiles and pin protocol output plus persisted logs; the nested SDK composition boots a second real profile runtime.
|
||||
- The real-API workflow caps file parallelism at four because one profile e2e file can own several complete `dsh` subprocess trees; workflow tests pin that resource bound.
|
||||
- The Python suite exercises exe and node carriers; all packaged-runtime scenarios, native macOS executable construction, both wheels, and clean-wheel default/MCP smokes retain the existing artifact names.
|
||||
- `verify-application-entrypoints` includes invalid fixtures for package bins, executable sources, package-launching demo wrappers, and unclassified demos.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A user changes an SDK application's plugin composition through a named profile and ordered patches, using the same installation and resolution model as every other dsh application.
|
||||
- A custom profile receives live config watching without server module HMR and opts into source-module replacement only through an explicit row override.
|
||||
- SDK and ACP share the complete base application and one set of policy and tools; snapshots present intentional assembled differences explicitly.
|
||||
- Adding `@deepseek-ai/dsh` increases the TypeScript client's install size in exchange for a deterministic same-version runtime.
|
||||
- Trusted user patches can add a plugin that writes to stdout and corrupt their own protocol stream; shipped profiles guarantee purity, not arbitrary third-party composition.
|
||||
- Python keeps a visibly private, narrowly allowed direct-config carrier until its platform artifact migration is independently proven.
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# Agent Note: 由一个 dsh 启动应用 profile
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-22-single-dsh-application-launcher.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
DeepSeek Harness 应用进程需要由同一个机制负责组合、插件解析、环境发现、关闭和用户自定义。带完整 `cordis.yml` 的专用应用 bin 会在 profile 启动之外形成第二套生命周期:安装到 profile 的插件无法到达它,行为会与 `dsh-base` 偏离,SDK 调用方还需要学习任意进程 argv,而不是产品的组合模型。
|
||||
|
||||
Python SDK 分发一个原生可执行文件和三个平台 wheel 包;其中嵌入的直读配置运行时只有在重建并验证完整 VFS 闭包后才能改变启动架构。该分发需要一个明确的临时例外,而不是另一种通用 Node 应用模式。
|
||||
|
||||
## Decision
|
||||
|
||||
### 启动范围
|
||||
|
||||
所有受支持的 Node 应用都通过 `dsh` CLI 与一个具名 profile 启动。随附应用命令是 `dsh web`、`dsh --profile headless`、`dsh --profile sdk` 与 `dsh --profile acp`;`dsh web` 是刻意为 `--profile web` 保留的便捷别名,不是另一个应用入口。
|
||||
|
||||
Vendor CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于应用启动清单。包应用 bin 或直接启动包入口的根 demo 都不是可接受的扩展点。
|
||||
|
||||
### Profile 应用
|
||||
|
||||
`@deepseek-ai/dsh-sdk-app` 与 `@deepseek-ai/dsh-acp-app` 在 `@deepseek-ai/dsh-base` 之上组合协议应用。SDK 组合包增加 JSON-RPC 服务器、应用自有帮助和 stdio 生命周期;ACP 组合包增加仅用于自动化的 ACP 服务器与相同的应用职责。两者都采用 base 层的模型、工具、持久化、settings、credentials、策略和环境行为。
|
||||
|
||||
Profile manifest 负责 patch 重载:
|
||||
|
||||
| Profile | `patchReload` |
|
||||
|---|---|
|
||||
| `web` | `live` |
|
||||
| `headless` | `startup` |
|
||||
| `sdk` | `startup` |
|
||||
| `acp` | `startup` |
|
||||
|
||||
自定义 profile 默认为 `live`。`startup` profile 仍会应用组合包、profile、home 级与调用时 `--patch` 各层,但启动后不会监视这些文件。`dsh-base` 插入的模块 HMR(热模块替换)配置项默认禁用;具有经过验证的源码模块重载生命周期的 profile 必须显式启用它。随附 profile 均不启用服务器模块 HMR:`patchReload: live` 使用启动器的仅配置 watcher,`startup` profile 则不安装 watcher。SDK 与 ACP 无法在一个自有 stdio 连接内安全替换其服务器、agent、持久化或工具注册表。
|
||||
|
||||
随附协议 profile 将 stdout 保留给协议帧,显示帮助时不启动 transport,并通过有界根节点 dispose(资源释放)处理 stdin EOF 与信号。ACP 继续仅用于自动化。SDK JSON-RPC 方法、通知字段与 `initialize.serverInfo.name` 保持稳定。模型可见工具与持久化默认值来自 `dsh-base`,可运行快照负责钉住这些已组装的应用输出。
|
||||
|
||||
### TypeScript SDK 自定义
|
||||
|
||||
`@deepseek-ai/dsh-sdk-client` 依赖同版本的 `@deepseek-ai/dsh` 包,解析其已安装 CLI 模块,通过当前 Node 可执行文件运行该模块,并默认选择 `sdk`。两层客户端都暴露 `dshBin`、`profile`、有序 `patches`、`dshHome`、进程 cwd、环境和超时;任意 command/argv 启动只保留为 fake-runtime 测试的内部适配器。
|
||||
|
||||
SDK 用户通过 profile 自定义插件。`dsh plugin --profile <name> ...` 管理持久依赖与组合包顺序,profile 的 `cordis.patch.yml` 负责持久配置项变更,启动时 `patches` 提供有序临时覆盖。自定义 profile 必须保留 `@deepseek-ai/dsh-sdk-app` 或另一个 SDK 服务器配置项。相对 CLI 模块、patch、显式 home 与进程 cwd 路径会在 spawn 前变为绝对路径;初始化具有有限时限,诊断会写明所选 profile。
|
||||
|
||||
直接使用 SDK 时遵循普通 Harness home 解析:显式 `dshHome`、继承的 `DSH_HOME`,最后是 `~/.dsh`。`subagent-dsh-sdk` 则要求显式绝对 home,因此嵌套运行时不会通过操作系统 home 发现个人 profile、已安装插件、凭据或会话。DSH 专用 ACP 子进程示例同样传入隔离 home;ACP 后端自身继续适用于非 DSH agent。
|
||||
|
||||
### Python 例外与命名
|
||||
|
||||
Python SDK 的直读配置应用位于私有 `packages/sdk/python-runtime` 包,名称是 `@deepseek-ai/dsh-sdk-python-runtime`。它唯一的打包可执行入口是 `lib/packaged-bin.js`,由私有 `dsh-sdk-python-runtime-closure` 部署根消费。它没有公开 npm bin。可运行的直启 Python 示例是 `examples/python-sdk-agent`。
|
||||
|
||||
Python 可观察行为保持不变:Python API、SDK 协议格式、默认 `cordis.yml`、环境变量、wheel 包分发名称、打包可执行文件名称、伴随文件名称、显式运行时选项、零配置行为与支持平台。稳定 SDK 包族继续是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol`、`@deepseek-ai/dsh-sdk-jsonrpc-server`,协议 identity 继续是 `deepseek-harness-sdk-runtime`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。
|
||||
|
||||
### 强制校验
|
||||
|
||||
`verify-application-entrypoints` 扫描应用/包 manifest、可执行源码和根 demo 脚本。允许清单对 `dsh` 产品 bin、排除的 vendor 范围、私有 WebWorker 构建工具、测试支持以及私有 Python 载体进行分类。未分类的 shebang、新包 bin 或绕过 `apps/cli/src/bin.ts` 的 demo wrapper 都会使 hygiene 与 primary/static CI 聚合失败。
|
||||
|
||||
## 暂缓的 Python 迁移
|
||||
|
||||
Python 运行时后续工作必须把打包进程迁移到 `dsh --profile sdk`,保持 wheel 包的封闭依赖与原生伴随文件行为,并删除 `@deepseek-ai/dsh-sdk-python-runtime`。只有这些条件在 Linux x64、Linux arm64 与 macOS arm64 全部通过后,可执行文件族才会从 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 改名为 `deepseek-harness-sdk-runtime-<platform>-<arch>`。临时载体与当前产物名称使这项义务清晰可见,同时不削弱当前 Python 兼容性。
|
||||
|
||||
## 既有决策与取代关系
|
||||
|
||||
本决策取代 [profile 插件组合包](2026-08-05-profile-plugin-bundles.zh.md)、[TypeScript SDK 客户端与 SDK subagent 后端](../feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)、[移除 SDK 项目工具链](../simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)和[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的应用启动与包名事实。这些 Note 对 profile 分层、客户端/协议语义、已删除的项目工具链与原生打包仍分别具有独立权威。
|
||||
|
||||
[ACP 仅自动化协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)继续负责 ACP 协议格式与交互范围。[仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)继续负责基于角色的包名。没有任何活跃 Note 被完全取代,也没有 Note 符合归档条件。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留直启 bin,只声明推荐 profile。** 拒绝:只要受支持的可执行文件仍然绕过 profile,文档就无法让 profile 真正负责插件安装、环境加载、关闭和测试。
|
||||
|
||||
**保留转发兼容 bin。** 拒绝:转发可执行文件仍然形成另一个公开启动名称与兼容承诺。预发布仓库可以让调用方直接迁移到 profile。
|
||||
|
||||
**把完整独立 Cordis 树放到 profile wrapper 后面。** 拒绝:这只集中 argv,没有集中应用组合。`dsh-base` 加轻量应用组合包让共享策略只有一个归属,同时保留协议专属的负面保证。
|
||||
|
||||
**在 TypeScript 构造函数中接受内联插件或完整 `cordis.yml`。** 拒绝:SDK 会因此成为另一个包安装器和应用组合器。具名 profile 与 patch 文件已通过统一解析模型提供持久与逐次启动自定义。
|
||||
|
||||
**只从 `PATH` 解析 `dsh`。** 拒绝:普通 Node 进程不一定继承项目本地 `.bin` 路径。同版本包依赖可以提供确定的运行时。
|
||||
|
||||
**在 `dsh-base` 中启用模块 HMR,再由不安全的 profile 逐一禁用。** 拒绝:共享 base 同样承载自定义 profile;默认启用会要求每个新应用都记得退出源码模块替换。base 默认禁用会让模块 HMR 成为显式的 profile 能力,同时保留 `patchReload: live` 配置监视。
|
||||
|
||||
**热重载协议 profile。** 拒绝:替换协议服务器或其依赖可能破坏待处理协议帧与 SDK 自有 agent。进程重启是 SDK 与 ACP 配置变更的采用边界。
|
||||
|
||||
**不做独立打包证明就把 Python 可执行文件迁移到 profile。** 拒绝:原生 VFS 闭包、三个平台 wheel 包、ripgrep 与 spawn-helper 伴随文件、默认配置发现和干净安装行为都需要自己的迁移证据。
|
||||
|
||||
## 验证
|
||||
|
||||
- 源码与构建后 CLI 验收覆盖 `sdk` 和 `acp` 的帮助、transport 启动、stdout 纯净性、EOF、信号与根节点 dispose。
|
||||
- 组合包配置测试钉住 `dsh-base` 默认禁用模块 HMR,随附模式覆盖层不再重复该策略;自定义 live profile 的 e2e 钉住启动器仅监视 fallback 提供的配置重载。
|
||||
- 聚焦单元套件覆盖 profile 启动解析、初始化时限、SDK 重试、服务器就绪和嵌套隔离 home,并对变更后的运行时源码实现 100% 覆盖率。
|
||||
- 免密钥 ACP 与 SDK 快照启动真实 `dsh` profile,并钉住协议输出与持久化日志;嵌套 SDK 组合会启动第二个真实 profile 运行时。
|
||||
- 真实 API 工作流把文件并行度限制为 4,因为一个 profile e2e 文件可能拥有多个完整 `dsh` 子进程树;工作流测试会钉住该资源上限。
|
||||
- Python 套件同时测试 exe 与 node 载体;全部打包运行时场景、原生 macOS 可执行文件构建、两个 wheel 包以及干净 wheel 默认/MCP 冒烟测试都保留既有产物名称。
|
||||
- `verify-application-entrypoints` 包含包 bin、可执行源码、直启包的 demo wrapper 与未分类 demo 等非法 fixture(测试前置数据)。
|
||||
|
||||
## 影响
|
||||
|
||||
- 用户通过具名 profile 与有序 patch 更改 SDK 应用的插件组合,使用与其他所有 dsh 应用相同的安装与解析模型。
|
||||
- 自定义 profile 可以在不启用服务器模块 HMR 的情况下获得实时配置监视,只有显式覆盖配置项才会启用源码模块替换。
|
||||
- SDK 与 ACP 共享完整 base 应用和同一份策略与工具;快照以显式差异呈现刻意采用的组装变化。
|
||||
- 增加 `@deepseek-ai/dsh` 会扩大 TypeScript 客户端的安装体积,换来确定的同版本运行时。
|
||||
- 受信任用户 patch 可以增加写入 stdout 的插件并破坏自己的协议流;随附 profile 保证纯净,不为任意第三方组合提供保证。
|
||||
- Python 保留一个清晰可见的私有直读配置载体,直到其平台产物迁移得到独立证明。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md
|
||||
2026-08-23-locale-owned-client-ui-copy.md: 7fa2d60f14253a74b2bd3df4398471905a32509b
|
||||
2026-08-23-locale-owned-client-ui-copy.zh.md: 5515699bb1702d41726c57435b19a2256ee0b896
|
||||
@@ -0,0 +1,42 @@
|
||||
# Agent Note: Locale-owned client UI copy
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-23-locale-owned-client-ui-copy.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Typed locale namespaces and bilingual dictionary parity proved that registered dictionaries were complete, but they could not prove that presentation code used them. JSX text, accessibility attributes, formatter returns, and zero-Cordis primitive defaults could bypass `t` while every locale check remained green. The deferred and supposedly language-neutral exceptions recorded in the [initial full-rollout decision](2026-07-30-client-locale-full-rollout.md) accumulated into a mixed-language UI, especially in trajectory inspection and generic Tool cards.
|
||||
|
||||
## Decision
|
||||
|
||||
**Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated.
|
||||
|
||||
**Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionBanner` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning.
|
||||
|
||||
**Localized display text is never an identity.** Models and stores retain discriminants, stable ids, and non-display markers. Renderers translate after matching, and request maps carry stable group membership into the trajectory ledger. A client-synthesized error that must survive in a view model uses a stable marker and is translated only when displayed. Language switching therefore changes wording without changing selection, grouping, search identity, or lifecycle state.
|
||||
|
||||
**`verify-client-ui-i18n` enforces source ownership.** The TypeScript-AST check discovers every package `src/client` tree that contains TSX, all helper TS files under `packages/client/ui-*`, and the web app source. It rejects natural-language JSX text, copy-bearing attributes and component props, literal JSX branches, label/copy data, named copy helpers, string-returning display formatters, and destructuring defaults. Locale dictionary owners and immutable language tokens are the narrow syntactic exclusions. Discovery refuses a narrowed corpus, unit fixtures pin admitted and excluded forms, and the check runs in the static CI and `hygiene` graphs. Dictionary-key parity remains a separate check: one gate proves copy enters the locale path, while the other proves both shipped languages implement that path.
|
||||
|
||||
The product-authored error and design-literal exclusions, primitive defaults, and trajectory deferral in the [initial rollout](2026-07-30-client-locale-full-rollout.md) are superseded by this decision. Its label-thunk, typed-seat, browser-locale, date-formatting, and search-placeholder decisions remain active.
|
||||
|
||||
## Verification
|
||||
|
||||
The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client component suites exercise both direct translated seats and locale-prop adapters, and the assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Rely on review and AGENTS.md alone.** Rejected because the existing rule and typed dictionaries coexisted with hundreds of bypasses; reviewers need a source-level failure at the introducing line.
|
||||
|
||||
**Use a text regex or ban every string literal.** Rejected because TypeScript and JSX contain imports, CSS classes, discriminants, event names, SVG data, and user/wire values. Syntax-aware contexts provide useful signal without an ever-growing file allowlist, while the minimum discovery count prevents a falsely green narrowed scan.
|
||||
|
||||
**Keep primitive fallback copy for convenient direct use.** Rejected because a fallback is itself an implicit locale choice. Required label props keep primitives framework-free and make each product render site name its copy owner.
|
||||
|
||||
**Translate every string that reaches the DOM.** Rejected because authored data and protocol/code tokens are not product wording. Translating them corrupts evidence, identifiers, commands, paths, URLs, and provider diagnostics; only surrounding product chrome belongs to the locale system.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Adding or changing client UI copy requires a typed dictionary key in both locales and behavior evidence for the affected render path.
|
||||
- Pure primitives have larger explicit prop types, and tests provide deliberate label fixtures; this cost prevents hidden locale behavior.
|
||||
- The AST check catches authored literal bypasses but cannot prove that an arbitrary dynamic string prop was translated. Types, dictionary parity, component tests, and review still own that semantic distinction.
|
||||
- Boot markup that renders before the locale service and externally authored runtime data remain outside the dictionary path; product UI replaces boot copy after locale activation.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Agent Note: locale 归属的 client UI 文案
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-23-locale-owned-client-ui-copy.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
typed locale namespace 与双语字典对等性可以证明已注册字典完整,却无法证明展示代码使用了字典。JSX 文本、无障碍属性、格式化函数返回值和 zero-Cordis 原子组件默认值都可能绕过 `t`,而全部 locale 检查仍保持绿色。[最初的全量接入决策](2026-07-30-client-locale-full-rollout.zh.md)中缓做或假定为语言无关的例外逐渐形成混合语言 UI,trajectory 检查面和通用工具卡尤为明显。
|
||||
|
||||
## Decision
|
||||
|
||||
**所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。
|
||||
|
||||
**Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionBanner` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。
|
||||
|
||||
**本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。
|
||||
|
||||
**`verify-client-ui-i18n` 强制源码归属。** 基于 TypeScript AST 的检查会发现每个包含 TSX 的 package `src/client` 目录树、`packages/client/ui-*` 下的所有辅助 TS 文件和 web 应用源码;它拒绝自然语言 JSX 文本、承载文案的属性与组件 prop、JSX 字面量分支、label/copy 数据、具名文案辅助函数、返回字符串的展示格式化函数和解构默认值。locale 字典 owner 与不可变语言 token 是严格的语法级排除项。发现范围缩窄会直接失败,单元 fixture 固定纳入与排除形态,检查加入静态 CI 与 `hygiene` 图。字典 key 对等性仍由独立检查负责:一道门禁证明文案进入 locale 路径,另一道门禁证明两种发布语言都实现该路径。
|
||||
|
||||
[最初接入决策](2026-07-30-client-locale-full-rollout.zh.md)中的产品自产错误与设计字面量例外、原子组件默认文案和 trajectory 缓做均由本决定取代;其 label thunk、typed 席位、浏览器 locale、日期格式化和搜索占位行决定仍有效。
|
||||
|
||||
## Verification
|
||||
|
||||
AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 组件测试同时覆盖直接翻译席位与 locale prop 适配器;组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**只依赖评审与 AGENTS.md。** 否决。既有规则和 typed 字典与数百个绕过点同时存在;评审者需要在引入行收到源码级失败。
|
||||
|
||||
**使用文本正则,或禁止所有字符串字面量。** 否决。TypeScript 与 JSX 中包含 import、CSS class、判别值、事件名、SVG 数据和用户/wire 值。按语法上下文检查可在不扩张文件 allowlist 的情况下保持有效信号,而最小发现数量可防止扫描范围缩小后伪绿。
|
||||
|
||||
**为方便直接使用而保留原子组件回落文案。** 否决。回落值本身就是隐式 locale 选择。必填 label prop 让原子组件保持框架无关,并迫使每个产品渲染点明确文案 owner。
|
||||
|
||||
**翻译所有进入 DOM 的字符串。** 否决。外部编写的数据和协议/代码 token 并非产品措辞。翻译会破坏证据、标识符、命令、路径、URL 和提供方诊断;只有其周围的产品 chrome 属于 locale 系统。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 新增或修改 client UI 文案时,必须在两种 locale 中添加 typed 字典 key,并为受影响渲染路径提供行为证据。
|
||||
- 纯原子组件的显式 prop 类型变大,测试需提供有意选择的 label fixture;这项成本换来无隐藏 locale 行为。
|
||||
- AST 检查可以抓到产品编写的字面量绕过,却无法证明任意动态字符串 prop 已翻译。类型、字典对等性、组件测试和评审仍共同负责这一语义区分。
|
||||
- locale 服务之前渲染的 boot 标记和外部编写的运行时数据仍在字典路径之外;locale 激活后,产品 UI 会替换 boot 文案。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md
|
||||
2026-07-30-web-transcript-log-ordered-projection.md: e4c9fdd6fcdec0b14ed2724dd58365e1d98af8b4
|
||||
2026-07-30-web-transcript-log-ordered-projection.zh.md: 625bd993a0b20bbbed318edf71d79866fa80bff0
|
||||
2026-07-30-web-transcript-log-ordered-projection.md: ed6fa4df3ac25bf6fe2947e6fb7bb7cbde6ac00a
|
||||
2026-07-30-web-transcript-log-ordered-projection.zh.md: cf8dc35099f416356310081f758d0b89fe8282b9
|
||||
|
||||
+2
-2
@@ -37,9 +37,9 @@ import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/che
|
||||
const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact'
|
||||
```
|
||||
|
||||
Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compaction/compaction"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`.
|
||||
Renaming the Service Definition's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compaction/compaction"}` in `packages/client/ui-chat/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`.
|
||||
|
||||
`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it.
|
||||
`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` is the behavioral half, driving the compaction Definition with checkpoint and provenance records and proving that an older page can fill missing summary data. The Definition's type-only leaf import keeps the client isolated from the compact package root and the host-side `Context` merges reachable through it.
|
||||
|
||||
The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type.
|
||||
|
||||
|
||||
+2
-2
@@ -37,9 +37,9 @@ import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/che
|
||||
const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact'
|
||||
```
|
||||
|
||||
重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compaction/compaction"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。
|
||||
重命名 Service Definition 的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/ui-chat/tsconfig.json` `references` 中的 `{"path": "../../compaction/compaction"}`:composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`。
|
||||
|
||||
`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。
|
||||
`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 是行为侧的另一半,用检查点与溯源记录驱动压缩 Definition,并证明后续加载的旧分页可以补齐缺失的摘要数据。Definition 仅类型导入该叶子路径,使客户端继续与 compact 包根及经由它可达的宿主侧 `Context` 合并隔离。
|
||||
|
||||
因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md
|
||||
2026-08-05-workspace-blank-session-reuse-membership.md: 0d46a0ccf6924c508db2e6c0f3591468e2956722
|
||||
2026-08-05-workspace-blank-session-reuse-membership.zh.md: 6350c6773265edebb381d5ca10fd5126a5722a5a
|
||||
2026-08-05-workspace-blank-session-reuse-membership.md: df8cc898f5a80937b8aac69ebd51a6a93882971b
|
||||
2026-08-05-workspace-blank-session-reuse-membership.zh.md: 73ccdbbb002920eea8f7b01bc0fa8a18859bd3ee
|
||||
|
||||
+1
-1
@@ -26,4 +26,4 @@ Stray blank sessions remain visible in Ungrouped (the user can still open them)
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/runtime/tests/workspaces-service.client.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green.
|
||||
`packages/client/ui-workspace/tests/workspaces-service.client.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green.
|
||||
|
||||
+1
-1
@@ -26,4 +26,4 @@ Status: implemented
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/client/runtime/tests/workspaces-service.client.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。
|
||||
`packages/client/ui-workspace/tests/workspaces-service.client.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次提示词被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md
|
||||
2026-08-06-reader-scroll-attribution-observed-top-ledger.md: 66a1ca361cf28bf0beab95fa81da9cac3527474c
|
||||
2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: 6aa1866e802b00d0a3431dfd5bef9efd57121294
|
||||
2026-08-06-reader-scroll-attribution-observed-top-ledger.md: b55bbc39f6e1f24bb7751b7743da23736abbd06b
|
||||
2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: e38ead6c9b80e41b37556172bd6c1f411858b6ed
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ A shrink clamp whose layout regrows within the same rendering update before the
|
||||
|
||||
## Testing
|
||||
|
||||
Unit specs in `packages/client/ui-conversation/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger.
|
||||
Unit specs in `packages/client/ui-chat/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger.
|
||||
|
||||
The lane's Chromium cannot synthesize any non-wheel device scrolling, which bounds what the e2e can drive for real: `Input.synthesizeScrollGesture` with a touch source and hand-rolled `Input.dispatchTouchEvent` sequences deliver DOM events but never move a scroller (headless and headed-under-Xvfb alike); the `default` gesture source synthesizes wheel events; and compositor scrollbars ignore synthetic mouse input entirely, with a gutter visible only when `--hide-scrollbars` is removed. Keyboard is the one working non-wheel primitive, so it carries the real-input-pipeline proof, and the fling scenario replays touch's signature — per-frame decaying displacements the component never authored — through the scrollport directly.
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/client/ui-conversation/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。
|
||||
`packages/client/ui-chat/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。
|
||||
|
||||
该车道的 Chromium 无法合成任何非滚轮的设备滚动,这限定了 e2e 能真实驱动的范围:触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件,却从不移动滚动容器(无头模式与 Xvfb 下的有头模式皆然);`default` 手势来源合成的是滚轮事件;合成器滚动条则完全无视合成的鼠标输入,且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语,因此由它承担真实输入流水线的证明;快滑场景则把触控的特征(组件从未写入过的逐帧衰减位移)直接回放进滚动容器。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md
|
||||
2026-08-10-minimal-preset-owns-rl-composition.md: 65d24f9a03eedffac34f0c0141a8e2f643a48b7b
|
||||
2026-08-10-minimal-preset-owns-rl-composition.zh.md: 983fea50dfd4a262204b68937d07910c69018614
|
||||
2026-08-10-minimal-preset-owns-rl-composition.md: 47a1bbe9f8f4875e2437b3ff955663c4b3de7dce
|
||||
2026-08-10-minimal-preset-owns-rl-composition.zh.md: 0fab1e1f23a314bec80b5fd355abcb2881c1b1a5
|
||||
|
||||
@@ -22,7 +22,7 @@ The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace at
|
||||
|
||||
System-prompt and persona package tests prove final complete-section and runtime-context suppression, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web-orientation text, dynamic policy contexts, and a test section are registered, asserts that no runtime-context snapshot exists, the entry-local filesystem is bare, and compaction is absent, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path.
|
||||
|
||||
The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) is the complete two-tool composition for the bundled JSON-RPC runtime. The [bare two-tool runtime decision](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) owns its launch-specific environment configuration, bare filesystem, and absence of compaction. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point.
|
||||
The standalone [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) is the complete two-tool composition for the bundled JSON-RPC runtime. The [bare two-tool runtime decision](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) owns its launch-specific environment configuration, bare filesystem, and absence of compaction. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ preset persona 恰好是 `You are a helpful software engineer assistant.`,它
|
||||
|
||||
系统提示词与 persona 包测试证明了 complete 段最终约束与 runtime-context 抑制,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求,同时注册全局身份、Web 定位文本、动态策略上下文和一个测试段落;它断言不存在 runtime-context 快照、entry 本地文件系统是裸后端且压缩不存在,随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。
|
||||
|
||||
独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 是内置 JSON-RPC 运行时的完整双工具组合。[裸双工具运行时决策](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)说明其启动方式专属的环境配置、裸文件系统和无压缩选择。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash,并使用编辑器;Python SDK 教程提供可运行的入口。
|
||||
独立的 [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) 是内置 JSON-RPC 运行时的完整双工具组合。[裸双工具运行时决策](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)说明其启动方式专属的环境配置、裸文件系统和无压缩选择。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash,并使用编辑器;Python SDK 教程提供可运行的入口。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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-23-win32-utf16-nul-truncation.md
|
||||
2026-08-23-win32-utf16-nul-truncation.md: 3962730c66d72b9927e5ce6dabde0f50101c5787
|
||||
2026-08-23-win32-utf16-nul-truncation.zh.md: 23df80053b23c5e2fbb810c95668bbeef7c91fe9
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Win32 folder-picker paths stop truncating at U+XX00 code units
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-23-win32-utf16-nul-truncation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`readUtf16` in `packages/host/directory-picker-native/src/win32-dialog-bindings.ts` translated the `IFileOpenDialog` result buffer by scanning for a zero byte with `bytes[end] !== 0`. UTF-16LE encodes NUL as two zero bytes, so any BMP code unit whose low byte is zero — U+XX00, such as 开 (U+5F00) — ended the scan early. Selecting a folder like `C:\Users\XIAOPAN\Desktop\安卓开发` returned `C:\Users\XIAOPAN\Desktop\安卓`, and the workspace-creation call failed with `workspace-invalid-path ... ENOENT`.
|
||||
|
||||
## Decision
|
||||
|
||||
The scan ends only when both bytes of a code unit are zero, still advancing two bytes at a time over the same 32KiB `koffi.view` buffer. A regression test drives `readUtf16` through the existing fake koffi COM world with a path containing 安卓开发 (U+5F00), so the termination rule is proven without a real Windows host.
|
||||
|
||||
The fix is adopted verbatim from the community patch series on the `fix/win32-utf16-nul-truncation` branch of the ericcaiwx-star fork — [c8aac14703](https://github.com/ericcaiwx-star/deepseek-harness/commit/c8aac14703a517b8db1573f9ca4ed94dc58e276b) for the scan fix and [e1d6265cb9](https://github.com/ericcaiwx-star/deepseek-harness/commit/e1d6265cb930a0a74cba03c40e73ed872a83575f) for the fixture cleanup — reported in [discussion #580](https://github.com/deepseek-ai/deepseek-harness/discussions/580) (earlier reported in [discussion #563](https://github.com/deepseek-ai/deepseek-harness/discussions/563)). Both cherry-picks retain the original author, ericcaiwx-star; the upstream fork is the source of record for the patch.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Reject the community patch and rewrite the scan locally.** Rejected: the patch is minimal, fits the dialog's existing test approach, and a byte-identical cherry-pick preserves provenance and credit.
|
||||
|
||||
**Decode the whole buffer with `toString('utf16le')` and split at `\0`.** Rejected: it copies the entire buffer instead of scanning, and the split would still depend on the same two-zero-byte rule.
|
||||
|
||||
**Ask COM or koffi for a string length.** Rejected: the binding surface provides no length; the double-zero scan is the standard UTF-16LE NUL test.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Any path containing a U+XX00 code unit survives the picker translation; paths with such characters (for example Chinese folder names) can be selected and used to create workspaces.
|
||||
- The fix changes no ABI usage, buffer size, or dialog flow; the COM child-process architecture in the [Win32 folder dialog note](../feature/2026-08-02-win32-in-process-folder-dialog.md) is untouched.
|
||||
- Real-dialog rendering and selection remain a manual Windows check; this change's regression test exercises only the byte-to-string translation against the fake COM world. The fixture path is synthetic (`C:\fixture\安卓开发`) so no real user path appears in the repository.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Win32 目录选择器路径不再在 U+XX00 码元处截断
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-23-win32-utf16-nul-truncation.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`packages/host/directory-picker-native/src/win32-dialog-bindings.ts` 的 `readUtf16` 用 `bytes[end] !== 0` 扫描 `IFileOpenDialog` 结果缓冲区来寻找零字节。UTF-16LE 真正的 NUL 是两个零字节,因此任何低字节为 0 的 BMP 码元——U+XX00,例如「开」(U+5F00)——都会提前结束扫描。选择 `C:\Users\XIAOPAN\Desktop\安卓开发` 这类目录会得到 `C:\Users\XIAOPAN\Desktop\安卓`,随后创建工作区的调用以 `workspace-invalid-path ... ENOENT` 失败。
|
||||
|
||||
## 决策
|
||||
|
||||
扫描只有在一个码元的两个字节都为零时才结束,仍按每次两个字节在同一个 32KiB `koffi.view` 缓冲区上推进。回归测试通过既有的假 koffi COM 世界驱动 `readUtf16`,路径包含「安卓开发」(U+5F00),从而不依赖真实 Windows 主机验证终止规则。
|
||||
|
||||
修复逐字采用 ericcaiwx-star fork 的 `fix/win32-utf16-nul-truncation` 分支上的社区补丁系列——[c8aac14703](https://github.com/ericcaiwx-star/deepseek-harness/commit/c8aac14703a517b8db1573f9ca4ed94dc58e276b) 是扫描修复,[e1d6265cb9](https://github.com/ericcaiwx-star/deepseek-harness/commit/e1d6265cb930a0a74cba03c40e73ed872a83575f) 是 fixture 清理——在 [discussion #580](https://github.com/deepseek-ai/deepseek-harness/discussions/580) 报告(更早在 [discussion #563](https://github.com/deepseek-ai/deepseek-harness/discussions/563) 报告)。两次 cherry-pick 均保留原作者 ericcaiwx-star;上游 fork 是补丁的记录来源。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**拒绝社区补丁,本地重写扫描。** 拒绝:补丁极小,与目录选择器现有测试方式一致;逐字节一致的 cherry-pick 保留来源与署名。
|
||||
|
||||
**用 `toString('utf16le')` 解码整个缓冲区再按 `\0` 切分。** 拒绝:复制整个缓冲区而非扫描,且切分仍依赖同一「双零字节」规则。
|
||||
|
||||
**向 COM 或 koffi 索取字符串长度。** 拒绝:绑定面不提供长度;双零扫描是标准的 UTF-16LE NUL 判定。
|
||||
|
||||
## 后果
|
||||
|
||||
- 任何含 U+XX00 码元的路径组件都能通过选择器转译;含这类字符的路径(例如中文目录名)可以选中并用于创建工作区。
|
||||
- 修复不改变 ABI 用法、缓冲区大小或对话框流程;[Win32 目录选择器 note](../feature/2026-08-02-win32-in-process-folder-dialog.zh.md) 中的 COM 子进程架构不受影响。
|
||||
- 真实对话框渲染与选择仍是手动 Windows 检查;本次回归测试只针对假 COM 世界中的字节到字符串转译。fixture 路径为合成路径(`C:\fixture\安卓开发`),仓库中不出现真实用户路径。
|
||||
@@ -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-06-29-todo-write-tool.md
|
||||
2026-06-29-todo-write-tool.md: 74af8a66ea86a474533b9c53d6431f7e0026192c
|
||||
2026-06-29-todo-write-tool.zh.md: 223268ee1e9d330df3299651a594b7b2ed08e1ce
|
||||
2026-06-29-todo-write-tool.md: 4fe6cd5ce921e0f6fe71fd2548a0cfa6b8d2173b
|
||||
2026-06-29-todo-write-tool.zh.md: eaeb70b02cdcbdee0ad83d24208e9c8bb0ec3bda
|
||||
|
||||
@@ -10,7 +10,7 @@ The harness gives the model bash and subagent tools but no way to record a struc
|
||||
|
||||
## Decision
|
||||
|
||||
Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Interactive hosts render from the durable event: the TUI folds it directly, the web client projects it into `ConversationSnapshot.todos` ([web todo display](2026-07-23-web-todo-display.md)), while the [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately omits todo presentation.
|
||||
Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a `todo/write` `SessionEventMap` variant owned by the todo package ([event ownership](../architecture/2026-07-20-todo-event-ownership.md)). Interactive hosts render from the durable event: the TUI folds it directly, the web client projects it into `ConversationSnapshot.todos` ([web todo display](2026-07-23-web-todo-display.md)), while the [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately omits todo presentation.
|
||||
|
||||
### Whole-list replace, three-state status
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结
|
||||
|
||||
## 决策
|
||||
|
||||
新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。交互式宿主从持久事件渲染:TUI 直接折叠它,web 客户端将其投影进 `ConversationSnapshot.todos`([web todo 展示](2026-07-23-web-todo-display.zh.md)),而[仅面向自动化的 ACP(Agent Client Protocol)桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)有意省略 todo 展示。
|
||||
新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为由 todo 包拥有的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上(见[事件所有权](../architecture/2026-07-20-todo-event-ownership.zh.md))。交互式宿主从持久事件渲染:TUI 直接折叠它,web 客户端将其投影进 `ConversationSnapshot.todos`([web todo 展示](2026-07-23-web-todo-display.zh.md)),而[仅面向自动化的 ACP(Agent Client Protocol)桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)有意省略 todo 展示。
|
||||
|
||||
### 整列表替换,三态 status
|
||||
|
||||
|
||||
@@ -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-06-approval-seam.md
|
||||
2026-07-06-approval-seam.md: ace41ebbb94cc24c2fdd3e7ae2d3a69b28b169af
|
||||
2026-07-06-approval-seam.zh.md: 63b7478b0903c149fc87fd944c95e94cc8de2359
|
||||
2026-07-06-approval-seam.md: d7f8d90d408bb85c20f6a4dd0373de1aff9b180b
|
||||
2026-07-06-approval-seam.zh.md: 8f2481ff0e25193e91118d5b8181cb4dd3cf40e5
|
||||
|
||||
@@ -25,7 +25,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou
|
||||
# policy: never # deployment default for sessions without an override; 'ask' when omitted
|
||||
```
|
||||
|
||||
The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its [automation-only bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) registers an answerer that sends `session/request_permission` to the owning client with the exact tool-call id and one-shot allow/reject options. `policy: never` is the unattended stance — every ask auto-rejects deterministically, and the current value joins the runtime-context snapshot. `policy` is validated against the closed list at plugin load; anything else throws.
|
||||
The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP profile app (`@deepseek-ai/dsh-acp-app`, as in [the acp-agent example](../../../../examples/acp-agent/README.md)) completes the loop: its [automation-only bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) registers an answerer that sends `session/request_permission` to the owning client with the exact tool-call id and one-shot allow/reject options. `policy: never` is the unattended stance — every ask auto-rejects deterministically, and the current value joins the runtime-context snapshot. `policy` is validated against the closed list at plugin load; anything else throws.
|
||||
|
||||
What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Status: implemented
|
||||
# policy: never # deployment default for sessions without an override; 'ask' when omitted
|
||||
```
|
||||
|
||||
仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用会被拒绝——无需配置即可做到故障时默认拒绝。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.zh.md))即可闭环:其[仅面向自动化的桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)注册一个应答者,向拥有该会话的客户端发送 `session/request_permission`,携带精确的工具调用 id 和一次性 allow/reject 选项。`policy: never` 是无人值守姿态:每次 ask 都会被确定性地自动拒绝,当前值也会加入运行时上下文快照。`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。
|
||||
仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用会被拒绝——无需配置即可做到故障时默认拒绝。组合 ACP profile 应用(`@deepseek-ai/dsh-acp-app`,如 [acp-agent 示例](../../../../examples/acp-agent/README.zh.md))即可闭环:其[仅面向自动化的桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)注册一个应答者,向拥有该会话的客户端发送 `session/request_permission`,携带精确的工具调用 id 和一次性 allow/reject 选项。`policy: never` 是无人值守姿态:每次 ask 都会被确定性地自动拒绝,当前值也会加入运行时上下文快照。`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。
|
||||
|
||||
组合部署的可观测行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;轮次内成功的请求会在发起请求的 agent 的会话日志上落一对持久化的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。空闲时的请求或审计追加失败会拒绝,而不会返回未经审计的决策。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md
|
||||
2026-07-12-subagent-persona-tool-filter-and-depth.md: 08dffb459621b0d76c7e08ac6ed9a566fcc6e131
|
||||
2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: e484319f4ea76f4bc3e833e8296142480d374516
|
||||
2026-07-12-subagent-persona-tool-filter-and-depth.md: 26349e87c008ff89fa8f7ebfd7fce30e446180d4
|
||||
2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 6628cd84a008c59c991549668c76a2ce118edcf6
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ The depth limit bounds recursive delegation independently of tool visibility. A
|
||||
|
||||
The effective parent depth is the greater of durable `SessionHeader.delegationDepth` and runtime `AgentOptions.subagentDepth`. An in-process child records its derived depth in the session header, and resume restores that header, so a restart cannot lower the recursion count.
|
||||
|
||||
Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations: the [JSON-RPC example](../../../../examples/jsonrpc-agent/cordis.yml) uses that general policy, while the ACP and headless examples pin one. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`.
|
||||
Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations: the [JSON-RPC example](../../../../examples/python-sdk-agent/cordis.yml) uses that general policy, while the ACP and headless examples pin one. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`.
|
||||
|
||||
A deployment can combine depth and filtering, but the numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent's current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A deployment may separately deny delegation tools in children when its visibility policy is static. Neither choice changes the provider's conversation-history behavior.
|
||||
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma
|
||||
|
||||
有效父级深度取持久 `SessionHeader.delegationDepth` 与运行时 `AgentOptions.subagentDepth` 中的较大值。进程内子 agent 把推导出的深度记录在会话 header 中,恢复时会重新载入该 header,因此重启无法降低递归计数。
|
||||
|
||||
每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代:[JSON-RPC 示例](../../../../examples/jsonrpc-agent/cordis.yml)采用这项通用策略,而 ACP 与 headless 示例固定为一。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。
|
||||
每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代:[JSON-RPC 示例](../../../../examples/python-sdk-agent/cordis.yml)采用这项通用策略,而 ACP 与 headless 示例固定为一。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。
|
||||
|
||||
部署可以组合深度与过滤,但数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。可见性策略固定的部署可以另外在子 agent 中 deny 委派工具。两种选择都不改变提供方的对话历史行为。
|
||||
|
||||
|
||||
@@ -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-19-human-goal-command.md
|
||||
2026-07-19-human-goal-command.md: 7ed104eef1a48879a6e93af707c6ea9d8f97190c
|
||||
2026-07-19-human-goal-command.zh.md: 523ac0b6956a7ad7a2428e187b2d0f8f99bd1120
|
||||
2026-07-19-human-goal-command.md: b4e47aa687d60aa5ea4d30ad08826fbaa3978393
|
||||
2026-07-19-human-goal-command.zh.md: fcb34d5acc02d44dcb68a1f3c511cbde495673ea
|
||||
|
||||
@@ -12,7 +12,7 @@ The command must also respect the goal design's two kinds of state. Durable phas
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-command-goal` in `packages/goal/command-goal/` is a command producer over `ctx.commands` and `ctx.goals`. It registers one global `goal` definition, so every command adapter in the composition discovers the same command; an incompatible app omits this producer rather than masking its registration at an adapter. The handler receives the exact target agent from command dispatch, reads or mutates that agent's goal through the domain service, and returns direct plain-text UI output. It does not import either adapter or the concrete agent loop.
|
||||
`@deepseek-ai/dsh-command-goal` in `packages/goal/command-goal/` is a command producer over `ctx.commands` and `ctx.goals`. It registers one `goal` definition in the Cordis scope where the producer is mounted, so every command adapter reading that agent's scope discovers the same command; an incompatible app or agent preset omits this producer rather than masking its registration at an adapter. The handler receives the exact target agent from command dispatch, reads or mutates that agent's goal through the domain service, and returns direct plain-text UI output. It does not import either adapter or the concrete agent loop.
|
||||
|
||||
The command follows the compact Codex shape in the [public OpenAI Codex TUI dispatcher at commit `678157a`](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805): bare status, a free-form objective, and `clear`, `edit`, `pause`, or `resume` controls. The commit permalink makes the researched grammar durable even as Codex evolves. This repository keeps its own event-sourced state, round-count policy, and post-resume activation rule rather than copying Codex's SQLite, token budget, or automatic-resume behavior.
|
||||
|
||||
@@ -40,11 +40,11 @@ Generic slash input, status text, and errors are not persisted. Successful goal
|
||||
|
||||
`agent-spine-demo` accepts an optional `goals` composition object containing the goal-domain and model-tool owner configs. Omission or `false` leaves the stack unmounted. This explicit opt-in is important for headless one-shot callers: their result API settles one correlated physical turn and must not silently become a long-running logical goal operation.
|
||||
|
||||
The TUI app bundle makes the opposite product choice. It defaults `goals` to the owner defaults and mounts the goal domain, model tools, same-session driver, command registry, and this producer; `goals: false` removes the stack coherently. The [ACP automation app](../simplification/2026-07-23-acp-automation-only-protocol.md) also defaults the goal domain and model tools but deliberately omits command services. The Python SDK runtime closure ships this producer, commands, and the goal stack so an external `cordis.yml` can compose the same command.
|
||||
The TUI app bundle makes the opposite product choice. It defaults `goals` to the owner defaults and mounts the goal domain, model tools, same-session driver, command registry, and this producer; `goals: false` removes the stack coherently. The Web bundle keeps the goal domain and driver on the host for remote access, disables the host command producer, and mounts the producer in the `standard`, `code`, and `cordis` agent presets; `minimal` omits both the command and model goal tools. A preset switch does not mutate host-owned goal state, and the Web GoalBar retains direct edit, pause, resume, and clear controls. The [ACP automation app](../simplification/2026-07-23-acp-automation-only-protocol.md) also defaults the goal domain and model tools but deliberately omits command services. The Python SDK runtime closure ships this producer, commands, and the goal stack so an external `cordis.yml` can compose the same command.
|
||||
|
||||
## Testing
|
||||
|
||||
The producer suite uses the real command registry, goal service, agent registry, and session log. It covers Loader-safe exports, registry discovery, disposal, empty status, objective parsing, unfinished replacement refusal, inline edit, completed replacement, all missing-state controls, pause/resume/clear, every durable phase, blocked code/explanation presentation, armed/disarmed presentation, sanitized domain errors, unexpected failures, and persisted mutation records. App composition tests cover explicit spine opt-in, TUI defaults, coherent opt-out, forwarded domain/tool config, command discovery, the packaged-runtime closure, and the expanded model-tool assembly. ACP backend snapshots continue to pin the goal tool schemas independently of this human command.
|
||||
The producer suite uses the real command registry, goal service, agent registry, and session log. It covers Loader-safe exports, registry discovery, disposal, empty status, objective parsing, unfinished replacement refusal, inline edit, completed replacement, all missing-state controls, pause/resume/clear, every durable phase, blocked code/explanation presentation, armed/disarmed presentation, sanitized domain errors, unexpected failures, and persisted mutation records. App composition tests cover explicit spine opt-in, TUI defaults, coherent opt-out, forwarded domain/tool config, command discovery, the packaged-runtime closure, and the expanded model-tool assembly. Web composition tests cover preset-scoped command discovery, Minimal omission, disposal during preset switching, and the assembled browser command list. ACP backend snapshots continue to pin the goal tool schemas independently of this human command.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -57,7 +57,7 @@ The producer suite uses the real command registry, goal service, agent registry,
|
||||
|
||||
## Consequences
|
||||
|
||||
- TUI exposes one Codex-shaped `/goal` command supplied by a removable plugin.
|
||||
- TUI and non-Web base compositions expose one Codex-shaped `/goal` command supplied by a removable plugin; Web presets expose it only where they mount the producer.
|
||||
- Human status distinguishes durable phase from live activation and reports the exact goal-round cap.
|
||||
- Direct pause, resume, clear, creation, and edit consume no model turn while their accepted mutations remain reconstructable from the session log.
|
||||
- Restored sessions wait for a human decision; `/goal resume` is the literal command path, while an ordinary prompt in any language may authorize the model tool path.
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
位于 `packages/goal/command-goal/` 的 `@deepseek-ai/dsh-command-goal` 是构建在 `ctx.commands` 与 `ctx.goals` 之上的命令生产方。它注册一个全局 `goal` 定义,因此组合中的每个命令适配器都会发现同一个命令;不兼容的应用应省略该生产方,而不是在适配器处屏蔽其注册。处理器从命令分发接收准确的目标 agent(智能体),通过领域服务读取或改变该 agent 的目标,并返回直接的纯文本 UI 输出。它不导入任何适配器或具体 agent loop(智能体循环)。
|
||||
位于 `packages/goal/command-goal/` 的 `@deepseek-ai/dsh-command-goal` 是构建在 `ctx.commands` 与 `ctx.goals` 之上的命令生产方。它在自身挂载的 Cordis scope 中注册一个 `goal` 定义,因此读取目标 agent scope 的每个命令适配器都会发现同一个命令;不兼容的应用或 agent preset 应省略该生产方,而不是在适配器处屏蔽其注册。处理器从命令分发接收准确的目标 agent(智能体),通过领域服务读取或改变该 agent 的目标,并返回直接的纯文本 UI 输出。它不导入任何适配器或具体 agent loop(智能体循环)。
|
||||
|
||||
该命令遵循 [OpenAI Codex 公共仓库 `678157a` 提交中的 TUI 分发实现](https://github.com/openai/codex/blob/678157acaa819d5510adfe359abb5d0392cfe461/codex-rs/tui/src/chatwidget/slash_dispatch.rs#L750-L805)所呈现的紧凑形态:无参数状态查询、自由形式目标描述,以及 `clear`、`edit`、`pause` 或 `resume` 控制。固定到提交的链接使调研所得语法在 Codex 后续演进时仍可核验。本仓库保留自身的事件溯源状态、Round 计数策略与恢复后激活规则,而不复制 Codex 的 SQLite、token 预算或自动恢复行为。
|
||||
|
||||
@@ -40,11 +40,11 @@ Status: implemented
|
||||
|
||||
`agent-spine-demo` 接受可选的 `goals` 组合对象,其中包含目标领域与模型工具的所有者配置。省略或设为 `false` 时不会挂载该栈。对无头单次调用方而言,明确选择加入非常重要:它们的结果 API 会在与调用关联的一个物理轮次后结束,不能静默变成长时间运行的逻辑目标操作。
|
||||
|
||||
TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者默认值,并挂载目标领域、模型工具、同会话驱动器、命令注册表与本生产方;`goals: false` 会一致地移除整个栈。[ACP(Agent Client Protocol)自动化应用](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)也默认挂载目标领域与模型工具,但有意省略命令服务。Python SDK 运行时闭包交付本生产方、命令与目标栈,使外部 `cordis.yml` 能组合相同命令。
|
||||
TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者默认值,并挂载目标领域、模型工具、同会话驱动器、命令注册表与本生产方;`goals: false` 会一致地移除整个栈。Web 组合包把 goal 领域与驱动器保留在 host 中以供远程访问,停用 host 命令生产方,并在 `standard`、`code` 与 `cordis` agent preset 中挂载该生产方;`minimal` 会同时省略命令与模型 goal 工具。切换 preset 不会改变 host 所拥有的 goal 状态,Web GoalBar 仍保留直接 edit、pause、resume 与 clear 控制。[ACP(Agent Client Protocol)自动化应用](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)也默认挂载目标领域与模型工具,但有意省略命令服务。Python SDK 运行时闭包交付本生产方、命令与目标栈,使外部 `cordis.yml` 能组合相同命令。
|
||||
|
||||
## 测试
|
||||
|
||||
生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、注册表发现、dispose(资源释放)、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、无目标状态下的所有控制命令、暂停/恢复/清除、每个持久阶段、阻塞代码/说明展示、已激活/未激活展示、经净化的领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、TUI 默认值、一致停用、转发的领域/工具配置、命令发现、打包运行时闭包与扩展后的模型工具组装。ACP 后端快照继续固定目标工具 schema,与这项面向人类的命令无关。
|
||||
生产方测试套件使用真实命令注册表、目标服务、agent 注册表与会话日志。它覆盖 Loader 安全导出、注册表发现、dispose(资源释放)、空状态、目标描述解析、拒绝未完成目标替换、行内编辑、已完成目标替换、无目标状态下的所有控制命令、暂停/恢复/清除、每个持久阶段、阻塞代码/说明展示、已激活/未激活展示、经净化的领域错误、意外失败与持久变更记录。应用组合测试覆盖显式主干选择加入、TUI 默认值、一致停用、转发的领域/工具配置、命令发现、打包运行时闭包与扩展后的模型工具组装。Web 组合测试覆盖 preset scope 中的命令发现、Minimal 省略、切换 preset 时的 dispose,以及组装后浏览器中的命令列表。ACP 后端快照继续固定目标工具 schema,与这项面向人类的命令无关。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -57,7 +57,7 @@ TUI 应用包作出相反的产品选择。它默认让 `goals` 使用所有者
|
||||
|
||||
## 后果
|
||||
|
||||
- TUI 暴露由可移除插件提供的 Codex 形态 `/goal` 命令。
|
||||
- TUI 与非 Web 基础组合暴露由可移除插件提供的 Codex 形态 `/goal` 命令;Web preset 仅在挂载生产方时暴露该命令。
|
||||
- 人类状态会区分持久阶段与实时激活态,并报告准确的目标 Round 上限。
|
||||
- 直接暂停、恢复、清除、创建与编辑不消耗模型轮次,而其已接受变更仍可从会话日志重建。
|
||||
- 恢复后的会话等待人类决策;`/goal resume` 是字面命令路径,任何语言的普通提示词则可以授权模型工具路径。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 84314eaf5827464767666b1b9c65e105ea4e869a
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: a822aac655ea3577660f09b2f2a2986f2a780d7a
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: eda9d3a3de91944a298070d6cc22f632294f7a28
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 1a72c6b1cdb7453b8468d6e6be37aec76323f714
|
||||
|
||||
+7
-7
@@ -13,19 +13,19 @@ The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-sdk-jsonrpc-server`, the [
|
||||
Three packages, layered exactly like the existing Python stack, plus one Service Provider registration:
|
||||
|
||||
- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-sdk-jsonrpc-server` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message).
|
||||
- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `TurnResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `scrubbedParentEnv` from the subprocess seam is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`).
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`.
|
||||
- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `RunResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. The launch interface resolves the same-version `@deepseek-ai/dsh` dependency and selects a named profile, with optional `dshBin`, ordered patches, an explicit Harness home, process cwd, environment, and timeouts; arbitrary command/argv launch remains an internal fake-runtime adapter. A clean checkout without `lib/bin.js` uses that package's source entry through an absolute `tsx/esm` loader and an internal patch that omits build-generated Typert contribution loading, which the SDK protocol does not consume. `env` replaces rather than merges and is read when `start()` spawns, so callers own credential policy and can finish preparing it before first use. `RunResult` carries the structured `reason` (Python exposes only `status`); teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit (the client runs outside any harness context, so it cannot ride `ctx.subprocess`).
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `dshBin`/profile/patch/home config selects an isolated SDK application, `provider`/`model` feeds the child's `initialize`, and `env` supplies explicit child-only values such as its API key.
|
||||
- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself.
|
||||
|
||||
`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical); `dsh-jsonrpc-agent-pkg` (the Python runtime closure) gains the `dsh-sdk-protocol` dependency line.
|
||||
`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical); the private `@deepseek-ai/dsh-sdk-python-runtime` carrier consumes the shared protocol through its packaged closure.
|
||||
|
||||
## Testing
|
||||
|
||||
Four tiers, per [testing policy](../../../../docs/testing.md):
|
||||
|
||||
- **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-dsh-sdk` drives the same fake through the real provider. 100% per-file coverage on all three packages.
|
||||
- **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`) where the child is a REAL second harness runtime with its own cordis.yml; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd. The child launch resolves through `resolveExampleLaunch`, so src/lib modes both hold.
|
||||
- **Keyless snapshot** — `examples/jsonrpc-agent/tests/sdk.snapshot.ts` is the jsonrpc example's first snapshot suite: the real `dsh-jsonrpc-agent` runtime driven through the real `dsh-sdk-client`, replaying recorded fixtures via `llm-replay` behind the new `cordis.snapshot.yml` overlay (passed explicitly through `DSH_CORDIS_CONFIG`; the jsonrpc bin performs no snapshot config swap of its own). Three scenarios — text turn, bash tool, spawn subagent — each pinning the normalized notification stream, the SDK turn result, and the persisted parent+child logs. This also closes the protocol-tier gap the single-exe note's Python-side snapshot left on the vitest side.
|
||||
- **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/python-sdk-agent/tests/fixtures/subagent/subagent-dsh-sdk/`) where the child is a real second `dsh --profile sdk` runtime with its own isolated home and ordered patch; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd.
|
||||
- **Keyless snapshot** — `examples/python-sdk-agent/tests/sdk.snapshot.ts` drives the real `dsh --profile sdk` runtime through the real `dsh-sdk-client`, replaying recorded fixtures through an ordered `llm-replay` patch. Four scenarios — text turn, bash tool, spawn subagent, and the minimal persistent-tool composition — each pin the normalized notification stream, SDK turn result, and persisted parent and child logs. This also closes the protocol-tier gap the single-exe note's Python-side snapshot left on the vitest side.
|
||||
- **With-key e2e** — the snapshot suite's `DSH_SNAPSHOT=record` mode is the live-API path (it produced the committed fixtures); the composition e2e needs no key by design.
|
||||
|
||||
## Alternatives considered
|
||||
@@ -36,7 +36,7 @@ Four tiers, per [testing policy](../../../../docs/testing.md):
|
||||
|
||||
**Fold the SDK backend into `subagent-acp` with a transport switch.** The two backends share the subprocess lifecycle but nothing about the wire (ACP SDK connection vs harness JSON-RPC), the child contract (any ACP agent vs a harness runtime), or the result extraction (`agent_message_chunk` accumulation vs session-event reading). A config discriminant would bury two protocols in one package; the genuinely shared provider-side parts moved into the subagent seam's `out-of-process.ts`, and the process mechanics live in the `dsh-subprocess` seam.
|
||||
|
||||
**Give the TS SDK bundled-runtime resolution parity with Python.** Python's carrier resolution exists to ship wheels to users without Node. A TypeScript consumer definitionally has Node and (in-repo) the workspace; inventing a distribution story with no consumer violates the require-current-need rule. Deferred until a real npm-distribution consumer appears.
|
||||
**Resolve `dsh` only from `PATH`.** Rejected: a Node consumer does not reliably inherit a project-local `.bin` directory. The same-version package dependency supplies the built CLI for installed consumers and the source entry for a clean checkout.
|
||||
|
||||
**Export source modules, normalization helpers, and subscription producer operations.** These are implementation details with no caller need; exposing them would make callers learn how the client validates and distributes wire input. The package roots instead enumerate the supported client and protocol interfaces, and the client re-exports the one protocol error callers must distinguish.
|
||||
|
||||
@@ -44,6 +44,6 @@ Four tiers, per [testing policy](../../../../docs/testing.md):
|
||||
|
||||
## Consequences
|
||||
|
||||
**Bought**: the SDK runtime protocol now has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors, structured turn reasons, and package roots that expose only caller-owned operations; the subagent seam gains a harness-native out-of-process backend whose children are full peers (own config, persistence, tools) — the recursive-composition story the seam note anticipated; the jsonrpc example finally has snapshot coverage, through the SDK path itself.
|
||||
**Bought**: the SDK runtime protocol has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors, structured turn reasons, and package roots that expose only caller-owned operations; the subagent seam has a harness-native out-of-process backend whose children are full peers (own config, persistence, tools); the SDK profile has snapshot coverage through the SDK path itself.
|
||||
|
||||
**Paid**: a third package in the `sdk/` group and a fourth subagent backend to keep current; the SDK backend boots a complete plugin tree per child (heavier per-run than an ACP child; pooling remains future work, same as ACP); the wire still has no cancel method, so both the SDK's `RequestTimeoutError` and the backend's dispose settle locally while the server-side turn runs on until process teardown; fixtures for the snapshot suite were recorded against `deepseek-v4-flash` and re-record on model-behavior drift like every other recorded corpus.
|
||||
|
||||
+7
-7
@@ -13,19 +13,19 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[
|
||||
三个包,分层与既有 Python 栈完全一致,外加一个 Service Provider 注册:
|
||||
|
||||
- **`@deepseek-ai/dsh-sdk-protocol`**(`packages/sdk/protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport` 从 `dsh-sdk-jsonrpc-server` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result`、`SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。该包根显式导出这一完整接口,且不提供指向源模块的深层导入。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data` 的 `JsonRpcResponseError` 拒绝(Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。
|
||||
- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`TurnResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费方的发行问题);`env` 整体替换而非合并(凭据策略归调用方;subprocess seam 的 `scrubbedParentEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(客户端运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize`;`env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。
|
||||
- **`@deepseek-ai/dsh-sdk-client`**(`packages/sdk/client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(spawn、分帧、通知扇出、有类型的错误表面、经共享 dispose(资源释放)阶梯关闭至完全停稳)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize`、`run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。其包根消费方接口显式导出两层客户端、面向调用方的类型,以及协议包所拥有的 `JsonRpcResponseError`;源模块、规范化辅助函数和通知投递端都保留为内部实现。`RunResult.events` 只包含根会话的类型化事件,而 `notifications` 则保留根会话及从 `subagent.started` 发现的后代各自的会话 id;基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。启动接口解析同版本 `@deepseek-ai/dsh` 依赖并选择具名 profile,可选配置包括 `dshBin`、有序 patch、显式 Harness home、进程 cwd、环境和超时;任意 command/argv 启动只作为内部 fake-runtime 适配器。干净 checkout 中若不存在 `lib/bin.js`,client 会通过绝对 `tsx/esm` loader 使用该包的源码入口,并应用一个省略构建期生成 Typert 贡献加载的内部 patch;SDK 协议不消费这些贡献。`env` 整体替换而非合并,并在 `start()` spawn 时读取,因此凭据策略归调用方,且调用方可在首次使用前完成环境准备。`RunResult` 携带结构化 `reason`(Python 只暴露 `status`);拆除走私有的 stdin-EOF → SIGTERM → SIGKILL 阶梯直到真正退出(client 运行在任何 harness 上下文之外,无法搭乘 `ctx.subprocess`)。
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `dshBin`/profile/patch/home 配置选择隔离的 SDK 应用,`provider`/`model` 写入子进程 `initialize`,`env` 则提供子进程专用的显式值,例如其 API key。
|
||||
- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。
|
||||
|
||||
`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致);`dsh-jsonrpc-agent-pkg`(Python 运行时闭包)增加 `dsh-sdk-protocol` 一行依赖。
|
||||
`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致);私有 `@deepseek-ai/dsh-sdk-python-runtime` 载体通过其打包闭包消费共享协议。
|
||||
|
||||
## 测试
|
||||
|
||||
四层,依[测试政策](../../../../docs/testing.zh.md):
|
||||
|
||||
- **免密钥单元**——`sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-dsh-sdk` 经真实提供方驱动同一伪运行时。三个包全部 100% 逐文件覆盖。
|
||||
- **免密钥 Loader 组合**——`subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml(`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/`),其中子进程是真实的第二个 harness 运行时、带自己的 cordis.yml;断言父工具结果与子进程自己持久化的 transcript(文本记录)都携带父会话 cwd。子启动经 `resolveExampleLaunch` 解析,src/lib 两种模式都成立。
|
||||
- **免密钥快照**——`examples/jsonrpc-agent/tests/sdk.snapshot.ts` 是 jsonrpc 示例的第一个快照套件:真实 `dsh-jsonrpc-agent` 运行时经真实 `dsh-sdk-client` 驱动,在新的 `cordis.snapshot.yml` 覆盖层后经 `llm-replay` 回放已录制 fixture(测试前置数据)(经 `DSH_CORDIS_CONFIG` 显式传入;jsonrpc bin 自身不做快照配置切换)。三个场景——文本轮次、bash 工具、spawn subagent——各自钉住规范化通知流、SDK 轮次结果与持久化的父+子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。
|
||||
- **免密钥 Loader 组合**——`subagent-dsh-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml(`examples/python-sdk-agent/tests/fixtures/subagent/subagent-dsh-sdk/`),其中子进程是真实的第二个 `dsh --profile sdk` 运行时,拥有独立 home 与有序 patch;断言父工具结果与子进程自己持久化的 transcript(文本记录)都携带父会话 cwd。
|
||||
- **免密钥快照**——`examples/python-sdk-agent/tests/sdk.snapshot.ts` 通过真实 `dsh-sdk-client` 驱动真实 `dsh --profile sdk` 运行时,并通过有序 `llm-replay` patch 回放已录制 fixture(测试前置数据)。文本轮次、bash 工具、spawn subagent 与极简持久工具组合四个场景分别钉住规范化通知流、SDK 轮次结果,以及持久化的父日志与子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。
|
||||
- **带密钥 e2e**——快照套件的 `DSH_SNAPSHOT=record` 模式即真实 API 路径(已提交 fixture 由它产出);组合 e2e 设计上无需密钥。
|
||||
|
||||
## 考虑过的替代方案
|
||||
@@ -36,7 +36,7 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[
|
||||
|
||||
**把 SDK 后端折进 `subagent-acp`、用传输开关区分。** 两个后端共享子进程生命周期,但协议(ACP SDK 连接 vs harness JSON-RPC)、子进程约定(任意 ACP agent vs harness 运行时)、结果提取(`agent_message_chunk` 累积 vs 会话事件读取)毫无共享。配置判别字段会把两个协议埋进一个包;真正共享的提供方侧部分移入 subagent seam 的 `out-of-process.ts`,进程机制则住在 `dsh-subprocess` seam。
|
||||
|
||||
**给 TS SDK 与 Python 对等的捆绑运行时解析。** Python 的载体解析是为了给没有 Node 的用户发 wheel 包。TypeScript 消费方按定义就有 Node,且仓库内消费方还有工作区;为尚不存在的消费方编造发行方案违反「只实现当前需求」的规则。推迟到真实的 npm 发行消费方出现时再处理。
|
||||
**只从 `PATH` 解析 `dsh`。** 拒绝:Node 消费方不一定继承项目本地 `.bin` 目录。同版本包依赖为已安装消费方提供构建后 CLI,并为干净 checkout 提供源码入口。
|
||||
|
||||
**导出源模块、规范化辅助函数和订阅投递端操作。** 这些都是调用方不需要的实现细节;暴露它们会让调用方不得不理解客户端如何校验与分发协议输入。各包根转而枚举受支持的客户端接口与协议接口,客户端则只重新导出调用方必须区分的那一种协议错误。
|
||||
|
||||
@@ -44,6 +44,6 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[
|
||||
|
||||
## 后果
|
||||
|
||||
**收益**:SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费方获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化轮次原因,包根也只暴露归调用方所有的操作;subagent seam 获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是 seam Agent Note 所设想的递归组合方式;jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。
|
||||
**收益**:SDK 运行时协议拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型;TypeScript 消费方获得与 Python 相同的子进程驱动能力,且带类型化错误与结构化轮次原因,包根也只暴露归调用方所有的操作;subagent seam 拥有一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具);SDK profile 通过 SDK 路径本身获得快照覆盖。
|
||||
|
||||
**代价**:`sdk/` 组多了第三个包、subagent 多了第四个要保持最新的后端;SDK 后端每个子进程启动完整插件树(单次成本高于 ACP 子进程;池化与 ACP 一样留作未来工作);协议仍无取消方法,SDK 的 `RequestTimeoutError` 与后端的 dispose 都只在本地结算、服务器侧轮次会继续运行到进程清理为止;快照 fixture 录制于 `deepseek-v4-flash`,与其他录制语料一样随模型行为漂移而重录。
|
||||
|
||||
@@ -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-30-web-result-card.md
|
||||
2026-07-30-web-result-card.md: 591471d219295019d28b9eeaf2e57f6d2115d380
|
||||
2026-07-30-web-result-card.zh.md: c850c148ba32ca782fd1d6d7e5c3bbfa758391c5
|
||||
2026-07-30-web-result-card.md: 35ad06998136cbffcf4a049cb0c68adb97498b68
|
||||
2026-07-30-web-result-card.zh.md: 81656b5f659996d8a30ee293ccbbf562c7e6dd85
|
||||
|
||||
@@ -22,7 +22,7 @@ Neither result view carries a `content` copy. A UI that does not render the stru
|
||||
|
||||
## Consequences
|
||||
|
||||
The frontend consumer is owned by the [web result card frontend note](2026-07-30-web-result-card-frontend.md): this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change.
|
||||
The frontend consumer is owned by the [web result card frontend note](2026-07-30-web-result-card-frontend.md): this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. Session Controller carries the event's typed `surfaceOp` without redeclaring card tags ([wire type](../../../../packages/api/session-controller/src/types.ts)), so the new view crosses the wire without a schema change.
|
||||
|
||||
A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag.
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Status: implemented
|
||||
|
||||
## Consequences
|
||||
|
||||
前端消费方属于 [Web result card 前端 note](2026-07-30-web-result-card-frontend.zh.md) 的工作范围:本次生产者变更新增约定分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript(文本记录)快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费方都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。
|
||||
前端消费方属于 [Web result card 前端 note](2026-07-30-web-result-card-frontend.zh.md) 的工作范围:本次生产者变更新增约定分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript(文本记录)快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费方都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退。Session Controller 直接携带事件中已类型化的 `surfaceOp`,不重新声明 card 标签([线路类型](../../../../packages/api/session-controller/src/types.ts)),因此新视图无需 schema 变更即可跨 wire。
|
||||
|
||||
未来想用此卡片的 web 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。
|
||||
|
||||
|
||||
@@ -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-02-web-thinking-tail-scroll.md
|
||||
2026-08-02-web-thinking-tail-scroll.md: b9aa47a01b4d8e22baddac1b03f52b3524250941
|
||||
2026-08-02-web-thinking-tail-scroll.zh.md: 41fe29f06202aef3307d756201eb4745fecca537
|
||||
2026-08-02-web-thinking-tail-scroll.md: 38c27274b2c85974044c2bb467c1519e19bfd148
|
||||
2026-08-02-web-thinking-tail-scroll.zh.md: bf637228b3a793f318fa5a5d7b0968ecd8e081d5
|
||||
|
||||
@@ -28,4 +28,4 @@ The collapsed row now communicates provider cadence through content motion as we
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable.
|
||||
`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable.
|
||||
|
||||
@@ -28,4 +28,4 @@ Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的无密钥组装态 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。
|
||||
`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的无密钥组装态 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要约定仍然稳定。
|
||||
|
||||
@@ -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-web-background-job-display.md
|
||||
2026-08-08-web-background-job-display.md: 1af949a537103637e8bac84b5bfa0f915fcf82e0
|
||||
2026-08-08-web-background-job-display.zh.md: 0aa8d65b7697312f603dd9ddd37f182da0b7f39c
|
||||
2026-08-08-web-background-job-display.md: 8da6c2fd914bf07cfa7d3545cff1e42552c69d27
|
||||
2026-08-08-web-background-job-display.zh.md: 0e05ef9d2fcd8193c661f471b5f7b9a84891f98a
|
||||
|
||||
@@ -14,24 +14,24 @@ The session header was already the place where per-session background activity l
|
||||
|
||||
## Decision
|
||||
|
||||
Task state reaches the browser as **one whole-snapshot mux frame per session**, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping.
|
||||
Task state reaches the browser as **one whole-snapshot control frame per session**, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping.
|
||||
|
||||
This ships the list alone. Per-task streamed output and a human-initiated cancellation are separate phases, and the channel is shaped so neither has to undo it.
|
||||
|
||||
### Wire shape
|
||||
|
||||
One frame in the mux stream:
|
||||
One frame in the Session Controller control stream:
|
||||
|
||||
```ts ignore-check
|
||||
| { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] }
|
||||
| { type: 'jobs'; sessionId: SessionId; jobs: SessionJob[] }
|
||||
```
|
||||
|
||||
`JobView` is browser-safe and owned by the carrier at [`packages/host/apiproxy/src/api/jobs.ts`](../../../../packages/host/apiproxy/src/api/jobs.ts), alongside the other domain contracts, with its wire schema beside it in `jobs.schema.ts`:
|
||||
`SessionJob` is browser-safe and owned beside the other Session Remote contracts in [`packages/api/session-controller/src/types.ts`](../../../../packages/api/session-controller/src/types.ts):
|
||||
|
||||
```ts
|
||||
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
|
||||
|
||||
export interface JobView {
|
||||
export interface SessionJob {
|
||||
id: JobId
|
||||
kind: string
|
||||
label: string
|
||||
@@ -48,7 +48,7 @@ export interface JobView {
|
||||
|
||||
Three `JobSnapshot` fields are deliberately absent: `ownerSession` (the frame's `sessionId` already carries it), `reported` (an internal notice-delivery bit with no user meaning), and `outputLimitBytes` (producer-owned model-presentation policy).
|
||||
|
||||
The frame carries a whole snapshot rather than a delta for the reason [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) states for itself: start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small.
|
||||
The frame carries a whole snapshot rather than a delta so start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small.
|
||||
|
||||
### The task-registry change feed
|
||||
|
||||
@@ -66,16 +66,16 @@ The listener is owner-granular rather than task-granular. The only consumer push
|
||||
|
||||
Service disposal deliberately announces nothing. Every `onJobsChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set.
|
||||
|
||||
### The api-proxy carrier
|
||||
### The Session Controller carrier
|
||||
|
||||
`mux()` subscribes `ctx.jobs.onJobsChanged` and pushes `session/jobs`; the subscription baseline rides next to the existing `session/subscribed` control frames, so a reconnecting client is current before it renders.
|
||||
[`SessionControlController.control()`](../../../../packages/api/session-controller/src/control.ts) emits one complete Host-wide baseline before later `jobs` replacement frames. Every physical reconnect opens a new generation, so the client replaces its process-local mirror before applying further changes.
|
||||
|
||||
Four rules the carrier keeps:
|
||||
|
||||
- **Never resume.** A change push reads `jobs.list(owner)` with the exact `Agent` the listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead reads `ctx.jobs.list(ctx.agents.get(session.id))` — the non-resuming registry read, where a session with no live Agent correctly yields only the unowned tasks. Neither path touches the [`api-remotes` Agent resolver](../../../../packages/api/remotes/src/agent-lookup.ts), which resumes a cold session as a side effect of lookup; listing must never revive a session the user merely scrolled past.
|
||||
- **Fan out unowned changes.** An `undefined` owner pushes a fresh snapshot to every subscribed session, because unowned tasks are visible to every caller.
|
||||
- **Stay optional.** The carrier reads `ctx.get('jobs')`. A composition without the registry emits no frames, and the client renders no entry point — the posture `sessionProjections` already has in this file.
|
||||
- **Say nothing about nothing.** The baseline is pushed only for sessions whose list is non-empty, and an absent key on the client means an empty list. A change that empties a list still pushes `[]`, because that one transition is the only thing the client cannot infer from absence.
|
||||
- **Never resume.** A change push reads `jobs.list(owner)` with the exact `Agent` the listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead reads `ctx.jobs.list(ctx.agents.get(session.id))`, where a Session with no live Agent correctly yields only unowned tasks. Neither path calls the [Session Controller Agent resolver](../../../../packages/api/session-controller/src/agent.ts), because listing must never revive a Session the user merely scrolled past.
|
||||
- **Fan out unowned changes.** An `undefined` owner pushes a fresh snapshot to every attached Session, because unowned tasks are visible to every caller.
|
||||
- **Stay optional.** The carrier reads `ctx.get('jobs')`. A composition without the registry reports empty job sets, and the client renders no entry point.
|
||||
- **Represent emptiness explicitly.** The opening baseline contains an entry for every attached Session, including `[]`; a later change that empties one list also pushes `[]`. The client may then normalize an empty set to an absent key without retaining stale rows.
|
||||
|
||||
### The client mirror
|
||||
|
||||
@@ -83,7 +83,7 @@ Four rules the carrier keeps:
|
||||
|
||||
It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel.
|
||||
|
||||
Two clears keep it honest. On re-subscribe the manager drops the session's mirror — the rule `session/queue` already follows, because a fresh baseline is arriving and this generation sends none for an empty set, so a retained list would survive as a phantom. On `host/session-removed` it drops the mirror again: owner disposal already removed the records registry-side, but that lands on the mux stream while the removal frame rides the host stream, so the two have no relative order.
|
||||
Two replacement points keep it honest. Each control-stream generation clears the complete jobs mirror before installing the new baseline's non-empty sets. An `api-session/removed` event also drops that Session's entry, independently of the job-registry disposal notification's ordering.
|
||||
|
||||
### The header action
|
||||
|
||||
@@ -101,7 +101,7 @@ A running one-shot background subagent therefore appears both there and in the s
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Signal frame plus RPC pull, the subagent-catalog shape.** Push a payload-free `jobs-changed` signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts): `catalogInflight` for single-flight, `catalogStale` for a trailing re-pull when a membership frame lands mid-request, `updateCatalogActivity` patching loaded rows in place *and* writing into the in-flight request so a response older than the frame gets overwritten, `parentAvailableOverride` replaying a stale `false`, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window.
|
||||
**Signal frame plus RPC pull, the subagent-catalog shape.** Push a payload-free `jobs-changed` signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in [`SessionManager`](../../../../packages/api/session-controller/src/client/sessions/manager.ts): `catalogInflight` for single-flight, `catalogStale` for a trailing re-pull when a membership frame lands mid-request, `updateCatalogActivity` patching loaded rows in place *and* writing into the in-flight request so a response older than the frame gets overwritten, `parentAvailableOverride` replaying a stale `false`, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window.
|
||||
|
||||
**Popover-scoped polling with no seam change.** Cheapest to build and the only option that avoids touching `JobRegistry`. It cannot support a resident count on the trigger without a resident poll, and both later phases need a real change feed anyway, so it buys a week and spends it back.
|
||||
|
||||
@@ -117,7 +117,7 @@ A running one-shot background subagent therefore appears both there and in the s
|
||||
|
||||
The [web e2e scenario](../../../../apps/web/tests/background-job-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.jobs`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer.
|
||||
|
||||
Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`api-proxy-jobs`](../../../../packages/host/apiproxy/tests/api-proxy-jobs.spec.ts) pins the baseline-only-when-non-empty rule, the three change pushes, the dropped internal fields, the unowned fan-out, the no-resume guarantee, and the registry-absent composition; and the client suites pin the last-wins fold, the absent-key representation, both clears, and the component's ordering, duration, and dismissal behavior.
|
||||
Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) pins the complete baseline, three change pushes, dropped internal fields, unowned fan-out, no-resume guarantee, registry-absent composition, and the prohibition on consuming model output; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -14,24 +14,24 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
任务状态以**每会话一帧的整份快照**到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。
|
||||
任务状态以**每会话一帧的整份 control 快照**到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。
|
||||
|
||||
本次只交付列表。每个任务的流式输出与人类发起的中断是各自独立的阶段,而通道的形状让两者都不必推翻它。
|
||||
|
||||
### 线路形状
|
||||
|
||||
mux 流中的一帧:
|
||||
Session Controller control 流中的一帧:
|
||||
|
||||
```ts ignore-check
|
||||
| { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] }
|
||||
| { type: 'jobs'; sessionId: SessionId; jobs: SessionJob[] }
|
||||
```
|
||||
|
||||
`JobView` 是浏览器安全类型,由载体在 [`packages/host/apiproxy/src/api/jobs.ts`](../../../../packages/host/apiproxy/src/api/jobs.ts) 里拥有,与其他领域契约并列,线路 schema 就在旁边的 `jobs.schema.ts`:
|
||||
`SessionJob` 是浏览器安全类型,与其他 Session Remote 约定一起由 [`packages/api/session-controller/src/types.ts`](../../../../packages/api/session-controller/src/types.ts) 拥有:
|
||||
|
||||
```ts
|
||||
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
|
||||
|
||||
export interface JobView {
|
||||
export interface SessionJob {
|
||||
id: JobId
|
||||
kind: string
|
||||
label: string
|
||||
@@ -48,7 +48,7 @@ export interface JobView {
|
||||
|
||||
`JobSnapshot` 的三个字段被刻意省去:`ownerSession`(帧的 `sessionId` 已经带了)、`reported`(内部的通知投递位,对用户无意义),以及 `outputLimitBytes`(生产者拥有的模型呈现策略)。
|
||||
|
||||
这一帧带整份快照而非增量,理由就是 [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) 为自己写下的那条:启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。
|
||||
这一帧带整份快照而非增量,因此启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。
|
||||
|
||||
### 任务注册表变更订阅
|
||||
|
||||
@@ -66,16 +66,16 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
|
||||
|
||||
服务销毁刻意什么都不通告。每个 `onJobsChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。
|
||||
|
||||
### api-proxy 载体
|
||||
### Session Controller 载体
|
||||
|
||||
`mux()` 订阅 `ctx.jobs.onJobsChanged` 并推送 `session/jobs`;订阅 baseline 紧挨着既有的 `session/subscribed` 控制帧发出,让重连的客户端在渲染前就是最新的。
|
||||
[`SessionControlController.control()`](../../../../packages/api/session-controller/src/control.ts) 先发出一份完整的 Host 范围 baseline,再发送后续 `jobs` 替换帧。每次物理重连都会打开新一代流,因此客户端会先替换进程本地镜像,再应用后续变更。
|
||||
|
||||
载体守着四条规则:
|
||||
|
||||
- **绝不 resume。** 变更推送用监听器给出的确切 `Agent` 调 `jobs.list(owner)`,即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读 `ctx.jobs.list(ctx.agents.get(session.id))`——不触发 resume 的注册表读法,没有活体 Agent 的会话正确地只得到无主任务。两条路径都不碰 [`api-remotes` 的 Agent 解析器](../../../../packages/api/remotes/src/agent-lookup.ts),那个解析器会把查询变成复活冷会话的副作用;列个任务不该让用户随手划过的会话活过来。
|
||||
- **无主变更要扇出。** `owner` 为 `undefined` 时向每一个已订阅会话推一份新快照,因为无主任务对所有调用方可见。
|
||||
- **保持可选。** 载体读 `ctx.get('jobs')`。没有挂注册表的组合不发任何帧,客户端也就不渲染入口——`sessionProjections` 在这个文件里已经是这个姿态。
|
||||
- **没有就不说。** baseline 只为列表非空的会话推送,客户端上键缺失即表示空列表。把列表清空的那次变更仍然推 `[]`,因为这一个转换是客户端唯一无法从「缺失」推断出来的东西。
|
||||
- **绝不 resume。** 变更推送用监听器给出的确切 `Agent` 调 `jobs.list(owner)`,即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读 `ctx.jobs.list(ctx.agents.get(session.id))`,没有 live Agent 的 Session 正确地只得到无主任务。两条路径都不调用 [Session Controller Agent 解析器](../../../../packages/api/session-controller/src/agent.ts),因为列出任务绝不能复活用户随手划过的 Session。
|
||||
- **无主变更要扇出。** `owner` 为 `undefined` 时向每一个已挂接 Session 推一份新快照,因为无主任务对所有调用方可见。
|
||||
- **保持可选。** 载体读 `ctx.get('jobs')`。没有挂注册表的组合报告空任务集,客户端也就不渲染入口。
|
||||
- **显式表示空集。** opening baseline 为每个已挂接 Session 提供一项,包括 `[]`;后续变更清空一个列表时也会推送 `[]`。客户端因此可以把空集归一化为缺失键,而不会保留陈旧行。
|
||||
|
||||
### 客户端镜像
|
||||
|
||||
@@ -83,7 +83,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
|
||||
|
||||
它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务);将来侧栏加指示器时不必再开第二条通道。
|
||||
|
||||
两处清理让它保持诚实。重新订阅时 manager 丢弃该会话的镜像——`session/queue` 已经遵循的规则,因为新的 baseline 正在路上,而这一世代对空集不发 baseline,被留下的列表会变成幽灵。`host/session-removed` 时再丢一次:owner 销毁在注册表侧已经移除了记录,但那件事落在 mux 流上而这一帧走 host 流,两者没有相对顺序。
|
||||
两个替换点让它保持诚实。每一代 control 流都会先清空完整任务镜像,再安装新 baseline 中的非空集合。`api-session/removed` 事件也会删除该 Session 的条目,不依赖任务注册表 disposal 通知与它之间的顺序。
|
||||
|
||||
### header 入口
|
||||
|
||||
@@ -101,7 +101,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
|
||||
|
||||
## 备选方案
|
||||
|
||||
**信号帧加 RPC 拉取,即 subagent 目录的形状。** 推一个无 payload 的 `jobs-changed` 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts) 里一览无余:`catalogInflight` 做单飞行、`catalogStale` 在成员帧落于请求中途时补一次尾拉、`updateCatalogActivity` 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、`parentAvailableOverride` 重放一个过期的 `false`,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。
|
||||
**信号帧加 RPC 拉取,即 subagent 目录的形状。** 推一个无 payload 的 `jobs-changed` 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 [`SessionManager`](../../../../packages/api/session-controller/src/client/sessions/manager.ts) 里一览无余:`catalogInflight` 做单飞行、`catalogStale` 在成员帧落于请求中途时补一次尾拉、`updateCatalogActivity` 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、`parentAvailableOverride` 重放一个过期的 `false`,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。
|
||||
|
||||
**只在弹层打开时轮询,不改 seam。** 最省事,也是唯一不碰 `JobRegistry` 的选项。它无法在不常驻轮询的前提下支持触发器上的常驻计数,而后面两期反正都需要一条真正的变更订阅,所以它省下一周又还回去。
|
||||
|
||||
@@ -117,7 +117,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
|
||||
|
||||
[web e2e 场景](../../../../apps/web/tests/background-job-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.jobs`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。
|
||||
|
||||
在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`api-proxy-jobs`](../../../../packages/host/apiproxy/tests/api-proxy-jobs.spec.ts) 钉住「非空才发 baseline」、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证,以及没有注册表的组合;客户端各套件钉住 last-wins 折叠、缺失键表示、两处清理,以及组件的排序、时长与关闭行为。
|
||||
在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) 钉住完整 baseline、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证、没有注册表的组合,以及不得消费模型输出;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。
|
||||
|
||||
## 影响
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md
|
||||
2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 2f26dda5ddc905076dbc2f6c681f462973bde793
|
||||
2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 1931a7468029e145d8e4792da1f889f2bc59d6d5
|
||||
2026-08-11-minimal-profiles-bare-two-tool-runtime.md: 7d068aebb6642602aac0a039c8635acf555ccfe8
|
||||
2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: c32a9a2e09ff0acda592062f780427c636fd65dd
|
||||
|
||||
+2
-2
@@ -14,9 +14,9 @@ The two launch paths also have different configuration owners. Web mounts a per-
|
||||
|
||||
Both shipped minimal profiles expose exactly persistent `bash` and `str_replace_editor`, mount no context-compaction provider, suppress every `dsh-system-prompt` runtime-context contribution for fresh sessions, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) and applies runtime-context suppression only to that agent scope. The standalone spine forwards the same setting to its process-owned system-prompt service. Sandbox and approval services remain mounted and enforce their policies; only their model-facing dynamic context is absent.
|
||||
|
||||
The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) remains a complete JSON-RPC process composition. It mounts `dsh-sdk-jsonrpc-server`, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the deployment's danger-full-access sandbox policy; the editor is not confined by that policy.
|
||||
The standalone [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) remains a complete JSON-RPC process composition. It mounts `dsh-sdk-jsonrpc-server`, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence. It does not mount `token-meter`, `compaction-basic`, `fs-sandbox`, or `fs-observation-policy`. Persistent Bash still consumes the deployment's danger-full-access sandbox policy; the editor is not confined by that policy.
|
||||
|
||||
`DSH_SYSTEM_PROMPT` selects the standalone persona. `DSH_MODEL` names the DeepSeek provider catalog entry, and `DSH_CONTEXT_WINDOW` supplies that entry's capacity. Because the SDK client owns the JSON-RPC `initialize` request, [`minimal.py`](../../../../examples/jsonrpc-agent/minimal.py) also uses `DSH_MODEL` as its default `model` argument; an explicit `--model` remains authoritative. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path.
|
||||
`DSH_SYSTEM_PROMPT` selects the standalone persona. `DSH_MODEL` names the DeepSeek provider catalog entry, and `DSH_CONTEXT_WINDOW` supplies that entry's capacity. Because the SDK client owns the JSON-RPC `initialize` request, [`minimal.py`](../../../../examples/python-sdk-agent/minimal.py) also uses `DSH_MODEL` as its default `model` argument; an explicit `--model` remains authoritative. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
+2
-2
@@ -14,9 +14,9 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash`
|
||||
|
||||
两种随附 minimal profile 都只对外提供持久 `bash` 与 `str_replace_editor`,不挂载上下文压缩提供方,为新建会话抑制每个 `dsh-system-prompt` runtime-context 贡献,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)所拥有的固定 complete 提示词,并仅为该 agent 作用域实施 runtime-context 抑制。独立 spine 将同一设置转发给其进程拥有的 system-prompt 服务。沙箱与批准服务仍保持挂载并强制其策略;只有它们面向模型的动态上下文缺席。
|
||||
|
||||
独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 仍是完整的 JSON-RPC 进程组合。它挂载 `dsh-sdk-jsonrpc-server`、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及未压缩的 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费部署的 danger-full-access 沙箱策略;编辑器不受该策略限制。
|
||||
独立的 [`minimal.cordis.yml`](../../../../examples/python-sdk-agent/minimal.cordis.yml) 仍是完整的 JSON-RPC 进程组合。它挂载 `dsh-sdk-jsonrpc-server`、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及未压缩的 JSONL 持久化。它不挂载 `token-meter`、`compaction-basic`、`fs-sandbox` 或 `fs-observation-policy`。持久 Bash 仍消费部署的 danger-full-access 沙箱策略;编辑器不受该策略限制。
|
||||
|
||||
`DSH_SYSTEM_PROMPT` 选择独立组合的 persona。`DSH_MODEL` 命名 DeepSeek 提供方目录项,`DSH_CONTEXT_WINDOW` 提供该目录项的容量。由于 SDK 客户端拥有 JSON-RPC `initialize` 请求,[`minimal.py`](../../../../examples/jsonrpc-agent/minimal.py)也使用 `DSH_MODEL` 作为 `model` 参数的默认值;显式 `--model` 仍具有最高优先级。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。
|
||||
`DSH_SYSTEM_PROMPT` 选择独立组合的 persona。`DSH_MODEL` 命名 DeepSeek 提供方目录项,`DSH_CONTEXT_WINDOW` 提供该目录项的容量。由于 SDK 客户端拥有 JSON-RPC `initialize` 请求,[`minimal.py`](../../../../examples/python-sdk-agent/minimal.py)也使用 `DSH_MODEL` 作为 `model` 参数的默认值;显式 `--model` 仍具有最高优先级。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -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-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
|
||||
2026-08-18-pi-ai-wire-compat-surface.md: 280ce2dd16bb7f89f238c8e0fab07ed74de943f1
|
||||
2026-08-18-pi-ai-wire-compat-surface.zh.md: 1a5704dc8c462a8007d1129de4ec7a51e9bd22a4
|
||||
|
||||
@@ -14,13 +14,13 @@ Writing the field anyway was worse than unsupported. schemastery passes unknown
|
||||
|
||||
## Decision
|
||||
|
||||
One drift gate per pi-ai compat type — keyed `Record<keyof OpenAICompletionsCompat | …, CompatDisposition>` — 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.
|
||||
One drift gate per pi-ai compat type — keyed `Record<keyof OpenAICompletionsCompat | …, CompatDisposition>` — classifies every upstream field as `offer` or `withhold`. Thirty-four distinct fields, twenty-three 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<PiAiCompatProfile>`, 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.
|
||||
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` and `chatTemplateArgs` are offered, which makes the two `chat-template` formats and `baseten` nameable; nothing cross-checks those pairings, 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.
|
||||
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 absent dicts as `{}` and both template-argument fields are present on every parsed profile whether or not anyone wrote them.
|
||||
|
||||
## Where a refusal lands
|
||||
|
||||
@@ -45,7 +45,7 @@ An external edit to the settings file is the one path that cannot report: the pr
|
||||
## 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.
|
||||
- A pi-ai upgrade that adds a compat field or thinking format fails the build until someone classifies it; the gates include both template-argument fields and every current format.
|
||||
- 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`.
|
||||
|
||||
@@ -14,13 +14,13 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提
|
||||
|
||||
## Decision
|
||||
|
||||
每个 pi-ai compat 类型一张漂移门禁——以 `Record<keyof OpenAICompletionsCompat | …, CompatDisposition>` 为键——把每一个上游字段分类为 `offer` 或 `withhold`。去重后三十个字段,开放二十个。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。
|
||||
每个 pi-ai compat 类型一张漂移门禁——以 `Record<keyof OpenAICompletionsCompat | …, CompatDisposition>` 为键——把每一个上游字段分类为 `offer` 或 `withhold`。去重后三十四个字段,开放二十三个。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。
|
||||
|
||||
`PiAiCompatProfile` 保持为带逐字段 JSDoc 的显式 interface——它是配置界面所渲染、也是 `docs/config-catalog.md` 所粘贴的东西——并由一个作用在对称差上的类型级 `AssertNever` 证明它恰好命名了开放集。schemastery schema 声明为 `z<PiAiCompatProfile>`,而使这条标注在两个方向上都真正吃劲的是 `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 **类型**而非协议名:pi-ai 让 `openai-responses`、`azure-openai-responses` 与 `openai-codex-responses` 共用同一个 `OpenAIResponsesCompat`,因此可设在其中之一的开关,三者皆可设。仅按协议名归组曾使两条随附的 catalog 路由拿不到其自身模型所声明的字段。协议集派生自 `Model.compat` 自身的条件类型,因此某个版本若给别的协议加上 compat 类型,门禁列表会以点名的方式失败。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。`chatTemplateKwargs` 与 `chatTemplateArgs` 予以开放,因此两个 `chat-template` 格式和 `baseten` 都可命名;这些配对不做交叉校验,因为实际生效的格式可能来自 catalog 条目或 pi-ai 的检测,而解析读不到那两层。
|
||||
|
||||
三类 `compat` 键在其被写下之处遭到拒绝而非丢弃:没有任何协议声明的键、被门禁扣留的键,以及完全没有写值的键。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。无值那一类是必须失败而不能忽略的:schemastery 会把 YAML 裸键放行为 null,照单收下就会用 null 写覆盖已安装 catalog 的值,随后 pi-ai 的 `??` 转而去够它的 baseURL 检测,catalog 这一层被整个跳过。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,`chatTemplateKwargs` 都出现在每一个解析过的 profile 上。
|
||||
三类 `compat` 键在其被写下之处遭到拒绝而非丢弃:没有任何协议声明的键、被门禁扣留的键,以及完全没有写值的键。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。无值那一类是必须失败而不能忽略的:schemastery 会把 YAML 裸键放行为 null,照单收下就会用 null 写覆盖已安装 catalog 的值,随后 pi-ai 的 `??` 转而去够它的 baseURL 检测,catalog 这一层被整个跳过。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,两个模板参数字段都会出现在每一个解析过的 profile 上。
|
||||
|
||||
## Where a refusal lands
|
||||
|
||||
@@ -45,7 +45,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提
|
||||
## Consequences
|
||||
|
||||
- 拒绝 `developer` 角色、`max_completion_tokens`、`store`、`stream_options` 或 `strict` 的 OpenAI 兼容网关,如今属于配置问题而非无法接入的提供方;拒绝 `temperature` 或工具 `cache_control` 的 Anthropic 兼容网关同理。
|
||||
- pi-ai 升级新增 compat 字段会使构建失败,直到有人为它做出分类——`chatTemplateKwargs` 与那两个 `chat-template` 格式正是因此不再是一项长期例外。
|
||||
- pi-ai 升级新增 compat 字段或思考格式会使构建失败,直到有人为它做出分类;门禁包含两个模板参数字段和当前每一种格式。
|
||||
- 未知 compat 键并入了其余所有配置错误的失败模型。相对此前静默丢弃的改善程度受 settings seam 限制:外部文件编辑仍会保留其上一个有效值并告警,因此运维拿到的信号是一次重启,而不是那次写入。
|
||||
- **搁置而非解决:** 改指 `api` 且完全未配置 compat 的路由,会经模型字面量的 `...base` 展开保留已安装条目的 `compat`,且形状属于**另一个**协议。多个 compat 类型共有的字段(`supportsLongCacheRetention`、`sendSessionAffinityHeaders`)因而会跨协议串味。它早于本面存在——其所依附的提前返回本就在那里——留给独立的一次改动处理。
|
||||
- **搁置而非解决:** `publish()` 对被拒绝的已存 section 只通过 `ctx.logger.warn` 报告,没有面向用户的通道。它影响每一个 settings namespace,归属 `dsh-settings`。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-22-fire-and-forget-webhook-sessions.md
|
||||
2026-08-22-fire-and-forget-webhook-sessions.md: 976bccd8b460de7cb696ee45ea8963710cc4c738
|
||||
2026-08-22-fire-and-forget-webhook-sessions.zh.md: f015d993e61864bbc21d9d55fc331c25118fbde3
|
||||
@@ -0,0 +1,58 @@
|
||||
# Agent Note: Fire-and-forget webhook Sessions
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-22-fire-and-forget-webhook-sessions.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
External repository events need to start ordinary DSH work without making every provider adapter understand Agent presets, Workspace attachment, titles, permissions, and callback teardown. GitHub pull requests becoming ready for review are the first use: a signed event may create a review Session that users can browse under the repository Workspace.
|
||||
|
||||
Turning this into a durable automation engine would introduce a second lifecycle beside Sessions: delivery records, execution states, retry and deduplication policy, crash recovery, and an answer to whether HTTP acceptance, prompt admission, Agent idle, or model output means completion. The requested capability needs none of those meanings.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-webhook` owns a two-operation Host runtime: rules register through `register()`, and authenticated provider adapters call `dispatch()`. Each matching callback runs independently as arbitrary trusted code and returns `null` or one Workspace-backed Session request. Dispatch returns before callbacks settle, while effect disposal aborts and drains only the calls it owns.
|
||||
|
||||
The runtime stores no provider delivery or execution record. It does not retry, deduplicate, resume callback work, observe Agent status, or collect a result. A repeated delivery may create another Session. `WebhookDeliveryId` remains available to a rule that deliberately implements idempotency through its own state.
|
||||
|
||||
## Provider adapters
|
||||
|
||||
Authentication belongs to provider adapters. `@deepseek-ai/dsh-webhook-github` registers one exact route on an injected WebServer, bounds the untouched UTF-8 body, resolves its secret reference per request, verifies `X-Hub-Signature-256` before parsing, and passes a signed lossless-JSON object to the runtime. `202` means only verified in-memory dispatch; it precedes rule matching, external calls, and Session creation.
|
||||
|
||||
The normal Web composition keeps its UI/API WebServer separate. The GitHub example mounts another WebServer and its adapter in a group that isolates only `webServer`, so a reverse proxy can expose the webhook port without exposing `/api`, WebSockets, or frontend files.
|
||||
|
||||
Patch loading anchors relative plugin names in inserted rows to the patch file. The same `./github-ready-review-rule.mjs` entry therefore works from a development `--patch` overlay and from a permanent profile patch without changing the rule into a package.
|
||||
|
||||
## Session creation
|
||||
|
||||
A rule result names a local Workspace path, title, text prompt, agent preset, permission preset, and optional explicit provider/model route with an output cap. Without that route, the runtime snapshots the complete live default, including reasoning effort, until the first request records its durable header. It validates presets before mutation, resolves or creates the canonical Workspace, creates the Agent with that path as Session cwd, mounts the preset before publication, and attaches the Session before admitting the prompt.
|
||||
|
||||
The initial follow-up is an ordinary durable user-role message with webhook provider, source, delivery, and rule provenance. Its inbox insertion is the webhook operation's last boundary. Ordinary Session persistence and Agent lifecycle own later work; the runtime neither flushes specially nor waits for a turn.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Persist deliveries and execution states.** Rejected because `pending`, `admitted`, `running`, and `settled` require retry, deduplication, crash, and completion semantics that the current capability does not consume.
|
||||
|
||||
**Acknowledge GitHub after Session creation.** Rejected because arbitrary rules may call external systems and exceed the provider's HTTP window; a valid delivery should not couple transport availability to later rule work.
|
||||
|
||||
**Register the route on the main WebServer.** Rejected because operators need to expose webhook ingress without also exposing the browser API. An isolated second instance reuses the existing HTTP module without creating another server implementation.
|
||||
|
||||
**Restrict rules to a declarative predicate language.** Rejected because programmatic rules explicitly need arbitrary external calls. Trusted Cordis plugins already provide the required authority and lifecycle.
|
||||
|
||||
**Let each adapter create Sessions directly.** Rejected because Workspace, preset, permission, title, rollback, and provenance logic would spread across provider packages.
|
||||
|
||||
## Verification
|
||||
|
||||
Package tests pin independent callback execution, fire-and-forget HTTP timing, cancellation and quiescent disposal, request validation, Workspace attachment before prompt admission, rollback, GitHub HMAC and body limits, credential rotation, and exact Loader composition. The assembled Web example sends a signed ready-for-review delivery to an isolated second listener and records the resulting ordinary Workspace conversation.
|
||||
|
||||
A real-API e2e test starts the built `dsh web` CLI with the webhook overlay and isolated listener, synthesizes only the signed inbound GitHub delivery, observes Workspace attachment and durable provenance through the public Web API, and waits for the real DeepSeek response. No DSH service, model adapter, or provider call is replaced by a test double.
|
||||
|
||||
Source audits keep execution records, retry timers, dedupe maps, completion events, and Agent-status listeners absent.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Provider adapters stay small and provider-specific while Session creation has one owner.
|
||||
- Users receive ordinary titled Sessions under Web Workspaces rather than a second automation UI.
|
||||
- HTTP success intentionally says nothing about downstream matching or Agent success.
|
||||
- Crashes and repeated deliveries retain simple at-most-process-lifetime semantics; deployments needing durable automation must add a separately designed subsystem rather than reinterpret this runtime.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Agent Note: Fire-and-forget webhook Session
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-22-fire-and-forget-webhook-sessions.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
外部仓库事件需要启动普通 DSH 工作,同时不能让每个提供方适配器都理解 Agent preset、Workspace 附加、标题、权限与回调 teardown。GitHub pull request 变为 ready for review 是第一个用途:签名事件可以创建一个评审 Session,用户能在仓库 Workspace 下浏览它。
|
||||
|
||||
如果把它变成持久自动化引擎,就会在 Session 旁引入第二套生命周期:交付记录、执行状态、重试与去重策略、崩溃恢复,以及 HTTP 接受、提示词接纳、Agent idle 或模型输出中究竟哪个表示完成。所请求能力不需要其中任何含义。
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-webhook` 拥有只有两个操作的 Host runtime:规则通过 `register()` 注册,已验证身份的提供方适配器调用 `dispatch()`。每个匹配回调都作为任意受信任代码独立运行,并返回 `null` 或一个基于 Workspace 的 Session 请求。dispatch 会在回调结算前返回,而 effect disposer 只中止并排空自己拥有的调用。
|
||||
|
||||
runtime 不存储提供方交付或执行记录。它不重试、不去重、不恢复回调工作、不观察 Agent 状态,也不收集结果。重复交付可能创建另一个 Session。`WebhookDeliveryId` 仍可供有意通过自有状态实现幂等性的规则使用。
|
||||
|
||||
## Provider adapters
|
||||
|
||||
身份验证属于提供方适配器。`@deepseek-ai/dsh-webhook-github` 会在注入的 WebServer 上注册一条精确路由,限制未改动的 UTF-8 body,为每次请求解析密钥引用,在解析前验证 `X-Hub-Signature-256`,并把签名无损 JSON 对象交给 runtime。`202` 只表示已验证的内存分发;它先于规则匹配、外部调用和 Session 创建。
|
||||
|
||||
普通 Web 组合保持其 UI/API WebServer 独立。GitHub 示例会把另一个 WebServer 及其适配器挂载到只隔离 `webServer` 的 group 中,因此反向代理可以暴露 webhook 端口,而不暴露 `/api`、WebSocket 或前端文件。
|
||||
|
||||
Patch 加载会把插入行中的相对插件名锚定到 patch 文件。因而同一个 `./github-ready-review-rule.mjs` 条目既可用于开发环境的 `--patch` overlay,也可用于永久 profile patch,而无需把规则改成软件包。
|
||||
|
||||
## Session creation
|
||||
|
||||
规则结果会指定本地 Workspace 路径、标题、文本提示词、agent preset、permission preset,以及可选的明确提供方/模型路由与输出上限。没有明确路由时,runtime 会快照包含推理强度的完整实时默认选择,直到首个请求记录其持久 header。runtime 会在变更状态前验证 preset,解析或创建规范 Workspace,以该路径作为 Session cwd 创建 Agent,在发布前挂载 preset,并在接纳提示词前附加 Session。
|
||||
|
||||
初始 follow-up 是普通持久 user-role 消息,并携带 webhook 提供方、来源、交付和规则来源信息。它的 inbox 插入是 webhook 操作的最后边界。之后的工作由普通 Session persistence 与 Agent 生命周期拥有;runtime 既不执行特殊 flush,也不等待轮次。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**持久化交付与执行状态。** 否决,因为 `pending`、`admitted`、`running` 与 `settled` 需要当前能力没有消费方的重试、去重、崩溃和完成语义。
|
||||
|
||||
**在 Session 创建后再向 GitHub 确认。** 否决,因为任意规则可能调用外部系统并超过提供方 HTTP 时间窗;有效交付不应把传输可用性与后续规则工作耦合。
|
||||
|
||||
**在主 WebServer 上注册路由。** 否决,因为操作者需要暴露 webhook 入口而不同时暴露浏览器 API。隔离的第二个实例会复用现有 HTTP 模块,而不会创建另一套服务器实现。
|
||||
|
||||
**把规则限制为声明式谓词语言。** 否决,因为程序化规则明确需要任意外部调用。受信任 Cordis 插件已经提供所需权限与生命周期。
|
||||
|
||||
**让每个适配器直接创建 Session。** 否决,因为 Workspace、preset、权限、标题、rollback 与来源信息逻辑会散布到各提供方包。
|
||||
|
||||
## Verification
|
||||
|
||||
包级测试固定独立回调执行、fire-and-forget HTTP 时序、取消与静止态释放、请求验证、提示词接纳前的 Workspace 附加、rollback、GitHub HMAC 与 body 限制、凭据轮换和精确 Loader 组合。组装 Web 示例会向隔离的第二监听器发送签名 ready-for-review 交付,并记录所得普通 Workspace 对话。
|
||||
|
||||
真实 API e2e 测试会通过带 webhook overlay 与隔离监听器的构建产物启动 `dsh web` CLI(命令行界面),只合成带签名的入站 GitHub 交付,通过公开 Web API 观察 Workspace 附加与持久来源信息,并等待真实 DeepSeek 响应。测试不会用 test double 替换任何 DSH 服务、模型适配器或提供方调用。
|
||||
|
||||
源码审计会保持执行记录、重试 timer、去重 map、完成事件与 Agent 状态监听器不存在。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 提供方适配器保持小而且只含提供方逻辑,Session 创建只有一个 owner。
|
||||
- 用户在 Web Workspace 下获得普通带标题 Session,而不是第二套自动化 UI。
|
||||
- HTTP 成功刻意不说明下游匹配或 Agent 成功。
|
||||
- 崩溃与重复交付保持简单的进程生命周期内语义;需要持久自动化的部署必须增加单独设计的子系统,而不是重新解释此 runtime。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user