Merge remote-tracking branch 'origin/master' into fs-overwrite-diff-bound-v2
# Conflicts: # docs/subsystems/lsp.i18n.yaml # packages/fs/fs-local/README.i18n.yaml # packages/fs/fs-local/README.md # packages/fs/fs-local/README.zh.md # packages/fs/fs-local/src/index.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: 229feae568ba6e40a9c633696097eff46fd5bc95
|
||||
README.zh.md: b84aef020a7e3edf305df709d399fbc7b093b6a3
|
||||
README.md: 7589f37b174b94558bff9f74f860bb4b42705fb8
|
||||
README.zh.md: 4e99dd9313cca89e84ae0bfaf4a2179d726da864
|
||||
|
||||
@@ -6,7 +6,7 @@ Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and fun
|
||||
|
||||
## Hierarchy
|
||||
|
||||
Packages live at `packages/<group>/<pkg>/`; groups are containers, while names remain `@deepseek-ai/dsh-<pkg>`. **Each group README is the canonical package/ctx-key map.**
|
||||
Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Group READMEs own package/ctx-key maps.**
|
||||
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
@@ -16,6 +16,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface |
|
||||
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`e2b/`](e2b/README.md) | E2B providers | POC |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
|
||||
@@ -33,23 +34,20 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call `tools/execute` deadline enforcement | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene advisory repeat-call reminders | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection, temporary Plugins, restricted repository Plugin loading | Product — stable surface |
|
||||
| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
|
||||
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
|
||||
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
|
||||
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface |
|
||||
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable surface |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | JSON-RPC integration, approval/interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable surface |
|
||||
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
|
||||
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 层级结构
|
||||
|
||||
包位于 `packages/<group>/<pkg>/`;组是容器,包名仍为 `@deepseek-ai/dsh-<pkg>`。**每个组 README 是规范的包/ctx 键映射。**
|
||||
包按组置于 `packages/<group>/<pkg>/`;包名仍为 `@deepseek-ai/dsh-<pkg>`。**组 README 负责包/ctx 键映射。**
|
||||
|
||||
| 组 | 职责 | 发布预期 |
|
||||
|---|---|---|
|
||||
@@ -16,6 +16,7 @@
|
||||
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定表面 |
|
||||
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定表面 |
|
||||
| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 |
|
||||
| [`e2b/`](e2b/README.md) | E2B 提供方 | POC |
|
||||
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 |
|
||||
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
|
||||
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 |
|
||||
@@ -33,23 +34,20 @@
|
||||
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
|
||||
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 |
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
|
||||
| [`timeout/`](timeout/README.md) | 工具调用 `tools/execute` 截止时间强制执行 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生建议性重复调用提醒 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定表面 |
|
||||
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定表面 |
|
||||
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检、临时 Plugin、受限 repository Plugin 加载 | 产品:稳定表面 |
|
||||
| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限 repository Plugin 加载 | 产品:稳定表面 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 |
|
||||
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
|
||||
| [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 |
|
||||
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定表面 |
|
||||
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
|
||||
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
|
||||
| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 |
|
||||
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` provider | 产品:稳定表面 |
|
||||
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
|
||||
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
|
||||
| [`scaffold/`](scaffold/README.md) | 创建/启动/驱动项目的工具:helper、启动器、初始化器、带两端的通信协议、启动器 telemetry | 产品:稳定表面 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
|
||||
| [`ui/`](ui/README.md) | JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
|
||||
| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、用户问答工具 | 产品:稳定表面 |
|
||||
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定表面 |
|
||||
| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
|
||||
| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
|
||||
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/acp/README.md
|
||||
README.md: 3ba247598f29f2244456061fe7f9a3282086148f
|
||||
README.zh.md: c13bc05b12fff2ef97b4d547936aa14a6556430a
|
||||
README.md: 97af6d164b265bf0e98e3c9f5a444cffad4face5
|
||||
README.zh.md: 266b108c108b9f543d93cbb727ddbc01ab4bef36
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The ACP group exposes harness agents to programmatic clients. It is an interoperability transport, not a presentation or human-interaction layer.
|
||||
The ACP group exposes harness agents to programmatic clients over the Agent Client Protocol. It is an interoperability transport, not a presentation or human-interaction layer; the matching out-of-process subagent *client* lives in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface.
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | Automation-only ACP server. |
|
||||
|
||||
The matching out-of-process subagent client remains in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface; arbitrary ACP clients may drive the same server contract.
|
||||
The server contract is documented in [`acp/README.md`](acp/README.md).
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开给程序化客户端。它是互操作传输层,而非展示层或人机交互层。
|
||||
ACP 组通过 Agent Client Protocol 把 harness agent 暴露给编程客户端。它是互操作传输层,不是展示或人机交互层;配对的进程外 subagent *客户端*在 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现的是 subagent 提供方接口。
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器。 |
|
||||
|
||||
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器。
|
||||
服务器契约见 [`acp/README.md`](acp/README.md)。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/README.md
|
||||
README.md: 601782caad24d3555a206365a3f1954d81af1cf0
|
||||
README.zh.md: 8b96c4f80ba8776bfd8bdde178178cea946ff36c
|
||||
README.md: 7395a051742af24b96842966b71297dc01853dca
|
||||
README.zh.md: 1d5825e077c46284dbd4895e285cda8f39c252a3
|
||||
|
||||
@@ -15,3 +15,5 @@ The capability family spans the canonical executor seam, its implementations, th
|
||||
| [`tool-pwsh/`](tool-pwsh/README.md) | Exposes PowerShell execution to the model. | (registers on `ctx.tools`) |
|
||||
|
||||
A leaf `cordis.yml` selects one executor implementation and the model-facing tools it needs. A sandboxed composition also selects a `ctx.sandbox` provider; the [ACP example](../../examples/acp-agent/) shows one complete wiring.
|
||||
|
||||
The subsystem reference — request/spec vocabulary, results, background processes, the service, and events — is [docs/subsystems/bash.md](../../docs/subsystems/bash.md).
|
||||
|
||||
@@ -15,3 +15,5 @@
|
||||
| [`tool-pwsh/`](tool-pwsh/README.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools`) |
|
||||
|
||||
叶节点 `cordis.yml` 选择一个执行器实现和所需的面向模型工具。沙箱化组合还会选择一个 `ctx.sandbox` 提供方;[ACP(Agent Client Protocol)示例](../../examples/acp-agent/)展示一套完整接线。
|
||||
|
||||
子系统参考——请求/spec 词汇、结果、后台进程、服务与事件——见 [docs/subsystems/bash.md](../../docs/subsystems/bash.md)。
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
|
||||
README.md: 88f519a21a0889d6b7649502c51077940c23709f
|
||||
README.zh.md: 294044692133da8baa57583146352e84c1ff9946
|
||||
README.md: 690f4e61740faf2648ecbc7f5995ec0fdaa64aee
|
||||
README.zh.md: c343e17bfd97eaf17316c034c263b0c71bb8327a
|
||||
|
||||
@@ -31,7 +31,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
|
||||
|
||||
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
|
||||
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/bash.md](../../../docs/subsystems/bash.md).
|
||||
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
`BashExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
|
||||
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [subsystems/bash.md](../../../docs/subsystems/bash.md)。
|
||||
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ describe('spawn construction (pure, every platform)', () => {
|
||||
/** A subprocess service that records spawn specs and settles instantly. */
|
||||
class CapturingSubprocessService extends SubprocessService {
|
||||
specs: SubprocessSpawnSpec[] = []
|
||||
override async resolveExecutable(command: string): Promise<string> { return command }
|
||||
override spawnTerminal(): Promise<never> { throw new Error('pwsh spawns pipes, never terminals') }
|
||||
private readonly reader: SubprocessOutputReader = {
|
||||
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"path": "../../bash/bash-env"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/README.md
|
||||
README.md: 15754410a4a81eb3fc898dd55269ddd1637e1dab
|
||||
README.zh.md: 4023b80085998f57ed321bfda3a0abdd08b70a28
|
||||
# pnpm run verify-translation-pairing --write packages/boot/README.md
|
||||
README.md: 5e4e483b60adab0b22ddb5279f4cd8fb699b9c35
|
||||
README.zh.md: d49afa45b9fb93f6fa83944167057f0c933cd05d
|
||||
11
packages/boot/README.md
Normal file
11
packages/boot/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# boot/ — shared app-bin boot glue
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/`](../scaffold/README.md) launcher, and the [`examples/`](../examples/README.md) demo bins all consume it.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md).
|
||||
11
packages/boot/README.zh.md
Normal file
11
packages/boot/README.zh.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# boot/:共享的 app bin 启动粘合层
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
各 app bin 共享的、与渠道无关的启动库:`apps/cli`、[`scaffold/`](../scaffold/README.md) 启动器与 [`examples/`](../examples/README.md) demo bin 都消费它。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
|
||||
|
||||
启动序列与个人配置契约见 [`app-boot/README.md`](app-boot/README.md)。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
|
||||
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
|
||||
README.md: c256b89288e3e384c1dd3e64629a06d7cfef31f6
|
||||
README.zh.md: 88d1c4ad0ced2f5a6440a1b64e34e738a842f938
|
||||
@@ -641,7 +641,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
|
||||
/**
|
||||
* Value mirrors used because Cordis's const enum has no runtime object to import.
|
||||
* Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and
|
||||
* Keep aligned with `packages/self-modification/tool-cordis/src/fiber-state.ts` and
|
||||
* `packages/client/web/src/loader-status.ts`.
|
||||
*/
|
||||
const FIBER_PENDING = 0 as FiberState.PENDING
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bundle/README.md
|
||||
README.md: 4759170435a80e85731446cef21d24fff2abed66
|
||||
README.zh.md: 1ef610a1b7b3c591c9a900e04f2d8096b0b086b9
|
||||
README.md: af06fe921a66378894a4ab0174ef403499f5f68c
|
||||
README.zh.md: ef2027e347b79e14574ba1164aa9fddd364327e9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../ui/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts.
|
||||
Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../boot/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 契约](../ui/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。
|
||||
Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 契约](../boot/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。
|
||||
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/README.md
|
||||
README.md: b6fa426fbe541e2b22d2bf5f19d4397361cf0899
|
||||
README.zh.md: 5a55bb8c2c31b5215fc73e75e1c4f3aca79add64
|
||||
README.md: 56b9363cc724515ecbd11127ea4c13aba84283df
|
||||
README.zh.md: edd378bf80b63e9ab2481ab555320ed18de85139
|
||||
|
||||
@@ -38,3 +38,5 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
|
||||
|
||||
Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.
|
||||
|
||||
The subsystem reference is [client-modules.md](../../docs/subsystems/client-modules.md); the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive slot model, and the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer.
|
||||
|
||||
@@ -38,3 +38,5 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
|
||||
|
||||
每个子文档负责自身的契约和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。
|
||||
|
||||
子系统参考是 [client-modules.md](../../docs/subsystems/client-modules.md);[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威 slot 模型,[web 客户端架构说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)拥有加载链与对象层。
|
||||
|
||||
@@ -2203,6 +2203,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
prompt: request => Promise.resolve(ok(request, {
|
||||
messageId: `fixture-message-${request.payload.childSessionId}` as never,
|
||||
})),
|
||||
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
@@ -2748,6 +2749,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'subagent.list': return this.api.subagents.list(request)
|
||||
case 'subagent.history': return this.api.subagents.history(request)
|
||||
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
|
||||
case 'subagent.interrupt': return this.api.subagents.interrupt(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
|
||||
|
||||
@@ -126,6 +126,9 @@ export class FakeApiClient implements IApiClient {
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
|
||||
messageId: 'fake-message' as never,
|
||||
}))),
|
||||
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({
|
||||
accepted: true as const,
|
||||
}))),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
@@ -28,10 +28,10 @@
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
"path": "../../interaction/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -281,17 +281,22 @@ export class Session implements SessionFace {
|
||||
|
||||
/**
|
||||
* Stop the active turn while the Host preserves pending inbox work; failures
|
||||
* land in promptError (same error-strip display slot).
|
||||
* land in promptError (same error-strip display slot). A continuable
|
||||
* subagent address routes through `subagent.interrupt`, whose durable
|
||||
* parent-address authority works without a live parent Agent; a one-shot
|
||||
* address stays uncancellable (the UI offers no stop action, so this arm is
|
||||
* defensive).
|
||||
* @returns the cancel result.
|
||||
*/
|
||||
async cancel(): Promise<RpcResult<{ accepted: true }>> {
|
||||
if (this.address !== undefined) {
|
||||
const address = this.address
|
||||
if (address !== undefined && address.mode === 'one-shot') {
|
||||
const result: RpcResult<{ accepted: true }> = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-delivery-unavailable',
|
||||
message: 'subagent activation cancellation is unavailable',
|
||||
details: { childSessionId: this.address.childSessionId },
|
||||
details: { childSessionId: address.childSessionId },
|
||||
},
|
||||
}
|
||||
this.promptError = { op: 'stop', error: result.error }
|
||||
@@ -300,7 +305,9 @@ export class Session implements SessionFace {
|
||||
}
|
||||
let result: RpcResult<{ accepted: true }>
|
||||
try {
|
||||
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
|
||||
result = address !== undefined
|
||||
? (await this.api.subagents.interrupt(address)).result
|
||||
: (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
|
||||
} catch (error) {
|
||||
result = transportError(error)
|
||||
}
|
||||
|
||||
@@ -140,10 +140,14 @@ export class FakeApiClient implements IApiClient {
|
||||
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
|
||||
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
|
||||
|
||||
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
|
||||
= () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
readonly subagents: IApiClient['subagents'] = {
|
||||
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
|
||||
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
|
||||
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
|
||||
@@ -639,7 +639,7 @@ describe('paging', () => {
|
||||
})
|
||||
|
||||
describe('prompt and cancel errors', () => {
|
||||
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
|
||||
it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
@@ -650,7 +650,7 @@ describe('prompt and cancel errors', () => {
|
||||
const cancelled = await session.cancel()
|
||||
|
||||
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
|
||||
expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 },
|
||||
])
|
||||
@@ -660,15 +660,37 @@ describe('prompt and cancel errors', () => {
|
||||
content: [{ type: 'text', text: '继续' }],
|
||||
},
|
||||
])
|
||||
expect(api.callsOf('subagent.interrupt')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
])
|
||||
expect(api.callsOf('session.history')).toEqual([])
|
||||
expect(api.callsOf('session.prompt')).toEqual([])
|
||||
expect(api.callsOf('session.cancel')).toEqual([])
|
||||
// A successful interrupt leaves no stop error behind.
|
||||
expect(session.getSnapshot().promptError).toBeNull()
|
||||
expect(session.getSnapshot().subagent).toEqual({
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('lands an interrupt business failure in promptError with op=stop', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSubagentInterrupt = () => Promise.resolve(err({
|
||||
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
|
||||
}) as never)
|
||||
const session = new Session(SID, api, {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await session.open()
|
||||
const cancelled = await session.cancel()
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } })
|
||||
expect(session.getSnapshot().promptError).toMatchObject({
|
||||
op: 'stop', error: { code: 'subagent-unauthorized' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, {
|
||||
@@ -676,12 +698,16 @@ describe('prompt and cancel errors', () => {
|
||||
})
|
||||
await session.open()
|
||||
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
|
||||
const cancelled = await session.cancel()
|
||||
|
||||
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
|
||||
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
|
||||
expect(api.callsOf('subagent.history')).toEqual([
|
||||
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 },
|
||||
])
|
||||
expect(api.callsOf('subagent.prompt')).toEqual([])
|
||||
expect(api.callsOf('subagent.interrupt')).toEqual([])
|
||||
expect(api.callsOf('session.cancel')).toEqual([])
|
||||
})
|
||||
|
||||
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
|
||||
|
||||
@@ -24,16 +24,16 @@
|
||||
"path": "../../host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
"path": "../../session/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 837edaa097d47ebfb72d027616a18fdfeed8a488
|
||||
README.zh.md: 419799666dc0689f8fe754d4c9de6d5dcf7fb09c
|
||||
README.md: f5cf9b72b4f8f4ce7b1a07c0c1e30f5213edf678
|
||||
README.zh.md: 90c4dc4cc169b2cbcd7e94275a692e3d062cfe98
|
||||
|
||||
@@ -34,13 +34,13 @@ Keyboard message submission resolves delivery from the addressed session's runni
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
|
||||
|
||||
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
|
||||
|
||||
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
|
||||
|
||||
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost.
|
||||
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -34,13 +34,13 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||
|
||||
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
|
||||
|
||||
`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。
|
||||
|
||||
完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。
|
||||
完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -317,6 +317,7 @@ export function apply(ctx: Context): void {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner),
|
||||
openFile: (path) => {
|
||||
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
|
||||
void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => {
|
||||
|
||||
@@ -13,7 +13,8 @@ import { memo, useMemo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { hasContentText } from './chat-flow.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||
@@ -41,6 +42,8 @@ export interface AssistantMarkdownProps {
|
||||
onFork?: ((seq: number) => void) | undefined
|
||||
/** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */
|
||||
turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined
|
||||
/** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */
|
||||
fileMentions?: ChatViewInjected['fileMentions'] | undefined
|
||||
/** The message is not the transcript tail of a completed turn. */
|
||||
forkUnavailable?: boolean | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
@@ -58,11 +61,25 @@ function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
|
||||
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail,
|
||||
fileMentions, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
|
||||
// Mention vocabulary for the closing prose. Keyed on the anchor seq, not the
|
||||
// growing transcript: a settled turn's produced files are final, and a
|
||||
// fresh identity per append would discard MarkdownText's cached parse for
|
||||
// every settled closing message on every stream chunk. The window-prepend
|
||||
// edge (a mid-turn window start later gaining earlier same-turn writes)
|
||||
// leaves a mention unlinked until remount — never a wrong link.
|
||||
const owner = turnTail?.owner
|
||||
const mentions: MarkdownFileMentions | undefined = useMemo(
|
||||
() => (owner === undefined ? undefined : fileMentions?.(owner)),
|
||||
// Deliberately not `owner`: its identity changes per append while the
|
||||
// seq-addressed vocabulary it yields does not.
|
||||
[fileMentions, owner?.seq],
|
||||
)
|
||||
const last = blocks.length - 1
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
||||
// a node that is only those heads (or empty) would paint an empty root
|
||||
@@ -79,7 +96,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return (
|
||||
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
|
||||
<MarkdownText
|
||||
key={i}
|
||||
text={block.text}
|
||||
streaming={streaming}
|
||||
codeLabels={codeLabels}
|
||||
fileMentions={mentions}
|
||||
/>
|
||||
)
|
||||
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
|
||||
@@ -263,7 +263,8 @@ function StreamingTail({ useSession, t }: {
|
||||
* ordered root Tool call crosses the declared whole-Tool render seat.
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
|
||||
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt,
|
||||
fileMentions, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const turnTimings = useSession(s => s.turnTimings)
|
||||
@@ -535,6 +536,7 @@ export function ChatView({
|
||||
turnTail={actionSeqs.has(node.seq)
|
||||
? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
|
||||
: undefined}
|
||||
fileMentions={actionSeqs.has(node.seq) ? fileMentions : undefined}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerBlock } from '../input/blocks.ts'
|
||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
@@ -160,6 +161,30 @@ export interface ConvViewOwnerProps {
|
||||
onInspectDone?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')`
|
||||
* (optional-service convention): the chat view asks it for a closing message's
|
||||
* inline-code vocabulary and threads the result into MarkdownText. Absent
|
||||
* service — the providing plugin composed out of cordis.yml — turns the
|
||||
* surface off; the prose renders inert code.
|
||||
*/
|
||||
export interface ChatFileMentions {
|
||||
/**
|
||||
* Mention vocabulary for the closing message the owner currency names.
|
||||
* @param owner - Turn-tail owner currency (nodes, closing seq, opener).
|
||||
* @returns The resolver MarkdownText consumes, or undefined when the turn
|
||||
* produced nothing worth linking.
|
||||
*/
|
||||
forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */
|
||||
chatFileMentions: ChatFileMentions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner currency of the chat view's turn-tail hole: the finalized snapshot
|
||||
* and the closing assistant's anchor. Registrants derive their own facts
|
||||
@@ -519,6 +544,13 @@ export interface ChatViewInjected {
|
||||
}
|
||||
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
|
||||
forkAt: (seq: number) => void
|
||||
/**
|
||||
* Prose file-mention vocabulary for one closing message, from the optional
|
||||
* {@link ChatFileMentions} service (resolved lazily per call, so composing
|
||||
* the provider in or out takes effect live). Undefined when the service is
|
||||
* absent or the turn produced nothing worth linking.
|
||||
*/
|
||||
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
} from './contract/views.ts'
|
||||
export type { ConversationKey } from './locales.ts'
|
||||
export type {
|
||||
ChatFileMentions,
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
|
||||
ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const zh = {
|
||||
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
|
||||
'placeholder.default': '给智能体发消息',
|
||||
'placeholder.unavailable': '会话不可用',
|
||||
'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行',
|
||||
'placeholder.hero': '描述你想要构建的内容',
|
||||
'placeholder.workspace': '选择一个工作区开始',
|
||||
'input.commands': '命令',
|
||||
@@ -159,6 +160,7 @@ export const en = {
|
||||
'placeholder.plan': PLAN_NEXT_ACTION_EN,
|
||||
'placeholder.default': 'Message the agent',
|
||||
'placeholder.unavailable': 'Session unavailable',
|
||||
'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run',
|
||||
'placeholder.hero': 'Describe what you want to build',
|
||||
'placeholder.workspace': 'Choose a workspace to start',
|
||||
'input.commands': 'Commands',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
|
||||
viewport bottom inside the centered message column; textarea on top, action
|
||||
row below, one primary circle button bottom-right. Input width rides the
|
||||
row below, primary action controls bottom-right. Input width rides the
|
||||
column (--dsh-composer-card-max-width = chat content + 32px, 16px per side,
|
||||
is a cap, not a fixed size — layout rule: the box shrinks with the center
|
||||
column keeping its clearance). Hero variant = the same card centered in the
|
||||
|
||||
@@ -83,11 +83,15 @@ export function InputBar({
|
||||
// (undefined = capability absent → the chip renders nothing).
|
||||
const permissions = useProjection('permissions')
|
||||
|
||||
// A continuable child without its live parent cannot accept human input,
|
||||
// but its independent Stop below stays available while it runs.
|
||||
const continuable = subagent?.address.mode === 'continuable'
|
||||
const parentOffline = continuable && !subagent.parentAvailable
|
||||
// Queue cut 1: running input stays free; locked = session removed, the
|
||||
// inert no-workspace state, or the machine faces absent (no session). The
|
||||
// transient machine locks (adjudicating pending / submitting) render
|
||||
// read-only — the draft stays visible and focused, keystrokes drop.
|
||||
const disabled = removed || inert || !live || blocked !== undefined
|
||||
// inert no-workspace state, the machine faces absent (no session), or a
|
||||
// parent-offline continuable child. An owner block also disables input;
|
||||
// adjudicating and submitting render read-only so the draft stays visible.
|
||||
const disabled = removed || inert || !live || blocked !== undefined || parentOffline
|
||||
const locked = disabled
|
||||
// The model seat is the ONE control a block leaves live: every block this
|
||||
// contract has is cleared by choosing a model, so locking it too would leave
|
||||
@@ -350,11 +354,14 @@ export function InputBar({
|
||||
if (el !== null) toggleCommandMenu?.(selectionOf(el))
|
||||
}
|
||||
|
||||
const ordinary = subagent === null
|
||||
const stopping = running && ordinary
|
||||
const primaryLabel = stopping ? t('input.stop') : t('input.send')
|
||||
// Ordinary sessions retain their primary Send/Stop toggle. A continuable
|
||||
// child keeps Send as the primary action and exposes Stop independently so
|
||||
// pointer users can queue follow-ups while its current turn is running.
|
||||
const primaryStops = running && subagent === null
|
||||
const interruptible = running && continuable
|
||||
const primaryLabel = primaryStops ? t('input.stop') : t('input.send')
|
||||
const onPrimary = (): void => {
|
||||
if (stopping) {
|
||||
if (primaryStops) {
|
||||
stop?.()
|
||||
return
|
||||
}
|
||||
@@ -478,9 +485,11 @@ export function InputBar({
|
||||
disabled={locked}
|
||||
readOnly={machineBusy}
|
||||
data-phase={input?.phase ?? 'inert'}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? t('placeholder.unavailable')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
placeholder={placeholder ?? (parentOffline
|
||||
? t('placeholder.parentOffline')
|
||||
: disabled
|
||||
? t('placeholder.unavailable')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -521,16 +530,32 @@ export function InputBar({
|
||||
{renderSlot('conversation.input.model', { locked: modelSeatLocked })}
|
||||
<ContextMeter useProjection={useProjection} t={t} />
|
||||
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
|
||||
{interruptible && (
|
||||
<Tooltip label={t('input.stop')} side="top" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.primary}
|
||||
aria-label={t('input.stop')}
|
||||
disabled={stop === undefined}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={stop}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={primaryLabel} side="top" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.primary}
|
||||
aria-label={primaryLabel}
|
||||
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
|
||||
disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
>
|
||||
{stopping ? (
|
||||
{primaryStops ? (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
@@ -180,6 +180,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
inspectCall,
|
||||
chatScroll,
|
||||
forkAt,
|
||||
// Absent-service default; mention tests override with a real resolver.
|
||||
fileMentions: () => undefined,
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
t,
|
||||
}
|
||||
@@ -350,6 +352,45 @@ describe('chat-flow derivation', () => {
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
|
||||
})
|
||||
|
||||
it('threads the injected file-mention vocabulary into the closing prose only', () => {
|
||||
const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
|
||||
...toolResult(seq, callId, 'write'),
|
||||
callView: {
|
||||
card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }],
|
||||
},
|
||||
})
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'build it'),
|
||||
assistant(2, 'writing `report.html` now', 1),
|
||||
wrote(3, 'w', 'site/report.html'),
|
||||
assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1),
|
||||
],
|
||||
turnEnds: new Map([[1, 4]]),
|
||||
})
|
||||
// Stub provider mirroring the real service: only produced files resolve.
|
||||
h.props.fileMentions = owner => ({
|
||||
resolve: (value) => {
|
||||
if (value !== 'report.html') return undefined
|
||||
return {
|
||||
open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) },
|
||||
label: '打开 site/report.html',
|
||||
title: 'site/report.html',
|
||||
}
|
||||
},
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// Exactly one live mention: the closing message links, the mid-turn
|
||||
// narration stays inert code, and the unknown file resolves to nothing.
|
||||
const mentions = view.container.querySelectorAll('code button')
|
||||
expect(mentions).toHaveLength(1)
|
||||
const mention = view.getByRole('button', { name: '打开 site/report.html' })
|
||||
expect(mention.getAttribute('title')).toBe('site/report.html')
|
||||
fireEvent.click(mention)
|
||||
// The vocabulary was built from the closing message's own owner currency.
|
||||
expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html')
|
||||
})
|
||||
|
||||
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
|
||||
// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
|
||||
// semantics (input stays free; primary turns stop), the machine pending lock,
|
||||
// semantics (input stays free; continuable children keep Send beside Stop), the machine pending lock,
|
||||
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
|
||||
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
@@ -143,11 +143,14 @@ function bench(over?: BenchOptions) {
|
||||
}
|
||||
const view = render(<InputBar {...props} />)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const stopping = over?.running === true && over.subagent === undefined
|
||||
const primaryStops = over?.running === true && over.subagent === undefined
|
||||
const button = view.container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
|
||||
`button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`,
|
||||
)!
|
||||
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
|
||||
const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
|
||||
return {
|
||||
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Enter semantics', () => {
|
||||
@@ -250,8 +253,8 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
|
||||
})
|
||||
|
||||
it('running subagent primary admits a follow-up instead of exposing Stop', () => {
|
||||
const { button, sink, stop } = bench({
|
||||
it('running continuable subagent keeps Send beside an independent Stop', () => {
|
||||
const { button, interruptButton, textarea, sink, stop } = bench({
|
||||
running: true,
|
||||
draft: '后续消息',
|
||||
subagent: {
|
||||
@@ -264,22 +267,53 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
},
|
||||
})
|
||||
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
||||
expect(interruptButton).not.toBeNull()
|
||||
expect(textarea.disabled).toBe(false)
|
||||
fireEvent.click(button)
|
||||
expect(sink).toHaveBeenCalledWith('后续消息', 'queue')
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
fireEvent.click(interruptButton!)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const empty = bench({
|
||||
it('parent-offline running continuable locks Send but keeps independent Stop usable', () => {
|
||||
const { button, interruptButton, textarea, stop, view } = bench({
|
||||
running: true,
|
||||
draft: '',
|
||||
subagent: {
|
||||
address: {
|
||||
parentSessionId: 'parent' as SessionId,
|
||||
childSessionId: SID,
|
||||
mode: 'continuable',
|
||||
},
|
||||
parentAvailable: false,
|
||||
},
|
||||
})
|
||||
expect(textarea.disabled).toBe(true)
|
||||
expect(textarea.placeholder).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
|
||||
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
||||
expect(button.disabled).toBe(true)
|
||||
expect(interruptButton?.disabled).toBe(false)
|
||||
fireEvent.click(interruptButton!)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('running one-shot subagent never exposes Stop', () => {
|
||||
const { button, interruptButton, stop } = bench({
|
||||
running: true,
|
||||
draft: '不可停止',
|
||||
subagent: {
|
||||
address: {
|
||||
parentSessionId: 'parent' as SessionId,
|
||||
childSessionId: SID,
|
||||
mode: 'one-shot',
|
||||
},
|
||||
parentAvailable: true,
|
||||
},
|
||||
})
|
||||
expect(empty.button.disabled).toBe(true)
|
||||
expect(button.getAttribute('aria-label')).toBe('发送消息')
|
||||
expect(interruptButton).toBeNull()
|
||||
expect(stop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps both running subagent Enter gestures on Queue transport', () => {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
@@ -51,7 +51,7 @@
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/permission"
|
||||
"path": "../../interaction/permission"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
|
||||
README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043
|
||||
README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b
|
||||
README.md: 189dedd88fed6914012204118ccdf9bdd0cd3bb2
|
||||
README.zh.md: bfcec3c54602533028942ed167b9526eaf3ca959
|
||||
|
||||
@@ -8,6 +8,8 @@ Produced-files feature owner: registers the deliverables row a finished turn end
|
||||
|
||||
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
|
||||
|
||||
The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request.
|
||||
@@ -18,4 +20,4 @@ None; this package neither assembles nor sends provider requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up.
|
||||
- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
|
||||
|
||||
收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留 code 胶囊并采用 markdown 样式表的链接语言——静止为链接蓝、悬停出下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。
|
||||
@@ -18,4 +20,4 @@
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作。
|
||||
- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持死文本;放宽匹配器等真实的收尾消息形态需要时再做。
|
||||
|
||||
@@ -6,18 +6,13 @@
|
||||
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { basename } from './turn-deliverables.ts'
|
||||
import type { NS } from './locales.ts'
|
||||
import css from './ProducedFiles.module.css'
|
||||
|
||||
/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */
|
||||
const SHOWN = 6
|
||||
|
||||
/** Trailing path segment, the part that identifies the file at a glance. */
|
||||
function basename(path: string): string {
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return at === -1 ? path : path.slice(at + 1)
|
||||
}
|
||||
|
||||
/** Matched paths plus the opener and locale seats needed to present them. */
|
||||
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
|
||||
matched: readonly string[]
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* Deliverables plugin, browser half: registers the produced-files row into
|
||||
* the chat view's turn-tail hole. All policy lives here — the derivation
|
||||
* from the mutation tools' `locations`, the chip cap, and the copy — so
|
||||
* composing this plugin out of cordis.yml removes the surface entirely; the
|
||||
* owning view renders an empty hole at zero cost.
|
||||
* the chat view's turn-tail chain, and provides the `chatFileMentions`
|
||||
* service that links inline-code mentions of produced files in the closing
|
||||
* prose. All policy lives here — the derivation from the mutation tools'
|
||||
* `locations`, the mention matching, the chip cap, and the copy — so
|
||||
* composing this plugin out of cordis.yml removes both surfaces entirely;
|
||||
* the owning view renders an empty chain and inert prose at zero cost.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { ProducedFiles } from './ProducedFiles.tsx'
|
||||
import { en, NS, zh, type DeliverablesKey } from './locales.ts'
|
||||
import { selectProducedFiles } from './turn-deliverables.ts'
|
||||
import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
@@ -39,4 +41,17 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: NS,
|
||||
}, ProducedFiles),
|
||||
)
|
||||
// The prose side of the same vocabulary: the chat view reaches this face
|
||||
// via ctx.get, so its absence — this plugin composed out — is the off state.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const mentions: ChatFileMentions = {
|
||||
forClosing(owner) {
|
||||
// Same claim test the turn-tail chain entry runs: no produced files,
|
||||
// no vocabulary — the two surfaces agree by construction.
|
||||
const paths = selectProducedFiles(owner)
|
||||
if (paths === null) return undefined
|
||||
return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path }))
|
||||
},
|
||||
}
|
||||
ctx.provide('chatFileMentions', mentions)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* own follow-along `locations`, never the closing prose.
|
||||
*/
|
||||
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
/**
|
||||
@@ -88,3 +89,45 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[
|
||||
const paths = producedForClosing(nodes, seq)
|
||||
return paths.length === 0 ? null : paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Trailing path segment, the part that identifies the file at a glance.
|
||||
* @param path - Slash- or backslash-separated path.
|
||||
* @returns The final segment, or the whole string when separator-free.
|
||||
*/
|
||||
export function basename(path: string): string {
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return at === -1 ? path : path.slice(at + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* File-mention vocabulary over one turn's produced paths, for the closing
|
||||
* message's prose: an inline-code token opens the file it names. A token
|
||||
* resolves by exact path, or by being exactly the basename of exactly one
|
||||
* produced path — a basename two paths share stays inert rather than
|
||||
* guessing, so a mention link can never open the wrong file or 404.
|
||||
* @param paths - The turn's produced paths (tool order, already deduped).
|
||||
* @param openFile - The chat view's file opener.
|
||||
* @param label - Localizes the accessible open-label for a resolved path.
|
||||
* @returns The resolver MarkdownText consumes; the full path rides `title`,
|
||||
* the same disambiguator the row's chips carry.
|
||||
*/
|
||||
export function producedFileMentions(
|
||||
paths: readonly string[],
|
||||
openFile: (path: string) => void,
|
||||
label: (path: string) => string,
|
||||
): MarkdownFileMentions {
|
||||
return {
|
||||
resolve(value) {
|
||||
const path = paths.includes(value) ? value : onlyPathWithBasename(paths, value)
|
||||
if (path === undefined) return undefined
|
||||
return { open: () => { openFile(path) }, label: label(path), title: path }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The single produced path whose basename is exactly `value`, else undefined. */
|
||||
function onlyPathWithBasename(paths: readonly string[], value: string): string | undefined {
|
||||
const matches = paths.filter(path => basename(path) === value)
|
||||
return matches.length === 1 ? matches[0] : undefined
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ import type {
|
||||
AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
|
||||
import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
|
||||
import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
import { apply as applyInvariant } from '../src/invariant.ts'
|
||||
@@ -142,6 +143,33 @@ describe('ProducedFiles row', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('producedFileMentions resolver', () => {
|
||||
const label = (path: string) => `打开 ${path}`
|
||||
|
||||
it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => {
|
||||
const opened: string[] = []
|
||||
const resolver = producedFileMentions(
|
||||
['out/index.html', 'a/style.css', 'b/style.css'],
|
||||
(path) => { opened.push(path) },
|
||||
label,
|
||||
)
|
||||
// Unique basename resolves to its full path; the full path rides title.
|
||||
const byBasename = resolver.resolve('index.html')
|
||||
expect(byBasename?.label).toBe('打开 out/index.html')
|
||||
expect(byBasename?.title).toBe('out/index.html')
|
||||
byBasename?.open()
|
||||
expect(opened).toEqual(['out/index.html'])
|
||||
// An exact path resolves even when its basename is ambiguous.
|
||||
const exact = resolver.resolve('a/style.css')
|
||||
expect(exact?.title).toBe('a/style.css')
|
||||
// A basename two paths share stays unresolved rather than guessing,
|
||||
// and so does a token naming nothing the turn wrote.
|
||||
expect(resolver.resolve('style.css')).toBeUndefined()
|
||||
expect(resolver.resolve('notes.md')).toBeUndefined()
|
||||
expect(basename('a\\b\\c.txt')).toBe('c.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('package shells', () => {
|
||||
it('the node half mounts inert and the invariant companion registers ownership', async () => {
|
||||
// The node half is deliberately inert; mounting it must simply not throw.
|
||||
@@ -173,7 +201,24 @@ describe('plugin registration', () => {
|
||||
await fiber.await()
|
||||
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
|
||||
|
||||
// The prose face is live while the plugin is: a produced turn yields a
|
||||
// resolver whose matches open through the owner-supplied opener.
|
||||
const opened: string[] = []
|
||||
const owner = {
|
||||
nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)],
|
||||
seq: 3,
|
||||
openFile: (path: string) => { opened.push(path) },
|
||||
}
|
||||
const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
|
||||
const mentions = service?.forClosing(owner)
|
||||
mentions?.resolve('report.html')?.open()
|
||||
expect(opened).toEqual(['site/report.html'])
|
||||
// A turn that produced nothing yields no vocabulary at all.
|
||||
expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined()
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
|
||||
// Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
|
||||
expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/permission"
|
||||
"path": "../../interaction/permission"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: fae49d5764d4001f1852cb43aab730064febf2d2
|
||||
README.zh.md: 37984b9020df08b8804306111ca13ec0e92e5ce7
|
||||
README.md: 098a202a4ac9ee263ce1beaaee7a8624ebf26b80
|
||||
README.zh.md: 2b33af3316dede35cb5a226e41365f692a8b25d3
|
||||
|
||||
@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
|
||||
## Terminal output
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有视图把命名真实文件的行内代码变成链接:token 保留代码样式并获得接到所解析 opener 的按钮,带解析器给出的无障碍标签与完整路径 `title`。渲染器绝不猜测什么长得像路径——未解析的 token 保持原样,提及只作用于已定稿的渲染(流式缓存不能烘进可能过期的 handler),锚点内部的 token 也保持原样,因为按钮不能嵌套在链接里。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
|
||||
## 终端输出
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export type { MarkdownCodeLabels } from './markdown/MarkdownText.tsx'
|
||||
export type { MarkdownCodeLabels, MarkdownFileMentions } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
|
||||
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
|
||||
|
||||
@@ -241,3 +241,25 @@
|
||||
background: var(--dsw-alias-bg-base);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Inline file mention: the same link language this sheet gives anchors (and
|
||||
thereby URL-promoted inline code) — link-blue at rest, underline only on
|
||||
hover/focus. An underline at rest reads badly inside the code chip, where
|
||||
it collides with monospace descenders and the pill background. */
|
||||
.fileMention {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileMention:hover,
|
||||
.fileMention:focus {
|
||||
outline: none;
|
||||
text-decoration: underline var(--dsw-alias-state-business-primary);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
@@ -19,20 +19,25 @@ import {
|
||||
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
|
||||
wrapBlockChildren,
|
||||
} from './render.tsx'
|
||||
import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
|
||||
import type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import css from './MarkdownText.module.css'
|
||||
|
||||
export type { MarkdownCodeLabels } from './render.tsx'
|
||||
export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx'
|
||||
|
||||
/** One settled full render: parse with math, resolve references, append the footnote section. */
|
||||
function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] {
|
||||
function renderSettled(
|
||||
text: string,
|
||||
codeLabels: MarkdownCodeLabels | undefined,
|
||||
fileMentions: MarkdownFileMentions | undefined,
|
||||
): ReactNode[] {
|
||||
const root = parseGfmWithMath(text)
|
||||
const targets = createReferenceTargets()
|
||||
collectReferenceTargets(root.children, targets)
|
||||
const context: MarkdownRenderContext = {
|
||||
streaming: false,
|
||||
codeLabels,
|
||||
fileMentions,
|
||||
targets,
|
||||
footnoteOrder: [],
|
||||
footnoteCounts: new Map(),
|
||||
@@ -96,6 +101,7 @@ class StreamingRenderer {
|
||||
const frozenContext: MarkdownRenderContext = {
|
||||
streaming: true,
|
||||
codeLabels: this.codeLabels,
|
||||
fileMentions: undefined,
|
||||
targets: frameTargets,
|
||||
footnoteOrder: this.frozenFootnoteOrder,
|
||||
footnoteCounts: this.frozenFootnoteCounts,
|
||||
@@ -113,6 +119,7 @@ class StreamingRenderer {
|
||||
const tailContext: MarkdownRenderContext = {
|
||||
streaming: true,
|
||||
codeLabels: this.codeLabels,
|
||||
fileMentions: undefined,
|
||||
targets: frameTargets,
|
||||
footnoteOrder: [...this.frozenFootnoteOrder],
|
||||
footnoteCounts: new Map(this.frozenFootnoteCounts),
|
||||
@@ -137,28 +144,33 @@ class StreamingRenderer {
|
||||
* the finalize swap) and parses incrementally across chunks; `codeLabels`
|
||||
* forwards localized copy-button labels to fence CodeBlocks — pass a
|
||||
* reference-stable object (memoized per locale revision), because a new
|
||||
* identity discards the streaming render cache mid-message.
|
||||
* identity discards the streaming render cache mid-message. `fileMentions`
|
||||
* links inline-code tokens its resolver recognizes as real files; this is
|
||||
* the single streaming gate — it applies to settled renders only, because a
|
||||
* streaming message's vocabulary is not final and frozen cached elements
|
||||
* must not bake in handlers that could go stale.
|
||||
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
|
||||
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
|
||||
* images render directly.
|
||||
*/
|
||||
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: {
|
||||
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: {
|
||||
text: string
|
||||
streaming?: boolean
|
||||
codeLabels?: MarkdownCodeLabels | undefined
|
||||
fileMentions?: MarkdownFileMentions | undefined
|
||||
}) {
|
||||
const streamRef = useRef<StreamingRenderer | null>(null)
|
||||
const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
|
||||
const children = useMemo(() => {
|
||||
if (!streaming) {
|
||||
streamRef.current = null
|
||||
return renderSettled(text, codeLabels)
|
||||
return renderSettled(text, codeLabels, fileMentions)
|
||||
}
|
||||
if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
|
||||
streamRef.current = new StreamingRenderer(codeLabels)
|
||||
streamLabelsRef.current = codeLabels
|
||||
}
|
||||
return streamRef.current.render(text)
|
||||
}, [text, streaming, codeLabels])
|
||||
}, [text, streaming, codeLabels, fileMentions])
|
||||
return <div className={css.markdown}>{children}</div>
|
||||
})
|
||||
|
||||
@@ -99,6 +99,21 @@ export function collectReferenceTargets(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File-mention affordance for inline code: the owner resolves an authored
|
||||
* token to the file it names, using its own vocabulary of real files — the
|
||||
* renderer never guesses at what looks like a path.
|
||||
*/
|
||||
export interface MarkdownFileMentions {
|
||||
/**
|
||||
* Resolve one inline-code token.
|
||||
* @param value - The authored token, exactly as written.
|
||||
* @returns The opener with its accessible label and full-path title, or
|
||||
* undefined when the token names no known file — it then stays inert code.
|
||||
*/
|
||||
resolve(value: string): { open: () => void; label: string; title: string } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* One render pass's state: immutable options and targets plus the footnote
|
||||
* numbering accumulated in document order while references render.
|
||||
@@ -108,6 +123,10 @@ export interface MarkdownRenderContext {
|
||||
readonly streaming: boolean
|
||||
/** Localized fence copy-button labels. */
|
||||
readonly codeLabels: MarkdownCodeLabels | undefined
|
||||
/** Inline-code file mentions; absent wherever no opener vocabulary exists. */
|
||||
readonly fileMentions: MarkdownFileMentions | undefined
|
||||
/** Inside an anchor's children: interactive mentions must not nest there. */
|
||||
readonly inLink?: boolean
|
||||
/** Reference targets visible to this pass. */
|
||||
readonly targets: ReferenceTargets
|
||||
/** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */
|
||||
@@ -217,7 +236,27 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
|
||||
// authored text, not a parsed destination, so no normalizeUri: port,
|
||||
// path, and query render unchanged.
|
||||
const href = inlineCodeHttpUrl(value)
|
||||
return <code key={key}>{href === undefined ? value : renderSafeLink(href, [value], 'link')}</code>
|
||||
if (href !== undefined) return <code key={key}>{renderSafeLink(href, [value], 'link')}</code>
|
||||
// A token the owner's file-mention vocabulary recognizes opens that
|
||||
// file; the resolver, not this renderer, decides what names a file.
|
||||
// Inside an anchor the token stays inert — a button cannot nest there.
|
||||
const mention = context.inLink === true ? undefined : context.fileMentions?.resolve(value)
|
||||
if (mention !== undefined) {
|
||||
return (
|
||||
<code key={key}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileMention}
|
||||
title={mention.title}
|
||||
aria-label={mention.label}
|
||||
onClick={mention.open}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
</code>
|
||||
)
|
||||
}
|
||||
return <code key={key}>{value}</code>
|
||||
}
|
||||
case 'html':
|
||||
// No HTML parser enters the pipeline: raw HTML stays literal text.
|
||||
@@ -236,7 +275,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
|
||||
case 'table':
|
||||
return renderTable(node, key, context)
|
||||
case 'link':
|
||||
return renderAnchor(node.url, renderChildren(node.children, context), key)
|
||||
return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key)
|
||||
case 'linkReference':
|
||||
return renderLinkReference(node, key, context)
|
||||
case 'image':
|
||||
@@ -460,14 +499,14 @@ function renderLinkReference(
|
||||
context: MarkdownRenderContext,
|
||||
): ReactNode {
|
||||
const definition = context.targets.definitions.get(node.identifier.toUpperCase())
|
||||
const children = renderChildren(node.children, context)
|
||||
if (definition === undefined) {
|
||||
// The grammar only emits references whose definitions exist somewhere in
|
||||
// the same parse, but incremental segments and hand-built trees may still
|
||||
// present unresolved ones: revert to the bracketed source text.
|
||||
return <Fragment key={key}>{'['}{children}{referenceSuffix(node)}</Fragment>
|
||||
// present unresolved ones: revert to the bracketed source text — which is
|
||||
// not an anchor, so mentions inside it stay live.
|
||||
return <Fragment key={key}>{'['}{renderChildren(node.children, context)}{referenceSuffix(node)}</Fragment>
|
||||
}
|
||||
return renderAnchor(definition.url, children, key)
|
||||
return renderAnchor(definition.url, renderChildren(node.children, { ...context, inLink: true }), key)
|
||||
}
|
||||
|
||||
function renderImageReference(
|
||||
|
||||
@@ -20,6 +20,7 @@ function makeContext(): MarkdownRenderContext {
|
||||
return {
|
||||
streaming: false,
|
||||
codeLabels: undefined,
|
||||
fileMentions: undefined,
|
||||
targets: createReferenceTargets(),
|
||||
footnoteOrder: [],
|
||||
footnoteCounts: new Map(),
|
||||
|
||||
@@ -148,6 +148,49 @@ describe('MarkdownText', () => {
|
||||
expect(container.querySelector('pre code a')).toBeNull()
|
||||
})
|
||||
|
||||
it('links inline code through the file-mention resolver: URL first, settled only, never inside links', () => {
|
||||
const opened: string[] = []
|
||||
const fileMentions = {
|
||||
resolve: (value: string) => value === 'index.html' || value === 'out/index.html'
|
||||
? { open: () => { opened.push(value) }, label: 'Open out/index.html', title: 'out/index.html' }
|
||||
: undefined,
|
||||
}
|
||||
const source = [
|
||||
'`index.html`',
|
||||
'`other.css`',
|
||||
'`https://example.com/`',
|
||||
// Inside an anchor the mention stays inert code: a button cannot nest there.
|
||||
'[see `out/index.html`](https://example.com/doc)',
|
||||
'[ref `out/index.html`][target]',
|
||||
'[target]: https://example.com/ref',
|
||||
'```',
|
||||
'index.html',
|
||||
'```',
|
||||
].join('\n\n')
|
||||
const { container } = render(<MarkdownText text={source} fileMentions={fileMentions} />)
|
||||
|
||||
const mention = screen.getByRole('button', { name: 'Open out/index.html' })
|
||||
expect(mention.closest('code')).not.toBeNull()
|
||||
// The full path rides title, the same disambiguator the row's chips carry.
|
||||
expect(mention.getAttribute('title')).toBe('out/index.html')
|
||||
fireEvent.click(mention)
|
||||
expect(opened).toEqual(['index.html'])
|
||||
// Exactly one live mention: the two inside anchors declined, and an
|
||||
// unresolved token plus fenced code stay inert.
|
||||
expect(container.querySelectorAll('code button')).toHaveLength(1)
|
||||
expect(container.querySelectorAll('a code button, a button')).toHaveLength(0)
|
||||
expect(screen.getByText('other.css').closest('button')).toBeNull()
|
||||
// URL promotion wins before the resolver sees a token.
|
||||
expect(screen.getByText('https://example.com/').closest('a')).not.toBeNull()
|
||||
|
||||
// Streaming renders keep mentions off — the one gate lives here: cached
|
||||
// frozen elements must not bake in handlers that could go stale.
|
||||
const streamed = render(
|
||||
<MarkdownText text={'`index.html`\n\nmore\n\n'} streaming fileMentions={fileMentions} />,
|
||||
)
|
||||
expect(streamed.container.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => {
|
||||
const extension = cjkFriendlyStrong()
|
||||
expect(cjkFriendlyStrong()).toBe(extension)
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/tool-ask-user"
|
||||
"path": "../../interaction/tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
|
||||
README.md: cb210b219a8c66985eb4e1370468372eed9614b4
|
||||
README.zh.md: 857e92d05a7ed2d0df9398acc9698db13b0c6eb2
|
||||
README.md: abd94eb7a3b5e7c4d6d79f7a2ed5f95d471e64e2
|
||||
README.zh.md: 8686902819918c9637a8aa87d5135135a8dd4996
|
||||
|
||||
@@ -6,7 +6,7 @@ Web subagent feature owner: contributes the lazily expandable catalog tree to `c
|
||||
|
||||
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
|
||||
|
||||
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
|
||||
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable and the child is not running, with copy explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer, whose input and Send action are disabled but whose independent Stop stays usable, and the takeover returns once it stops. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes prompts through `subagent.prompt`: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, while an independent Stop routes through `subagent.interrupt`. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) and the [current-turn interrupt Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md).
|
||||
|
||||
Subagent-origin Session rows are omitted from the ordinary sidebar, so the parent header catalog is their navigation entry point. Ordinary forks remain in the sidebar.
|
||||
|
||||
@@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button.
|
||||
- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes no Activation identity; stopping is limited to the composer's current-turn Stop for a running continuable child.
|
||||
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.
|
||||
|
||||
@@ -6,7 +6,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可
|
||||
|
||||
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
|
||||
|
||||
one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其会话会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 规定。
|
||||
one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器,并以文案说明恢复路径;此类 child 仍在运行期间,selector 会让位给普通编辑器——其输入区与 Send 操作被禁用,但独立的 Stop 保持可用,停止后只读替代恢复。确切 parent 存活时,可继续 child 保留普通输入 chrome,其会话通过 `subagent.prompt` 路由提示词:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagent.interrupt` 路由。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 与[当前轮次中断 Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md) 规定。
|
||||
|
||||
普通侧边栏会省略带 subagent origin 的会话行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。
|
||||
|
||||
@@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript(文本记录)
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开符合授权边界的取消按钮。
|
||||
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 不公开 Activation 身份;停止能力仅限编辑器上针对运行中可继续 child 的当前轮次 Stop。
|
||||
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。
|
||||
|
||||
@@ -43,7 +43,11 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc
|
||||
const subagent = owner.session?.subagent
|
||||
if (subagent === undefined || subagent === null) return null
|
||||
if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' }
|
||||
return subagent.parentAvailable ? null : { reason: 'parent-unavailable' }
|
||||
if (subagent.parentAvailable) return null
|
||||
// A RUNNING parent-offline continuable child keeps the default composer:
|
||||
// its input is disabled there, but the same primary Stop stays available so
|
||||
// the child can be interrupted. Once it stops, this takeover returns.
|
||||
return owner.session?.running === true ? null : { reason: 'parent-unavailable' }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -161,19 +161,26 @@ describe('apply', () => {
|
||||
const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null
|
||||
const owner = (
|
||||
subagent: ConversationSnapshot['subagent'] | undefined,
|
||||
running = false,
|
||||
): ComposerChainProps => ({
|
||||
interactions: [],
|
||||
session: subagent === undefined
|
||||
? undefined
|
||||
: ({ subagent } as unknown as ConversationSnapshot),
|
||||
: ({ subagent, running } as unknown as ConversationSnapshot),
|
||||
})
|
||||
expect(select(owner(undefined))).toBeNull()
|
||||
expect(select(owner(null))).toBeNull()
|
||||
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true })))
|
||||
.toEqual({ reason: 'one-shot' })
|
||||
// One-shot stays read-only even while running: it has no stop action.
|
||||
expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true)))
|
||||
.toEqual({ reason: 'one-shot' })
|
||||
expect(select(owner({ address, parentAvailable: true }))).toBeNull()
|
||||
expect(select(owner({ address, parentAvailable: false })))
|
||||
.toEqual({ reason: 'parent-unavailable' })
|
||||
// A RUNNING parent-offline continuable yields the default composer, whose
|
||||
// disabled input still carries the primary Stop; stopped, it takes back over.
|
||||
expect(select(owner({ address, parentAvailable: false }, true))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/code-runtime/README.md
|
||||
README.md: f20a287419b94b1a9dc1d8da7303fc4d3032cfd3
|
||||
README.zh.md: f5cd4c9949f2bd7a7d6d7cd078144910712a3819
|
||||
README.md: 45dd55b8609fd33b7116f6b2ea36df3b3332e419
|
||||
README.zh.md: ebf288a49f87ad01b1ec80c3843c87c91206af26
|
||||
|
||||
@@ -10,3 +10,5 @@ The code-execution capability seam (see [capability seams](../../.agents/notes/i
|
||||
| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker-thread backend | registers `ctx.codeRuntime` |
|
||||
|
||||
Backends register the seam without changing its consumer. The child READMEs own language, isolation, and execution-budget details.
|
||||
|
||||
The subsystem reference — run requests/results, binding namespaces, the failure taxonomy — is [docs/subsystems/code-runtime.md](../../docs/subsystems/code-runtime.md).
|
||||
|
||||
@@ -10,3 +10,5 @@
|
||||
| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker 线程后端 | 注册 `ctx.codeRuntime` |
|
||||
|
||||
后端在不改变消费方的情况下注册该 seam。子 README 负责语言、隔离和执行预算细节。
|
||||
|
||||
子系统参考——运行请求/结果、绑定命名空间、失败分类体系——见 [docs/subsystems/code-runtime.md](../../docs/subsystems/code-runtime.md)。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/compact/README.md
|
||||
README.md: 509ea2764250f42e492f787deba959a8dd9967b7
|
||||
README.zh.md: adc52690dbe9e904d8f24199cb78493dab3333a4
|
||||
README.md: 0a83b87f57e4c948ee6df9e622f17964aff00f0f
|
||||
README.zh.md: cba53ee7bb917a981a0fd4b77d833b8fb7dd2c3f
|
||||
|
||||
@@ -12,3 +12,5 @@ A compaction capability family (see [capability seams](../../.agents/notes/imple
|
||||
| [`command-compact/`](command-compact/README.md) | Human compaction command | registers on `ctx.commands` |
|
||||
|
||||
The backend, optional pruner, and human command compose through the seam; token measurement remains a separate LLM-family service. The [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) owns the dependency rationale.
|
||||
|
||||
The subsystem reference — the `compact/*` events, `CompactionResult`, the service, pruning outcomes — is [docs/subsystems/compaction.md](../../docs/subsystems/compaction.md); the seam's deliberate `dsh-session`/`dsh-llm` dependency is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
||||
|
||||
@@ -12,3 +12,5 @@
|
||||
| [`command-compact/`](command-compact/README.md) | 用户压缩命令 | 注册到 `ctx.commands` |
|
||||
|
||||
后端、可选修剪器和用户命令通过该 seam 组合;token 测量仍是独立的 LLM(大语言模型)家族服务。[压缩能力 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)负责依赖关系的设计原理。
|
||||
|
||||
子系统参考——`compact/*` 事件、`CompactionResult`、服务、剪除结果——见 [docs/subsystems/compaction.md](../../docs/subsystems/compaction.md);seam 有意依赖 `dsh-session`/`dsh-llm` 的决定记录在[压缩能力 seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/compact/command-compact/README.md
|
||||
README.md: 54f341e39447a423964b7d7435cfb638857eda6e
|
||||
README.zh.md: d4a122b8a19cdf907212ad019b2528ae52d03886
|
||||
README.md: 5af39e82928d64c11474199b6b30058e9d289f45
|
||||
README.zh.md: a582bd5272b2e39ac1ffbf839888a4006b6d44bc
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers and executes it without a model turn. The [queued manual compaction Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md) owns the admission, lock, and durability decisions.
|
||||
Human-facing `/compact` control over [`ctx.compact`](../compact/README.md). The plugin registers one global command through [`ctx.commands`](../../interaction/commands/README.md), so every composed command adapter discovers and executes it without a model turn. The [queued manual compaction Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md) owns the admission, lock, and durability decisions.
|
||||
|
||||
## Command contract
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩(compaction)控制。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现并执行它,无需模型轮次。[排队手动压缩 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。
|
||||
通过 [`ctx.compact`](../compact/README.md) 提供面向用户的 `/compact` 压缩(compaction)控制。该插件通过 [`ctx.commands`](../../interaction/commands/README.md) 注册一个全局命令,因此组合中的每个命令适配器都能发现并执行它,无需模型轮次。[排队手动压缩 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md)拥有接纳、锁与持久性决策。
|
||||
|
||||
## 命令契约
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../compact"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/compact/compact/README.md
|
||||
README.md: cd2404cda8d8702ca0400d3f23d9e1fe041495a1
|
||||
README.zh.md: ed6ccc630118dc1bd6d6ea761beefafadb4bd48f
|
||||
README.md: 2bf39cac91798b1e0fdb679d2a9b674caceffe22
|
||||
README.zh.md: 330b5b0cb9d5c2bf3953354112f3488447e71f87
|
||||
|
||||
@@ -24,7 +24,7 @@ All three operations are **abstract** — the backend owns trigger policy, reten
|
||||
| `compactNow(agent, signal)` | Explicitly compact one useful balanced older span even below automatic pressure. It synchronously reserves idle turn admission before yielding, writes nothing when no useful span exists, records a standalone `compact/* { turn: null }` attempt before summarization, and awaits its durability checkpoint before release. Expected operational failures use `ManualCompactionError`; cancellation rethrows the exact abort reason. |
|
||||
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source is `COMPACT_CHECKPOINT_SOURCE`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
|
||||
|
||||
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult).
|
||||
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/subsystems/compaction.md#compactionresult).
|
||||
|
||||
`compactIfNeeded` and `compactNow` take a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization. Automatic and explicit-region brackets recover their numeric owner from the currently open turn. Manual brackets require no open turn and stamp `turn: null`.
|
||||
|
||||
@@ -68,7 +68,7 @@ Subclass `CompactService`, implement `compactIfNeeded`, `compactNow`, and `compa
|
||||
|
||||
## Recognizing a checkpoint outside the host program (`./checkpoint`)
|
||||
|
||||
`COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compact/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../ui/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to this leaf with a type-only import, so renaming the plugin id here is a compile error there.
|
||||
`COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource()` are declared on the `@deepseek-ai/dsh-compact/checkpoint` subpath and re-exported from the root, so host-side consumers keep reading them from the root. The leaf imports no cordis and declares no module augmentation (the [`dsh-commands/brand`](../../interaction/commands/README.md) shape), which is what lets a client or wire program name the checkpoint source: the package **root** cannot enter such a program at all, because it reaches `dsh-session`'s root and that `Context` merge declares the host `sessions` service against the client's own (`TS2717` — one program per side, per [development.md](../../../docs/development.md#typescript-project-layout)). The web client's transcript adapter pins its plugin literal to this leaf with a type-only import, so renaming the plugin id here is a compile error there.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| `compactNow(agent, signal)` | 即使未达到自动压力,也显式压缩一段有效、平衡的较早范围。该操作会在让出控制权前同步预留空闲轮次接纳;没有有效范围时不写入任何内容;在摘要前记录独立的 `compact/* { turn: null }` 尝试;释放预留前等待其持久性检查点。预期操作失败使用 `ManualCompactionError`;取消会原样重新抛出 abort 原因。 |
|
||||
| `compactRegion(start, end, agent, signal?)` | 强制将表层节点 `[start, end]`(包含两端 seq)从 `agent.session` 摘要为单个替换节点,其源为 `COMPACT_CHECKPOINT_SOURCE`。如果压缩已在进行、`start`/`end` 不是表层节点,或 `start` 在表层上位于 `end` 之后,则**抛出异常**。该范围是表层位置范围,不是数值 seq 区间:在之前的 replace 将新生成的高 seq 摘要节点放到已遮蔽范围的位置之后,表层顺序不再跟随 seq 顺序。 |
|
||||
|
||||
`CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq,同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/core-data-structures/compaction.md#compactionresult)。
|
||||
`CompactionResult` 向调用方保留原始摘要与记录操作过程的事件 seq,同时保留已遮蔽范围与 token 计量;其结构由漂移检查保障,定义见 [压缩数据结构参考](../../../docs/subsystems/compaction.md#compactionresult)。
|
||||
|
||||
`compactIfNeeded` 和 `compactNow` 必须传入 `signal`;`compactRegion` 的该参数可选。通过 `ctx.llm.stream()` 摘要的后端**必须** 将它转发到调用的 `GenerateOptions.signal`,因此 abort 或 fiber dispose(资源释放)会停止进行中的摘要。自动和显式范围标记对会从当前打开的轮次恢复其数字形式归属。手动标记对不要求存在打开的轮次,并标记 `turn: null`。
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
## 在 host 程序之外识别检查点(`./checkpoint`)
|
||||
|
||||
`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compact/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。该叶子不导入 cordis、也不声明任何模块增强(即 [`dsh-commands/brand`](../../ui/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout))。Web 客户端的对话记录适配器用仅类型导入把它的插件字面量钉在该叶子上,因此在此处改插件 id 会让那边编译失败。
|
||||
`COMPACT_CHECKPOINT_SOURCE` 与 `isCompactCheckpointSource()` 声明在 `@deepseek-ai/dsh-compact/checkpoint` 子路径上,并由包根重新导出,因此 host 侧消费方仍从根读取它们。该叶子不导入 cordis、也不声明任何模块增强(即 [`dsh-commands/brand`](../../interaction/commands/README.md) 的形状),这正是客户端或 wire 程序能够命名该检查点来源的原因:包的**根**根本无法进入这类程序,因为它会到达 `dsh-session` 的根,而那处 `Context` 合并会让 host 的 `sessions` 服务与客户端自己的冲突(`TS2717`——每侧一个程序,见 [development.md](../../../docs/development.md#typescript-project-layout))。Web 客户端的对话记录适配器用仅类型导入把它的插件字面量钉在该叶子上,因此在此处改插件 id 会让那边编译失败。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/context/README.md
|
||||
README.md: a0b5eef51740b3aa67b9e158a00357f849b8bf57
|
||||
README.zh.md: d6ed8050f3a64f740a951883f0dc321ac08e978a
|
||||
README.md: 237f6191329f89834ec9bd9f72c0a1fd1d94651e
|
||||
README.zh.md: 7c55dd91dbf7e3fca222e4b6f6bed2769f5c253c
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user