Merge remote-tracking branch 'origin/master' into fix/fatal-diagnostics-and-crash-reports

This commit is contained in:
winewill committed 2026-09-22 17:58:58 +08:00
commit 603a8fc7d3
322 files changed
+6306 -1222

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md
2026-07-08-tool-output-spill-files.md: fa6697aad63c97b0ef580fe1ca46e50541e3d682
2026-07-08-tool-output-spill-files.zh.md: a995eafc7878b342a2164c5116da1f43ada791a5
2026-07-08-tool-output-spill-files.md: e32aec72f423dff33a29d9012c0f71c9cfbd24fb
2026-07-08-tool-output-spill-files.zh.md: 2941fc516723e72b5d5670e674ce612f3cf581cc
@@ -66,36 +66,7 @@ interface SpillRef {
### Spill policy
`dsh-spill-policy` is a `tools/post-execute` result transformer with one configuration knob:
```ts ignore-check
interface Config {
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
maxInlineBytes?: number
}
```
When `maxInlineBytes` is omitted the plugin registers nothing (a true no-op). When set, it applies a default policy to final plain-text tool results:
1. Let the tool run normally, delegating via `next()` so a downstream listener settles the result first.
2. Flatten the accepted final `ContentBlock[]` only when it is entirely plain text; a result with any non-text block is left untouched.
3. If its UTF-8 byte size is at or below `maxInlineBytes`, leave it unchanged.
4. If it is larger, call `ctx.spillStore.saveText()` with the full final text.
5. Replace the model-facing result with a retained head/tail preview plus the spill reference.
The preview is an implementation default owned by the policy: a head/tail split of `maxInlineBytes` via the retention library's `TextRetainer`. Future config can expose preview sizing only after a second deployment needs it.
The replacement text is intentionally generic because the policy only knows the final formatted tool result, not the tool's internal resource:
```text
<retained preview>
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
```
If `ctx.spillStore.saveText()` fails (permissions, ENOSPC, backend unavailable), or the call has no session owner, or no backend is loaded, the plugin logs the reason and returns the original result unchanged. Spill failure never turns a successful tool call into an `isError` result or hides the inline result.
The policy skips `read` to avoid a circular `read -> spill file -> read again` loop. Additional opt-out configuration is deferred until a real second tool needs it.
`dsh-spill-policy` retains ordered text/image ends under `maxInlineTokens` after post-execute policy accepts the result. `saveText()` stores the complete result with readable image paths; image bytes remain in attachment storage. The [multimodal retention decision](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.md) owns image projection order, atomic image omission, and route pricing. An omitted budget installs no listeners, recovery failures keep the original result, and model-facing `read` results skip retention to avoid a read/spill loop.
## Showcase: web_fetch
@@ -129,7 +100,7 @@ With `dsh-spill-policy` configured, a large formatted fetch result is automatica
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
maxInlineTokens: 12500
```
This separation is important. `web-fetch-http` still owns resource caps (`maxResponseBytes`, `maxBodyChars`) to protect network, memory, and decoding work. `spill-policy` owns only the model-facing context cap after the result already exists. If the provider already returned `truncated: true`, the spill file contains the full formatted result the tool returned, not the full original webpage; the policy does not claim otherwise.
@@ -186,11 +157,11 @@ The local-backend value proposition depends on the existing `read`/`grep` tools
**Snapshot gap.** No ACP snapshot scenario covers the transcript-visible `web_fetch` spill notice yet. The ACP snapshot harness replays keyless and cannot hit the live web, and a `web_fetch` spill requires a real over-cap HTTP body; a deterministic scenario would need a seeded loopback fetch target the replay tree does not currently wire (the examples do not load `tool-web` at all). The behavior is covered instead by the `dsh-tool-web` integration test against a loopback server. Closing the gap is follow-up work: wire `tool-web` + a seeded fetch target into the ACP example, then record a `web-fetch-spill` scenario.
The policy can become too large if it starts owning tool-specific semantics. It stays narrow: plain-text final results only. Tool-owned early spill remains future work.
The policy can become too large if it starts owning tool-specific semantics. It handles accepted text/image sequences without interpreting tool-specific text. Tool-owned early spill remains future work.
## Alternatives considered
**Require each tool to opt in with a retention declaration.** Rejected: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineBytes` deployment knob is enough to prove the shape.
**Require each tool to opt in with a retention declaration.** Rejected: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineTokens` deployment knob is enough to prove the shape.
**Make `tool-results` a broad tool-result platform.** Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a locator plus retrieval hint.
@@ -66,36 +66,7 @@ interface SpillRef {
### spill 策略
`dsh-spill-policy` 是一个 `tools/post-execute` 结果转换器,只提供一个配置项:
```ts ignore-check
interface Config {
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
maxInlineBytes?: number
}
```
省略 `maxInlineBytes` 时,插件不会注册任何内容,是真正的无操作。设置该值后,它会对最终的纯文本工具结果应用默认策略:
1. 让工具正常运行,通过 `next()` 委托,使下游监听器先结算结果。
2. 仅当已接受的最终 `ContentBlock[]` 全部是纯文本时,才将其展平;含任何非文本块的结果保持不变。
3. 如果 UTF-8 字节大小不超过 `maxInlineBytes`,保持不变。
4. 如果超出上限,使用完整的最终文本调用 `ctx.spillStore.saveText()`。
5. 把模型可见结果替换为保留的首尾预览和 spill 引用。
预览属于策略所有的实现默认值:以 `maxInlineBytes` 为上限,使用保留库的 `TextRetainer` 进行首尾分割。只有第二个部署证明有此需求后,未来配置才会公开预览大小。
替换文本刻意保持通用,因为策略只知道最终格式化的工具结果,不了解工具的内部资源:
```text
<retained preview>
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
```
如果 `ctx.spillStore.saveText()` 失败(权限、ENOSPC、后端不可用),或调用没有会话所有者,或未加载后端,插件会记录原因并原样返回结果。spill 失败绝不会把成功的工具调用变为 `isError` 结果,也不会隐藏内联结果。
策略跳过 `read`,以避免形成 `read -> spill file -> read again` 循环。额外的选择退出配置要等确实出现第二个有此需求的工具后再引入。
`dsh-spill-policy` 在执行后策略接受结果之后,按 `maxInlineTokens` 对文字和图片共同计量并保留首尾。完整结果仍通过 `saveText()` 保存,图片本体留在附件存储,结果文件记录可读取路径。图片投影顺序、整图省略和模型计量由[图文结果保留决策](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.zh.md)负责。省略配置时不安装监听器,存储失败时保留原结果;`read` 的模型可见结果跳过自动省略,避免重复读取产生循环。
## 示例:web_fetch
@@ -129,7 +100,7 @@ ctx.tools.register(defineTool({
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
maxInlineTokens: 12500
```
这项分离很重要。`web-fetch-http` 仍负责资源上限(`maxResponseBytes`、`maxBodyChars`),用来保护网络、内存和解码工作。`spill-policy` 只负责结果已经存在后针对模型上下文的上限。如果提供方已经返回 `truncated: true`,spill 文件包含的是工具返回的完整格式化结果,而不是原始网页全文;策略不会做出其他承诺。
@@ -186,11 +157,11 @@ ctx.tools.register(defineTool({
**快照缺口。** 目前没有 ACP 快照场景覆盖 transcript(文本记录)可见的 `web_fetch` spill 提示。ACP 快照 harness 在无密钥环境中回放,无法访问实时 web,而 `web_fetch` spill 需要一个真实的超上限 HTTP 正文;确定性场景需要一个预置的 loopback fetch 目标,但当前回放树尚未接线(示例根本没有加载 `tool-web`)。该行为改由 `dsh-tool-web` 针对 loopback server 的集成测试覆盖。弥补该缺口属于后续工作:把 `tool-web` 和预置 fetch 目标接入 ACP 示例,然后录制 `web-fetch-spill` 场景。
如果策略开始负责工具专用语义,就会膨胀得过大。它的范围保持狭窄:只处理纯文本最终结果。由工具负责的提前 spill 仍留作未来工作。
如果策略开始负责工具专用语义,就会膨胀得过大。它处理已接受的图文序列,不解释工具专用的文字语义。由工具负责的提前 spill 仍留作未来工作。
## 考虑过的替代方案
**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。
**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineTokens` 部署配置项即可验证该形态。
**把 `tool-results` 建成宽泛的工具结果平台。** 不予采纳:宽泛的包名会诱使系统把保留策略、结果替换、预览措辞、搜索和提前 spill 合并进一个 seam。可共享的存储部分更小:保存文本,并返回定位符与检索提示。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
2026-09-14-deepseek-account-login.md: 9d9853617a3b7e288084c93eac4e150f3c180999
2026-09-14-deepseek-account-login.zh.md: d9cdbed25b0ae79caa4c61b310383cc52d8b586a
2026-09-14-deepseek-account-login.md: 458e020c2000fd3db5ee9806e99d4b653710cb1e
2026-09-14-deepseek-account-login.zh.md: 2fb59009302309a67e2c3ec99050685ba39b4212
@@ -36,7 +36,7 @@ The account plugin supplies choice, waiting, failure and timeout dialogs through
Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and its temporary session.
Support questionnaires receive only the Platform UID and available environment metadata through explicit prefill fields. Account tokens and masked contact details stay out of questionnaire URLs. The account provider projects the current-user UID for this purpose; the UI uses the latest account snapshot so sign-out cannot reuse a previous UID.
Support questionnaires receive only available environment metadata through explicit prefill fields. Account UIDs, tokens and masked contact details stay out of questionnaire URLs.
Private proxy development can explicitly map authorization and completion URLs to platformOrigin with rewriteBrowserOrigin. Both pages retain their fixed paths and full query strings; allowing a remote origin without mapping it would send the browser outside the configured environment. Shipped configuration requires same-origin URLs.
@@ -36,7 +36,7 @@ API Key 与账号记录独立保存。账号 token 没有过期或刷新流程
Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档及其临时会话。
支持问卷仅通过显式预填字段接收 Platform UID 和已有的环境信息,URL 不包含账号 token 或脱敏联系方式。账号提供者为此投影 current-user 的 UID;UI 使用最新账号快照,避免退出登录后复用旧 UID。
支持问卷仅通过显式预填字段接收已有的环境信息,URL 不包含账号 UID、token 或脱敏联系方式。
私有代理开发可通过 rewriteBrowserOrigin 显式将授权页和完成页映射到 platformOrigin。两者保留固定路径和完整查询字符串;仅放宽来源而不做映射会让浏览器离开配置的环境。发布配置要求浏览器地址同源。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.md
2026-09-21-multimodal-tool-result-retention.md: 41470724911d3086878d206f82c76ad9a4993146
2026-09-21-multimodal-tool-result-retention.zh.md: 8e6d4953f7380d45235ac13f9f245e2e005f1abf
@@ -0,0 +1,31 @@
# Agent Note: Multimodal tool-result retention
Status: implemented
English | [中文](2026-09-21-multimodal-tool-result-retention.zh.md)
## Problem
MCP prepares durable images asynchronously while its pure renderer emits text placeholders. Installing prepared content after post-execute policy makes text retention invalidate image replacement. A byte-only limit also cannot compare text with visual input costs.
## Decision
`ToolDefinition.projectContent` installs execution-prepared content before post-execute policy. The final callback retains its separate role for terminal and job limits. Policy blocks and replacements remain authoritative.
The spill policy prices ordered text/image blocks in estimated tokens, reusing the text estimator and the active route image calculator, including descriptor text. It reserves omission notices, divides remaining tokens between contiguous ends, and omits the middle. Text can split between code points; images remain indivisible at their original positions.
The complete spill file contains accepted text and readable attachment paths in order. Attachments own image bytes. Missing image pricing or a readable recovery path preserves the original result with a diagnostic. PTC program values remain complete; forwarding and logs retain a recovery notice even when every image is omitted.
The [spill storage decision](../architecture/2026-07-08-tool-output-spill-files.md) still owns backend separation and secure local files. The [MCP client decision](../feature/2026-07-07-mcp-client-plugin.md) still owns canonical values and image admission.
## Alternatives considered
**Restore images after policy edits.** This can resurrect blocked images or discarded text. Preparing content before policy lets transformations inspect the actual images.
**Keep images outside the budget or charge a fixed maximum.** Images must share the result budget with text. The existing route calculator already prices projected dimensions and descriptor text, so it owns that cost.
**Embed image bytes in spill text.** Base64 inflates the artifact and duplicates attachment storage. Readable attachment paths preserve retrieval and ordering without copying bytes.
## Consequences
The shipped result budget is 12,500 estimated tokens, replacing the former 50,000-byte setting. Text estimation remains heuristic; provider usage is authoritative. Unused space at an image boundary stays unused to preserve contiguous ends. Other block types pass through.
@@ -0,0 +1,31 @@
# Agent Note: 工具结果的图文保留
Status: implemented
[English](2026-09-21-multimodal-tool-result-retention.md) | 中文
## Problem
MCP 异步准备持久化图片,而纯渲染器输出文字占位。在执行后策略完成后才安装已准备内容,会让文字保留使图片替换失效。按字节限制也无法比较文字与视觉输入的成本。
## Decision
`ToolDefinition.projectContent` 在执行后策略之前安装执行期间准备的内容。最终回调保留其对终端和任务结果设定上限的独立职责。策略阻止和替换仍然生效。
spill 策略以估算 token 对有序图文块计量,复用文字估算和当前模型图片计算器,并计入说明文字。它预留省略提示,将剩余 token 平分给连续两端,省略中间。文字可在码点之间切分;图片不可切分,并保持原位置。
完整结果文件按顺序保存已接受文字和可读取的附件路径。附件拥有图片字节。缺少图片计量或可读取的恢复路径时,保留原结果并记录诊断。PTC 程序返回值保持完整;全部图片被省略时,转发内容和日志仍保留读取提示。
[spill 存储决策](../architecture/2026-07-08-tool-output-spill-files.zh.md)继续负责后端拆分和安全的本地文件。[MCP 客户端决策](../feature/2026-07-07-mcp-client-plugin.zh.md)继续负责规范值和图片准入。
## Alternatives considered
**在策略修改后恢复图片。** 这可能恢复已阻止图片或已丢弃文字。在策略之前准备内容,让转换能够检查真实图片。
**图片不计入预算或按固定上限计费。** 图片必须与文字共享结果预算。现有模型计算器已经衡量请求尺寸和说明文字,因此由它负责成本。
**把图片字节嵌入 spill 文本。** Base64 会增大产物并重复附件存储。可读取的附件路径在不复制字节的情况下保留读取方式和顺序。
## Consequences
随产品提供的结果预算为 12,500 个估算 token,替代此前的 50,000 字节配置。文字计量仍为估算,提供方用量具有权威性。图片分界处剩余空间保持空闲,以维持连续的首尾内容。其他内容块类型原样通过。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-21-persistent-pwsh-keeps-controlled-prompt.md
2026-09-21-persistent-pwsh-keeps-controlled-prompt.md: 3fd42b77d4536215f34c32643526dbb03d21674f
2026-09-21-persistent-pwsh-keeps-controlled-prompt.zh.md: 88d4e2deef26af4ad92ef4a69d8ae17403027f70
@@ -0,0 +1,37 @@
# Agent Note: Persistent pwsh keeps the backend's controlled prompt
Status: implemented
English | [中文](2026-09-21-persistent-pwsh-keeps-controlled-prompt.zh.md)
## Problem
`dsh-tool-pwsh-persistent` initialized its shell with a `prompt` function of its own (`'__DSH_PERSISTENT_PWSH_PROMPT__ '`), overwriting the `prompt` that `dsh-terminal-bash` installs in its pwsh startup sequence. The backend's prompt readiness requires the printable tail after the OSC `133;D` marker to exactly equal the controlled `dsh> ` prompt ([design](../feature/2026-07-16-persistent-pty-sessions.md)), so after initialization no send could settle through it and every send paid the silence tier plus handoff grace. Measured on Windows through the real Loader composition with production defaults: 8493 ms for the first call (spawn, initialization, and command) and 3722/3832/3759 ms for the next three, against 1340/255/251/241 ms with the controlled prompt intact. The package tests masked it by configuring `idleSilenceMs: 300`.
The [persistent bash tool fixed the identical defect](../../archived/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md) by giving up its own prompt override; the pwsh twin kept it. The override served the same two consumers there: a viewport-suffix fallback detecting "shell at a prompt without the end marker", and cosmetic stripping of prompt text from partial output.
## Decision
The tool no longer installs a prompt. `dsh-terminal-bash` owns the pwsh `prompt` function, installs it during `spawn`, and returns the session only after that startup reached `stdin_read` readiness, so the tool's initialization send had nothing left to establish and is deleted together with its `PWSH_PROMPT_SETUP` command.
The viewport-suffix fallback is replaced with the seam's own signal: a send that settles as `stdin_read` without the end marker in scrollback returns the captured partial output. The private prompt constant and its stripping are deleted; partial output may now end with the backend's own prompt text, which the tool cannot and should not know. `stripPrompt` collapses to `trimTrailingNewline`, matching `dsh-tool-bash-persistent`.
## Alternatives considered
**Align the tool's prompt text with the backend's `dsh> `.** Rejected: it keeps a second installation of one protocol constant and a second owner of it, and the send is redundant because the backend had already installed the identical prompt before `spawn` resolved.
**Import the controlled prompt into the tool.** Rejected: the prompt is one provider's protocol constant; a Consumer matching it would couple the tool to `dsh-terminal-bash` specifically, and any other mounted pwsh-dialect backend would break it again.
**Lower `handoffGraceMs`/`idleSilenceMs` instead.** Rejected: no silence value fixes a dead fast path; it rebalances how much every call overpays, and the silence tier is also what absorbs a genuinely broken prompt.
**Re-assert the pwsh prompt per prompt, as `PROMPT_COMMAND` does for bash.** Rejected: PowerShell has no per-prompt hook outside the `prompt` function itself, so a redefinition cannot be healed the way bash's variable is; the residue stays a documented limitation.
## Consequences
Windows, production defaults, real Loader composition: tool calls drop from 8493/3722/3832/3759 ms to 1340/255/251/241 ms (spawn + initialization + first command, then three warm commands).
The `stdin_read` fallback is behavior, not only cosmetics: where a provider proves a foreground stdin wait, an interactive child returns captured partial output instead of spinning to the command deadline, and elsewhere the call still runs to `timeoutMs`. That early return keeps the session, so a child still reading stdin can consume the next call's command until that call reaches `timeoutMs` and resets the shell; `dsh-tool-bash-persistent` behaves the same way, and distinguishing the shell's own prompt readiness from a child's stdin wait belongs to the seam rather than to either tool. Complete marker-delimited output is unchanged.
The loader-composition suite records every send's `waitReason` and requires at least six `stdin_read` settlements and no `inferred_idle`: the regression is reported by the settle reason itself, not by how long silence takes. Reinstating the removed prompt override fails that assertion after 30 s with `expected 0 to be greater than or equal to 6`, while the case's output assertions still pass. The stub suite drops the initialization-handshake modes and pins the prompt text retained in partial output instead of its absence.
A model command that redefines `prompt` still removes the readiness marker and degrades later sends to the silence tier: the backend installs its prompt once at startup and cannot re-assert it.
@@ -0,0 +1,37 @@
# Agent Note: 持久 pwsh 保留后端的受控提示符
Status: implemented
[English](2026-09-21-persistent-pwsh-keeps-controlled-prompt.md) | 中文
## Problem
`dsh-tool-pwsh-persistent` 用自己安装的 `prompt` 函数(`'__DSH_PERSISTENT_PWSH_PROMPT__ '`)初始化其 shell,覆盖了 `dsh-terminal-bash` 在 pwsh 启动序列中安装的 `prompt`。后端的提示符就绪检测要求 OSC `133;D` 标记之后的可打印尾部与受控提示符 `dsh> ` 完全相等([设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)),因此初始化之后任何 send 都无法经由该路径结算,每次 send 都要支付静默层加交接宽限。在 Windows 上通过真实 Loader 组合以生产默认值实测:首次调用(spawn、初始化与命令)8493 毫秒,随后三次为 3722/3832/3759 毫秒;受控提示符完好时为 1340/255/251/241 毫秒。包测试把 `idleSilenceMs` 配成 300,掩盖了该问题。
[持久 bash 工具已修复同一缺陷](../../archived/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md),方式是放弃自己的提示符覆盖;pwsh 孪生工具保留了它。该覆盖在两个工具中服务同样的两个消费点:用视口后缀检测「shell 已回到提示符但没有结束标记」的回退判定,以及从部分输出中剥离提示符文本的美化。
## Decision
工具不再安装提示符。pwsh 的 `prompt` 函数由 `dsh-terminal-bash` 拥有,在 `spawn` 期间安装,且只在该启动流程达到 `stdin_read` 就绪后才返回会话,因此工具的初始化 send 已无可确立之事,与其 `PWSH_PROMPT_SETUP` 命令一并删除。
视口后缀回退替换为 seam 自己的信号:一次以 `stdin_read` 结算而 scrollback 中没有结束标记的 send,返回已捕获的部分输出。私有提示符常量及其剥离逻辑删除;部分输出现在可能以后端自己的提示符文本结尾,工具无法也不应知道该文本。`stripPrompt` 收敛为 `trimTrailingNewline`,与 `dsh-tool-bash-persistent` 一致。
## Alternatives considered
**把工具的提示符文本对齐到后端的 `dsh> `。** 被拒绝:这保留了同一个协议常量的第二份安装与第二个拥有者,而且该 send 是多余的——后端在 `spawn` 返回前就已安装相同的提示符。
**把受控提示符导入工具。** 被拒绝:提示符是单个提供方的协议常量;Consumer 匹配它就把工具与 `dsh-terminal-bash` 具体耦合,换任何其他 pwsh 方言后端都会再次损坏。
**改为调小 `handoffGraceMs`/`idleSilenceMs`。** 被拒绝:任何静默值都修不好已死的快速路径,只是重新分配每次调用多付多少;而静默层同时也是吸收真正损坏提示符的那一层。
**像 bash 的 `PROMPT_COMMAND` 那样在每个提示符处重新设定 pwsh 提示符。** 被拒绝:除 `prompt` 函数本身之外,PowerShell 没有每提示符钩子,因此重定义无法像 bash 的变量那样被治好;该残余已记入已知限制。
## Consequences
Windows、生产默认值、真实 Loader 组合:工具调用从 8493/3722/3832/3759 毫秒降至 1340/255/251/241 毫秒(spawn + 初始化 + 首条命令,随后三条热命令)。
`stdin_read` 回退是行为而不只是美化:在提供方能证明前台 stdin 等待之处,交互式子进程会返回已捕获的部分输出,而不是空转到命令期限;其他情况下调用仍会运行到 `timeoutMs`。该提前返回保留会话,因此仍在读取 stdin 的子进程可能吞掉下一次调用的命令,直到那次调用到达 `timeoutMs` 并重置 shell;`dsh-tool-bash-persistent` 行为相同,而区分「shell 自身的提示符就绪」与「子进程的 stdin 等待」属于 seam,而不是任一工具。由标记界定的完整输出保持不变。
loader 组合套件记录每次 send 的 `waitReason`,要求至少六次 `stdin_read` 且没有 `inferred_idle`:回归由结算原因本身报告,而不是靠静默层耗时。把删掉的提示符覆盖加回去,该断言会在 30 秒后以 `expected 0 to be greater than or equal to 6` 失败,而同一用例的输出断言仍然通过。stub 套件删除了初始化握手模式,并改为锚定部分输出中保留的提示符文本,而不是它的缺席。
重定义 `prompt` 的模型命令仍会移除就绪标记,并把后续 send 降级到静默层:后端只在启动时安装一次提示符,无法重新设定。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md
2026-07-07-mcp-client-plugin.md: a4aa2cf216d9f28afedebf82f0659db70606bbf1
2026-07-07-mcp-client-plugin.zh.md: 5903e4ef976317602527d9f335d640bc86939f72
2026-07-07-mcp-client-plugin.md: dd793b67bf2043fa88377d50837df40faadf3fbf
2026-07-07-mcp-client-plugin.zh.md: 5056118df0185f1e7ec2b1ea06818ed920d2af26
@@ -145,7 +145,7 @@ A unified `execute` handler for all tools from one MCP server:
1. Call the SDK's `callTool` with the closed-over `rawName`, model arguments, `exec.signal`, the configured timeout, and the complete discovered `toolDefinition` — the public name is never sent to the server.
2. Preserve canonical success as `{ content: JsonValue[], structuredContent? }`; complete MCP JSON blocks remain the programmatic/PTC mode value. `isError: true` throws before any image persistence so the registry owns the failure path.
3. Prepare a separate ordered Native projection. Text runs join with `'\n'`; resource links preserve name and URI as text; audio and embedded resources become explicit diagnostics. The SDK rejects malformed wire results. If any image exists, the bridge strictly decodes the complete batch, resolves the calling agent's latest exact route, requires an attachment store plus explicit model image input, and delegates all-member validation and ordered persistence to `AttachmentStore.saveImages()`. Any decode, capability, or storage refusal renders every image as diagnostic text and returns no partial references.
4. Keep `output.render` synchronous and pure. The executor stages its richer projection in a generation-local `WeakMap` keyed by the exact execution; `finalizeContent` installs it only when the registry's post-execute result still has the original canonical value and fallback content. A policy block, value replacement, or content replacement remains authoritative, and a re-sync cannot let an older generation consume new execution state.
4. The executor preserves the complete canonical MCP value and prepares ordered text/image content. `projectContent` installs that content before `tools/post-execute`, so retention sees real images. Subsequent content replacement, value replacement, and blocking remain authoritative.
5. PTC mode receives the untouched canonical value. Its generic dispatch bridge defers a successful final content sequence containing an image through the outer `run_code` result, so MCP requires no private parent-token special case.
6. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, exact-model lookup, and the pre-storage gate.
@@ -157,6 +157,8 @@ Build the child environment from the subprocess seam's shared `scrubbedParentEnv
A per-instance connection supervisor reconnects automatically after a lost connection with bounded exponential backoff and a per-outage attempt budget, re-running discovery on success; exhaustion unregisters the server's tools and stops until reload. The [auto-reconnect Agent Note](../../archived/feature/2026-08-06-mcp-client-auto-reconnect.md) owns that decision, including the `reconnect` config block and the `reconnect.enabled: false` opt-out that restores manual HMR/restart recovery.
The [multimodal retention decision](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.md) explains why prepared images enter content before result policies.
## Alternatives considered
### MCP Server side (expose harness tools to external MCP clients)
@@ -145,7 +145,7 @@ SDK 接受符合协议的工具,并执行现代 HTTP header 声明检查。注
1. 调用 SDK 的 `callTool`,传入闭包持有的 `rawName`、模型参数、`exec.signal`、配置的超时时间与完整的已发现 `toolDefinition`——公开名称永远不发送给服务器。
2. 把规范成功值保留为 `{ content: JsonValue[], structuredContent? }`;完整 MCP JSON 块仍是程序化调用/PTC mode 值。`isError: true` 会在持久化任何图片前抛出,使失败路径归注册表所有。
3. 另行准备有序 Native 投影。连续文本块以 `'\n'` 连接;资源链接以文本保留名称和 URI;音频和嵌入资源成为明确诊断。SDK 拒绝格式错误的协议结果。只要存在图片,桥接层就严格解码完整批次,解析调用 agent 的最新确切路由,要求附件存储以及模型明确支持图片输入,再把全成员校验和有序持久化委托给 `AttachmentStore.saveImages()`。任何解码、能力或存储拒绝都会把全部图片渲染为诊断文本,且不返回部分引用。
4. 保持 `output.render` 同步且纯净。执行器把更丰富的投影暂存在按同步世代创建、以确切执行为键的 `WeakMap` 中;只有注册表的 post-execute 结果仍保留原规范值和兜底内容时,`finalizeContent` 才安装该投影。策略阻止、值替换或内容替换仍具有权威性,重新同步也无法让旧世代消费新执行状态。
4. 执行器保留完整的规范 MCP 返回值,并准备有序图文内容。`projectContent` 在 `tools/post-execute` 之前安装已准备的内容,让省略策略看到真实图片块。后续策略的内容替换、值替换和阻止均保持生效。
5. PTC mode 接收未改动的规范值。其通用分发桥接层会把包含图片的成功最终内容序列经外层 `run_code` 结果延后,因此 MCP 无需私有父 token 特例。
6. 取消:`exec.signal`(来自 agent loop 的取消)透传给 MCP SDK 的 `callTool`、确切模型查询和存储前门禁。
@@ -157,6 +157,8 @@ SDK 接受符合协议的工具,并执行现代 HTTP header 声明检查。注
每个实例的连接监督器在连接丢失后以有界指数退避和单次故障尝试预算自动重连,成功后重新执行发现流程;尝试耗尽则注销该服务器的工具并停止,直到重新加载。[自动重连 Agent Note](../../archived/feature/2026-08-06-mcp-client-auto-reconnect.md) 拥有该决策,包括 `reconnect` 配置块和恢复手动 HMR/重启恢复的 `reconnect.enabled: false` opt-out。
图片在结果策略之前进入内容的原因见[图文结果保留决策](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.zh.md)。
## 曾考虑的替代方案
### MCP Server 端(将 harness 工具暴露给外部 MCP 客户端)
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md
2026-08-11-background-job-completion-wakes-an-idle-owner.md: b8a10a2150ead02036cb97506a25c3a51202adcf
2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: adc0770cd00adeb3b13c95dcc386464493dcd943
2026-08-11-background-job-completion-wakes-an-idle-owner.md: f13f09d326dc7b4e7b2998f3124f70c185c50922
2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: f9b563ebbf90e2ca19d1fc5ca9fa5b028e4429f1
@@ -28,11 +28,11 @@ For a driver that is genuinely running, `steer()` and `inject()` are the same de
Injection is correct there. A cancelled turn is a user pressing stop, and reopening one on their behalf launders an interrupt into a model request they did not ask for. The turn loop already covers the ordinary case: it cannot close while the next-step inbox holds anything, so a notice arriving before that check extends the current turn, and several tasks settling together cost one step rather than one turn each.
### Waking is bounded, and the bound is not time
### The cap on consecutive wakes is opt-in, and it is not time
`maxConsecutiveWakes` (default 3) caps the turns one owner may open this way; beyond it a notice degrades to injection and waits for the next turn. Claiming any user-authored message restores the budget — claiming, not arrival, because that is the point human input actually enters a step. Notices this plugin queued never refill it.
Waking is unbounded unless `maxConsecutiveWakes` is set. With it set, the value caps the turns one owner may open this way; beyond it a notice degrades to injection and waits for the next turn. Claiming any user-authored message restores the budget — claiming, not arrival, because that is the point human input actually enters a step. Notices this plugin queued never refill it.
The bound exists because this chain is self-exciting in a way subagent settlement is not. Settlement is bounded by how many children the model spawned; a woken turn can start the background job whose completion wakes it again, with nobody watching. `dsh run` needs no separate policy: its one user message is claimed in the first turn and never repeats, so the budget is spent monotonically and the process terminates.
The cap exists because this chain is self-exciting in a way subagent settlement is not. Settlement is bounded by how many children the model spawned; a woken turn can start the background job whose completion wakes it again, with nobody watching. It has no default because a notice past the cap stalls the session invisibly; the [unbounded-by-default decision](2026-09-22-unbounded-completion-wakes-by-default.md) records that trade-off. `dsh run` needs no separate policy either way: headless exits once the task turn goes idle, so a completion after that point has no owner to wake.
`completionDelivery: quiet` restores the old lane for idle owners. It exists for deterministic transcripts; job completion independently retains `quiet | wakeup` because its bounded owner-turn policy differs from next-step subagent reports.
@@ -52,15 +52,15 @@ The bound exists because this chain is self-exciting in a way subagent settlemen
**A general unsolicited-input queue** with priority lanes, as Claude Code uses to merge background jobs, cron, MCP push, and hooks into one drain. DSH's inbox already is that queue — durable `agent/inbox/spliced` splices over `next-turn`/`next-step` — so this would add a layer above an existing one to decide a single bit.
**Refusing to reopen a turn that already produced a visible answer,** Codex's `MailboxDeliveryPhase` latch. That latch is the default this decision deliberately inverts: waking after the model has spoken is the entire point, and the wake budget is the bound instead.
**Refusing to reopen a turn that already produced a visible answer,** Codex's `MailboxDeliveryPhase` latch. That latch is the default this decision deliberately inverts: waking after the model has spoken is the entire point, and the optional wake budget is the bound instead.
**A wall-clock window** on top of the counter. For an interactive agent the slow case is the wanted one — an hour-long build finishing and the agent resuming is the feature — and `dsh run` is already bounded by the counter it cannot refill. Worth revisiting only if an unattended long-lived deployment appears.
**A wall-clock window** on top of the counter. For an interactive agent the slow case is the wanted one — an hour-long build finishing and the agent resuming is the feature — and `dsh run` exits once its task turn goes idle regardless. Worth revisiting only if an unattended long-lived deployment appears.
**Suppressing `onJobDone` entirely during owner drain,** symmetric with the service-wide `listenersClosed`. It reads cleaner and removes a signal that is not only for notices: the force-fail record and the runtime invariant both observe teardown settlements. The `reported` bit denies exactly the reporters and nothing else.
## Consequences
- Default behavior changes: an idle owner now spends a model request per completion, capped at `maxConsecutiveWakes` per owner between user messages. Deployments that want the old behavior set `completionDelivery: quiet`.
- Default behavior changes: an idle owner now spends a model request per completion, capped per owner between user messages only where `maxConsecutiveWakes` is set. Deployments that want the old behavior set `completionDelivery: quiet`.
- The `tool-jobs` prompt section needs no edit; "You are notified in-session when a task finishes" became true rather than aspirational.
- `JobSnapshot.reported` gains teardown as a fourth setter, documented at the Service Definition and in [the subsystem reference](../../../../docs/subsystems/jobs.md).
- `settle()` announces completion after committing the record and publishing the visible-set change. Any listener relying on running before waiters were released or before `onJobsChanged` now runs after both.
@@ -69,7 +69,7 @@ The bound exists because this chain is self-exciting in a way subagent settlemen
### Accepted risks
A spent budget is restored only by user input. An unattended agent that exhausts it collects its remaining notices whenever something else opens a turn, and nothing re-arms it in the meantime.
Under a configured `maxConsecutiveWakes`, a spent budget is restored only by user input. An unattended agent that exhausts it collects its remaining notices whenever something else opens a turn, nothing re-arms it in the meantime, and the client shows nothing while the notice waits. This is why the cap has no default.
A notice pending on an idle owner under `quiet` still dies with that owner's disposal, unchanged from before: the disposal cancel clears the unclaimed inbox and the log keeps the insert/cancel pair as the record. The [settlement delivery note](2026-08-06-manager-owned-subagent-settlement-delivery.md) owns the offline-mailbox discussion this would need.
@@ -28,11 +28,11 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
在那里注入才是对的。轮次被取消意味着用户按了停止,替他们重新开一轮等于把一次中断洗成了他们没有要求的模型请求。普通情形已由轮次循环覆盖:只要 next-step inbox 还有内容,轮次就无法结束,因此在该检查之前抵达的通知会延长当前轮次,同时结算的多个任务只花掉一步而不是各占一轮。
### 唤醒有界,且该界不是时间
### 连续唤醒上限是可选的,且该界不是时间
`maxConsecutiveWakes`(默认 3)限制一个所有者由此开启的轮数;超出后通知降级为注入,等待下一轮。领取任何用户撰写的消息都会恢复预算——是领取而非抵达,因为那才是人类输入真正进入某一步的时刻。本插件自己排队的通知永远不会补充它。
除非设置了 `maxConsecutiveWakes`,唤醒没有上限。设置后,该值限制一个所有者由此开启的轮数;超出后通知降级为注入,等待下一轮。领取任何用户撰写的消息都会恢复预算——是领取而非抵达,因为那才是人类输入真正进入某一步的时刻。本插件自己排队的通知永远不会补充它。
设界是因为这条链会自激,而 subagent 结算不会。结算受限于模型派生了多少子 agent;被唤醒的一轮却可能启动某个后台任务,而它的完成又会唤醒同一个所有者,且无人旁观。`dsh run` 不需要单独策略:它唯一的用户消息在第一轮就被领取且不会重复,因此预算单调消耗,进程必然终止。
设上限是因为这条链会自激,而 subagent 结算不会。结算受限于模型派生了多少子 agent;被唤醒的一轮却可能启动某个后台任务,而它的完成又会唤醒同一个所有者,且无人旁观。它没有默认值,因为超出上限的通知会让会话不可见地停摆;[默认无上限的决策](2026-09-22-unbounded-completion-wakes-by-default.zh.md)记录了这项取舍。`dsh run` 无论如何都不需要单独策略:headless 在任务轮次空闲后即退出,此后的完成没有可唤醒的所有者。
`completionDelivery: quiet` 为空闲所有者恢复旧通道。它的存在是为了确定性 transcript;后台任务完成会独立保留 `quiet | wakeup`,因为其有界的所有者轮次策略不同于 next-step subagent 报告。
@@ -52,15 +52,15 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
**一个通用的非请求输入队列**并带优先级通道,正如 Claude Code 用来把后台任务、cron、MCP 推送与 hook 合并进同一次排空。DSH 的 inbox 本身就是那个队列——`next-turn`/`next-step` 之上的持久 `agent/inbox/spliced` splice——因此这等于在既有层之上再加一层,只为决定一个 bit。
**拒绝重开一个已经产出可见答复的轮次**,即 Codex 的 `MailboxDeliveryPhase` 闩锁。那条闩锁正是本决策刻意反转的默认值:在模型已经说完话之后唤醒它就是本特性的全部意义,界由唤醒预算来承担。
**拒绝重开一个已经产出可见答复的轮次**,即 Codex 的 `MailboxDeliveryPhase` 闩锁。那条闩锁正是本决策刻意反转的默认值:在模型已经说完话之后唤醒它就是本特性的全部意义,界由可选的唤醒预算来承担。
**在计数之上再加墙钟窗口**。对交互式 agent 而言,慢的那种情形恰恰是想要的——一小时的构建结束、agent 接着干下去,这就是特性本身——而 `dsh run` 已被它无法补充的计数封顶。只有当出现无人值守的长生命周期部署时才值得重新考虑。
**在计数之上再加墙钟窗口**。对交互式 agent 而言,慢的那种情形恰恰是想要的——一小时的构建结束、agent 接着干下去,这就是特性本身——而 `dsh run` 无论如何都在任务轮次空闲后退出。只有当出现无人值守的长生命周期部署时才值得重新考虑。
**在 owner 排空期间整体压制 `onJobDone`**,与服务级的 `listenersClosed` 对称。它读起来更干净,但会移走一个不只服务于通知的信号:强制失败记录与运行时不变量都会观察 teardown 结算。`reported` 位恰好只否决报告方,别的什么也不否决。
## 影响
- 默认行为改变:空闲所有者现在每次完成会花掉一次模型请求,按所有者、在两次用户消息之间由 `maxConsecutiveWakes` 封顶。想要旧行为的部署设置 `completionDelivery: quiet`。
- 默认行为改变:空闲所有者现在每次完成会花掉一次模型请求,只有设置了 `maxConsecutiveWakes` 时才按所有者、在两次用户消息之间封顶。想要旧行为的部署设置 `completionDelivery: quiet`。
- `tool-jobs` 的提示词段落无需改动;「任务完成时你会在会话内收到通知」从愿景变成了事实。
- `JobSnapshot.reported` 新增 teardown 作为第四个置位方,记录在 Service Definition 与[子系统参考](../../../../docs/subsystems/jobs.zh.md)中。
- `settle()` 在提交记录并发布可见集变更之后才宣布完成。任何依赖「在释放等待方之前或在 `onJobsChanged` 之前运行」的监听器现在都排在两者之后。
@@ -69,7 +69,7 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
### 已接受的风险
已花掉的预算只由用户输入恢复。耗尽预算的无人值守 agent 要等到其他原因开启轮次时才收走剩余通知,在此期间没有任何机制为它重新充能。
在设置了 `maxConsecutiveWakes` 的情况下,已花掉的预算只由用户输入恢复。耗尽预算的无人值守 agent 要等到其他原因开启轮次时才收走剩余通知,在此期间没有任何机制为它重新充能,通知等待时客户端也毫无显示。这正是上限没有默认值的原因。
在 `quiet` 下待领于空闲所有者的通知仍会随该所有者释放而消亡,与此前一致:释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。[结算交付 note](2026-08-06-manager-owned-subagent-settlement-delivery.zh.md) 承载这需要的离线信箱讨论。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-22-unbounded-completion-wakes-by-default.md
2026-09-22-unbounded-completion-wakes-by-default.md: 8760154054de423240e16d93e3db5ae01521820a
2026-09-22-unbounded-completion-wakes-by-default.zh.md: e5560bc7ced5a8ca9ab55ea56ba309676d78b568
@@ -0,0 +1,36 @@
# Agent Note: Background job completion wakes are unbounded by default
Status: implemented
English | [中文](2026-09-22-unbounded-completion-wakes-by-default.zh.md)
## Problem
The [idle-owner wake decision](2026-08-11-background-job-completion-wakes-an-idle-owner.md) shipped `maxConsecutiveWakes` with a default of 3: an idle owner could be woken three times by job completions before further notices degraded to injection, and only a claimed user message refilled the budget. The counter was per owner and shared by every job kind — background `bash`/`pwsh`/PTY commands and one-shot `subagent_*` children.
A session that chained more than three of them without a user message hit the cap (issue #4944, reported as [dsh-external/issues#641](https://github.com/dsh-external/issues/issues/641)). The fourth notice was injected into the next-step inbox of an owner nothing would wake; the client renders no pending `context` message, so the session sat with a notice it could not see. The `tool-jobs` prompt promises the model "you are notified in-session when a job finishes — do not busy-poll", which encourages exactly the start-and-end-turn pattern that exhausts the cap. The stall arrived when the user was away and looked like a hang until they typed something.
## Decision
`maxConsecutiveWakes` has no default. Unset, every completion reaching an idle owner under `wakeup` delivery opens a turn. The `agent/inbox/claimed` refill listener and the per-owner ledger exist only when a cap is configured; a configured value must still be a whole number of turns, and `Infinity` is rejected because omitting the field already means unbounded.
The self-exciting chain the cap was built for — a woken turn starting the job whose completion wakes it again — is still real, and deployments that want the bound set the field. The trade-off is that the chain spends model requests the user can watch and stop, while a notice parked past a cap is invisible: nothing in the client shows it, nothing re-arms the budget, and the model's promise becomes false. An observable cost beats an unobservable stall.
`dsh run` is unaffected: headless exits once the task turn goes idle, so there is no idle owner left to wake.
## Alternatives considered
**Keep the default cap and make the parked notice visible** — a session-level pending indicator or an unread mark on the jobs list. This keeps the runaway bound but still breaks the prompt's promise: the model ended its turn expecting a notice and receives nothing until the user acts. Visibility is worth adding for a configured cap; it does not justify a default one.
**Count only self-excited wakes** — a wake counts against the budget only when the woken turn starts a new background job. This bounds the runaway chain without cutting off a chain of independent jobs, but the distinction needs the plugin to correlate job starts with the turn a wake opened, state no current consumer needs. It remains the natural refinement if a configured cap proves too coarse.
**A cooldown or rate limit instead of a counter.** Time does not distinguish a wanted chain from a runaway one; the wanted case is often the slow one, and a rate limit still parks a notice invisibly when it triggers.
**Re-check the next-step inbox on every turn-close path** (abort, LLM error, rejected pre-step). This closes the other parking spots the issue lists, but they are reached only by an already-parked notice; with no default cap the reported scenario never parks one. It stays a separate `agent-loop` change.
## Consequences
- An unattended session chaining background jobs is woken for every completion; the model request per wake is the cost, and the user can stop the session.
- The cap is opt-in configuration. Its README, config-catalog, and JSDoc state the silent-stall cost of setting it.
- Unit coverage pins four unattended completions each opening a turn under the default config; the cap, refill, and quiet-delivery tests keep explicit `maxConsecutiveWakes` values.
- No recorded-session snapshot changes: no shipped profile sets the field, and no existing scenario chains more than three idle completions.
@@ -0,0 +1,36 @@
# Agent Note: Background job completion wakes are unbounded by default
Status: implemented
[English](2026-09-22-unbounded-completion-wakes-by-default.md) | 中文
## 问题
[空闲所有者唤醒决策](2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)发布时 `maxConsecutiveWakes` 默认为 3:空闲所有者最多被任务完成唤醒三次,此后的通知降级为注入,只有被领取的用户消息才补充预算。计数器按所有者记录,并由所有任务类型共用——后台 `bash`/`pwsh`/PTY 命令与一次性 `subagent_*` 子级。
一个会话在没有用户消息的情况下串起超过三个这样的任务就会触顶(issue #4944,来源为 [dsh-external/issues#641](https://github.com/dsh-external/issues/issues/641))。第四条通知被注入到一个无人会唤醒的所有者的 next-step inbox;客户端不渲染待领的 `context` 消息,会话就带着一条自己看不见的通知停在那里。`tool-jobs` 的提示词向模型承诺「任务完成会在会话内通知你,不要轮询」,恰恰鼓励了会耗尽上限的「启动后结束本轮」模式。停摆发生在用户离开时,看起来像挂死,直到用户输入点什么。
## 决策
`maxConsecutiveWakes` 没有默认值。未设置时,`wakeup` 投递下抵达空闲所有者的每次完成都会开启一轮。`agent/inbox/claimed` 补充监听与按所有者的台账只在配置了上限时存在;配置的值仍必须是整数轮数,`Infinity` 被拒绝,因为省略该字段已经表示无上限。
上限所针对的自激链——被唤醒的一轮启动某个任务,其完成又唤醒它——依然真实存在,想要该界的部署自行设置字段。取舍在于:这条链花掉的是用户看得见、能停下的模型请求,而停在上限之外的通知是不可见的:客户端不显示,没有机制重新充能预算,模型得到的承诺也成了假话。可观察的代价胜过不可观察的停摆。
`dsh run` 不受影响:headless 在任务轮次空闲后即退出,没有可唤醒的空闲所有者。
## 被否决的替代方案
**保留默认上限,让停驻的通知可见**——会话级的待处理指示或任务列表上的未读标记。这保住了失控约束,但仍然违背提示词的承诺:模型结束本轮等着通知,却要等到用户动手才收到。可见性值得为已配置的上限补上;它不能为默认上限辩护。
**只统计自激的唤醒**——只有被唤醒的一轮又启动了新的后台任务时才计入预算。这约束了失控链而不切断一串独立任务,但这种区分需要插件把任务启动与唤醒所开启的轮次关联起来,是当前没有消费者需要的状态。若配置的上限被证明过粗,它仍是自然的细化方向。
**用冷却或速率限制代替计数器。** 时间无法区分想要的链与失控的链;想要的情形往往正是慢的那种,而速率限制触发时同样会把通知不可见地停驻。
**在每条轮次结束路径上复查 next-step inbox**(abort、LLM 错误、被拒绝的 pre-step)。这关闭了 issue 列出的其他停驻点,但它们只会被一条已停驻的通知触及;没有默认上限时,报告的场景根本不会停驻通知。它仍是独立的 `agent-loop` 改动。
## 影响
- 串起后台任务的无人值守会话会为每次完成被唤醒;每次唤醒一次模型请求是其代价,用户可以停止会话。
- 上限是可选配置。其 README、config-catalog 与 JSDoc 说明了设置它带来的静默停摆代价。
- 单元覆盖钉住默认配置下连续四次无人值守完成各开启一轮;上限、补充与 quiet 投递的测试保留显式的 `maxConsecutiveWakes` 值。
- 录制会话快照无变化:没有任何内置 profile 设置该字段,也没有既有场景串起超过三次空闲完成。
+13
View File
@@ -0,0 +1,13 @@
---
name: agent-experience
description: Use when designing agent tools, skills, context loading, or multi-step workflows to make information discoverable and use context efficiently.
metadata:
date: "2026-09-11"
---
- **Start with minimal context:** expose purpose, available actions, and constraints first; load detailed instructions when needed.
- **Make discovery explicit:** every deferred resource needs a clear description and a reliable way to retrieve it.
- **Keep critical constraints visible:** permissions, destructive effects, and required validation should appear before the relevant action.
- **Prefer bounded outputs:** return concise results with identifiers or paths for retrieving details; avoid dumping entire logs or documents.
- **Use locality:** include a bounded amount of likely needed adjacent context with an operation’s result. For example, deliver a thread reply with a few preceding messages. Mark omissions and truncation explicitly, and provide a way to retrieve more.
- **Evaluate total work:** saving context is useful only if it does not cause more searches, repeated reads, or mistakes.
+1
View File
@@ -143,6 +143,7 @@ pnpm applies local patches to the following packages at install time, so shipped
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@yao-pkg/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
## Official Claude Code platform payloads
@@ -189,9 +189,9 @@ class TeamFixtureAdapter extends LlmAdapter {
}
const chunks = identity === 'TEAM_WORKFLOW_CHILD'
? textChunks('Fresh workflow child complete.')
: identity === '<system-reminder>\nYou are teammate "researcher".\n</system-reminder>'
: identity === '<system-reminder>\nYou are teammate "researcher".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "<teammate name>", message: "..." }).\n</system-reminder>'
? researcher(options.messages)
: identity === '<system-reminder>\nYou are teammate "implementer".\n</system-reminder>'
: identity === '<system-reminder>\nYou are teammate "implementer".\nYour Team Lead is named "lead".\nUse list_agents({}) to find your teammates and their names.\nTo message your Team Lead, use send_message({ target: "lead", message: "..." }).\nTo message another teammate, use send_message({ target: "<teammate name>", message: "..." }).\n</system-reminder>'
? implementer(options.messages)
: lead(options.messages)
for (const chunk of chunks) {
@@ -862,9 +862,17 @@ describe('headless stream-json snapshots', () => {
"identityReminders": [
"<system-reminder>
You are teammate "implementer".
Your Team Lead is named "lead".
Use list_agents({}) to find your teammates and their names.
To message your Team Lead, use send_message({ target: "lead", message: "..." }).
To message another teammate, use send_message({ target: "<teammate name>", message: "..." }).
</system-reminder>",
"<system-reminder>
You are teammate "researcher".
Your Team Lead is named "lead".
Use list_agents({}) to find your teammates and their names.
To message your Team Lead, use send_message({ target: "lead", message: "..." }).
To message another teammate, use send_message({ target: "<teammate name>", message: "..." }).
</system-reminder>",
],
"memberEdges": 4,
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: 0cbc30d9214f3aa6c663d1e4a8a2771c4124da9c
README.zh.md: 0f03cb002ec8c4fa16e1255dc03728be13ae8d6f
README.md: c4f0e74710f3702402b0f8b316facf18648ac654
README.zh.md: c38695562b1084f7c1f9617629e6c8edf73c31e7
+1 -1
View File
@@ -407,7 +407,7 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
- Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
An expired login displays a timeout heading with explicit Sign in again and Add API Key actions. Opening the API-key form dismisses the authorization view; late account-state notifications do not replace an in-progress key entry.
+1 -1
View File
@@ -407,7 +407,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
- 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。
- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
登录超时后显示超时标题,并提供重新登录和添加 API Key 按钮。打开 API Key 表单会关闭授权视图;后续账号状态通知不会覆盖正在填写的密钥。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+2
View File
@@ -119,6 +119,8 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
graphics.ScaleTransform(page->dpi / 96.0f, page->dpi / 96.0f);
graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic);
graphics.SetPixelOffsetMode(PixelOffsetModeHalf);
graphics.DrawImage(page->brand, Rect(0, 174, 600, 196));
const int stage = static_cast<int>(reinterpret_cast<INT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.Stage")));
const double fraction = reinterpret_cast<UINT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.ExtractProgress")) / 100.0;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 126 KiB

+3 -3
View File
@@ -1,8 +1,8 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(32 32) scale(0.9375) translate(-40 -32)"><g filter="url(#filter0_d_1637_371)">
<rect rx="44" width="1024" height="1024" transform="translate(40 32)" fill="#FBFBFB"/>
<rect rx="44" width="1024" height="1024" transform="translate(40 32)" fill="url(#paint0_linear_1637_371)" fill-opacity="0.4"/>
<rect rx="44" width="1024" height="1024" transform="translate(40 32)" fill="url(#paint1_linear_1637_371)" fill-opacity="0.8"/>
<rect rx="192" width="1024" height="1024" transform="translate(40 32)" fill="#FBFBFB"/>
<rect rx="192" width="1024" height="1024" transform="translate(40 32)" fill="url(#paint0_linear_1637_371)" fill-opacity="0.4"/>
<rect rx="192" width="1024" height="1024" transform="translate(40 32)" fill="url(#paint1_linear_1637_371)" fill-opacity="0.8"/>
<g filter="url(#filter1_ddi_1637_371)">
<path d="M968.002 326.025C959.5 321.85 955.842 329.804 950.865 333.835C949.162 335.143 947.722 336.834 946.283 338.393C933.859 351.673 919.336 360.407 900.376 359.363C872.649 357.804 848.963 366.525 828.035 387.759C823.586 361.583 808.812 345.951 786.313 335.936C774.536 330.729 762.64 325.51 754.401 314.186C748.645 306.112 747.074 297.127 744.195 288.274C742.36 282.936 740.538 277.465 734.385 276.554C727.718 275.51 725.103 281.112 722.476 285.803C712.006 304.95 707.952 326.039 708.349 347.405C709.26 395.463 729.54 433.742 769.823 460.962C774.404 464.094 775.579 467.212 774.14 471.771C771.394 481.153 768.119 490.257 765.241 499.639C763.406 505.624 760.66 506.933 754.256 504.329C732.154 495.08 713.049 481.417 696.175 464.874C667.524 437.138 641.633 406.536 609.325 382.566C601.733 376.964 594.154 371.758 586.298 366.802C553.33 334.773 590.616 308.464 599.251 305.333C608.282 302.082 602.393 290.877 573.214 291.009C544.048 291.141 517.364 300.906 483.353 313.935C478.389 315.891 473.147 317.318 467.786 318.494C436.917 312.64 404.873 311.332 371.377 315.111C308.332 322.141 257.975 351.964 220.953 402.876C176.471 464.081 166.014 533.624 178.835 606.153C192.302 682.594 231.291 745.887 291.194 795.372C353.328 846.68 424.876 871.813 506.498 866.99C556.076 864.122 611.279 857.489 673.532 804.74C689.23 812.55 705.708 815.681 733.052 818.02C754.111 819.976 774.391 816.976 790.076 813.726C814.674 808.519 812.971 785.726 804.072 781.564C731.995 747.962 747.826 761.638 733.435 750.565C770.06 707.198 825.263 662.139 846.85 516.156C848.553 504.567 847.114 497.273 846.85 487.892C846.718 482.157 848.025 479.95 854.574 479.303C872.623 477.215 890.156 472.273 906.238 463.42C952.938 437.892 971.765 395.965 976.215 345.7C976.875 338.01 976.083 330.069 967.976 326.039L968.002 326.025ZM561.067 778.432C491.222 723.477 457.343 705.374 443.347 706.154C430.263 706.933 432.626 721.918 435.505 731.682C438.515 741.315 442.436 747.962 447.929 756.418C451.718 762.021 454.346 770.345 444.14 776.609C421.641 790.549 382.533 771.918 380.698 771.006C335.174 744.182 297.109 708.757 270.293 660.316C244.389 613.698 229.35 563.685 226.868 510.302C226.208 497.406 230.01 492.847 242.831 490.508C259.704 487.376 277.106 486.729 293.98 489.2C365.264 499.625 425.959 531.523 476.844 582.052C505.878 610.831 527.861 645.213 550.491 678.815C574.561 714.492 600.452 748.49 633.42 776.358C645.066 786.122 654.348 793.548 663.247 799.019C636.431 802.018 591.698 802.666 561.08 778.445L561.067 778.432ZM594.55 562.905C594.55 557.171 599.132 552.625 604.888 552.625C606.195 552.625 607.371 552.876 608.414 553.273C609.853 553.801 611.16 554.581 612.203 555.744C614.038 557.567 615.081 560.17 615.081 562.905C615.081 568.64 610.5 573.185 604.743 573.185C598.987 573.185 594.537 568.627 594.537 562.905H594.55ZM698.539 616.301C691.871 619.037 685.19 621.375 678.787 621.64C668.845 622.155 657.992 618.125 652.103 613.17C642.953 605.479 636.404 601.185 633.658 587.773C632.483 582.039 633.13 573.185 634.186 568.111C636.536 557.171 633.922 550.141 626.212 543.759C619.927 538.553 611.952 537.112 603.185 537.112C599.911 537.112 596.914 535.685 594.682 534.509C591.025 532.686 588.015 528.127 590.893 522.525C591.804 520.701 596.254 516.275 597.297 515.495C609.206 508.716 622.937 510.936 635.625 516.01C647.403 520.833 656.288 529.686 669.109 542.186C682.193 557.289 684.543 561.465 692.003 572.789C697.892 581.642 703.252 590.759 706.909 601.185C709.128 607.699 706.249 613.038 698.539 616.288V616.301Z" fill="url(#paint2_linear_1637_371)" fill-opacity="0.9"/>
</g>

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

+6 -2
View File
@@ -3,6 +3,8 @@
export const en = {
application: 'Application',
aboutMenu: 'About DeepSeek Harness',
aboutProduct: 'DeepSeek Harness',
aboutVersion: 'Version V{version}',
hideApplication: 'Hide DeepSeek Harness',
hideOtherApplications: 'Hide Others',
showAllApplications: 'Show All',
@@ -89,7 +91,7 @@ export const en = {
updateTechnicalDetails: 'View technical details',
updateTitle: 'DeepSeek Harness Update',
updateAvailable: 'An update is available',
updateDetail: 'DeepSeek Harness {version}\n\nThis release includes its matching dsh version. The application will restart after installation.',
updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness will restart to complete the update.',
installAndRestart: 'Install and Restart',
later: 'Later',
updateFailedTitle: 'Update Failed',
@@ -131,6 +133,8 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
export const zh = {
application: '应用',
aboutMenu: '关于 DeepSeek Harness',
aboutProduct: 'DeepSeek Harness',
aboutVersion: '版本 V{version}',
hideApplication: '隐藏 DeepSeek Harness',
hideOtherApplications: '隐藏其他',
showAllApplications: '显示全部',
@@ -217,7 +221,7 @@ export const zh = {
updateTechnicalDetails: '查看技术详情',
updateTitle: 'DeepSeek Harness 更新',
updateAvailable: '发现可用更新',
updateDetail: 'DeepSeek Harness {version}\n\n新版本绑定匹配的 dsh,安装后将重新启动。',
updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness 将重启以完成更新。',
installAndRestart: '安装并重启',
later: '稍后',
updateFailedTitle: '更新失败',
+29 -3
View File
@@ -156,6 +156,20 @@ function chromeFallbackFill(): string {
return nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb'
}
/**
* Add the effective Desktop palette to a Platform authorization URL so the
* login page opens in the application's theme. `system` resolves through
* `nativeTheme.shouldUseDarkColors`, which follows the theme source the
* application preload publishes.
* @param authorizeUrl - validated Platform authorization URL.
* @returns the authorization URL carrying `theme=light` or `theme=dark`.
*/
function platformLoginUrl(authorizeUrl: string): string {
const url = new URL(authorizeUrl)
url.searchParams.set('theme', nativeTheme.shouldUseDarkColors ? 'dark' : 'light')
return url.href
}
function createWindow(preload: string, show = false, primary = false): BrowserWindow {
const window = new BrowserWindow({
width: 1280,
@@ -306,6 +320,13 @@ async function main(): Promise<void> {
}
finally { ordinaryDialogs.delete(controller) }
}
// Copy comes from the same locale as the update prompts so the dialog
// chrome and its content never mix languages.
const showAbout = async (): Promise<void> => {
await ordinaryMessageBox({ type: 'info', title: locale.messages.aboutMenu, message: locale.messages.aboutProduct,
detail: formatDesktopMessage(locale.messages.aboutVersion, { version: app.getVersion() }),
buttons: [locale.messages.updateAcknowledge], cancelId: 0 })
}
const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
const applicationUrl = `${SCHEME}://app/`
let hostUrl: string | undefined
@@ -363,7 +384,7 @@ async function main(): Promise<void> {
const attempt = state.attempt
if (attempt?.phase === 'waiting-browser' && attempt.authorizeUrl !== undefined && openedAttempt !== attempt.id) {
openedAttempt = attempt.id
void shell.openExternal(attempt.authorizeUrl).catch(() => undefined)
void shell.openExternal(platformLoginUrl(attempt.authorizeUrl)).catch(() => undefined)
}
if ((attempt?.phase === 'failed' || attempt?.phase === 'expired') && returnedAttempt !== attempt.id) {
returnedAttempt = attempt.id
@@ -785,7 +806,12 @@ async function main(): Promise<void> {
{ role: 'unhide', label: currentDesktopLocale().messages.showAllApplications }, { type: 'separator' }]
: []
const applicationItems = (): MenuItemConstructorOptions[] => [
{ label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
// Windows has no system About panel; Electron's fallback is a plain
// message box, so the shell shows its own dimmed dialog instead.
process.platform === 'win32'
? { label: currentDesktopLocale().messages.aboutMenu,
click: () => { void showAbout().catch((error: unknown) => { console.error(error) }) } }
: { label: currentDesktopLocale().messages.aboutMenu, role: 'about' },
{ type: 'separator' },
{ label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } },
...development ? [
@@ -928,7 +954,7 @@ async function main(): Promise<void> {
if (state?.attempt?.id !== id || state.attempt.phase !== 'waiting-browser' || state.attempt.authorizeUrl === undefined) {
throw new Error('desktop welcome: login link is unavailable')
}
await clipboard.writeText(state.attempt.authorizeUrl)
await clipboard.writeText(platformLoginUrl(state.attempt.authorizeUrl))
},
saveApiKey: async (apiKey) => {
if (backend.host === undefined || welcomeBackend === undefined) return { ok: false }
+9 -8
View File
@@ -1,4 +1,4 @@
/** Mirrors the Web UI's theme source into the macOS native theme so window vibrancy follows the app palette. */
/** Mirrors the Web UI's theme source into Electron's native theme so native chrome and Platform login pages follow the app palette. */
import { ipcRenderer } from 'electron'
import { DESKTOP_IPC } from './ipc.ts'
@@ -7,14 +7,15 @@ import { DESKTOP_IPC } from './ipc.ts'
const THEME_SOURCE_ATTRIBUTE = 'data-ds-theme-source'
/**
* On macOS, watches `html[data-ds-theme-source]` and forwards each value to
* the main process, which sets `nativeTheme.themeSource` — the sidebar
* vibrancy material then follows the app's theme preference instead of the
* OS appearance, while `system` keeps following the OS. Other platforms
* never send.
* Watches `html[data-ds-theme-source]` and forwards each value to the main
* process, which sets `nativeTheme.themeSource`. Native chrome and renderer
* `prefers-color-scheme` queries on every platform then follow the app's
* theme preference instead of the OS appearance while `system` keeps
* following the OS; the macOS sidebar vibrancy material is one such consumer.
* The main process reads the same value back as `shouldUseDarkColors` when a
* Platform login link needs the resolved palette.
*/
export function syncNativeTheme(): void {
if (process.platform !== 'darwin') return
let sent: string | undefined
const send = (): void => {
const value = document.documentElement.getAttribute(THEME_SOURCE_ATTRIBUTE)
@@ -28,6 +29,6 @@ export function syncNativeTheme(): void {
}
// The preload runs before the document root exists; the theme bootstrap
// script writes the attribute before DOMContentLoaded.
if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', observe)
if (document.readyState === 'loading') window.addEventListener('DOMContentLoaded', observe, { once: true })
else observe()
}
+28 -2
View File
@@ -1,5 +1,5 @@
{
"en-US": {
"darwin:en-US": {
"menu": [
{ "label": "About DeepSeek Harness", "role": "about" },
{ "type": "separator" }
@@ -12,7 +12,7 @@
"iconPath": "<app icon>"
}
},
"zh-CN": {
"darwin:zh-CN": {
"menu": [
{ "label": "关于 DeepSeek Harness", "role": "about" },
{ "type": "separator" }
@@ -24,5 +24,31 @@
"copyright": "",
"iconPath": "<app icon>"
}
},
"win32:zh-CN": {
"menu": [
{ "label": "关于 DeepSeek Harness" },
{ "type": "separator" }
],
"options": {
"applicationName": "DeepSeek Harness",
"applicationVersion": "1.0.0",
"version": "",
"copyright": "",
"iconPath": "<app icon>"
}
},
"win32:en-US": {
"menu": [
{ "label": "About DeepSeek Harness" },
{ "type": "separator" }
],
"options": {
"applicationName": "DeepSeek Harness",
"applicationVersion": "1.0.0",
"version": "",
"copyright": "",
"iconPath": "<app icon>"
}
}
}
+38 -3
View File
@@ -175,7 +175,7 @@ const harness = await vi.hoisted(async () => {
get publishUpdate() { return publishUpdate! },
set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value },
dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() },
openExternal: vi.fn(),
openExternal: vi.fn(async () => {}),
protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise<Response>) => void>(),
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
disableAllPlugins: vi.fn(async () => {
@@ -415,7 +415,7 @@ describe('desktop main startup', () => {
['darwin', false, 'zh-CN'],
['win32', true, 'zh-CN'],
['win32', false, 'en-US'],
] as const)('offers the native About panel before other commands on %s (packaged=%s, locale=%s)', async (platform, packaged, locale) => {
] as const)('offers the About command before other commands on %s (packaged=%s, locale=%s)', async (platform, packaged, locale) => {
vi.stubGlobal('process', { ...process, platform })
harness.app.isPackaged = packaged
vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale)
@@ -425,9 +425,26 @@ describe('desktop main startup', () => {
const submenu = applicationMenuItems()
const options = harness.app.setAboutPanelOptions.mock.calls[0]![0]
const expected = JSON.parse(readFileSync(new URL('./expected/about-panel.json', import.meta.url), 'utf8')) as Record<string, unknown>
expect({ menu: submenu.slice(0, 2), options: { ...options, iconPath: '<app icon>' } }).toEqual(expected[locale])
const [about, separator] = submenu
expect({ menu: [{ label: about!.label, role: about!.role }, separator], options: { ...options, iconPath: '<app icon>' } })
.toEqual(expected[`${platform}:${locale}`])
expect(options.iconPath).toBe(packaged ? join('desktop-test-resources', 'icon.png')
: join('desktop-test-app', 'resources', 'icon-windows.png'))
if (platform !== 'win32') { expect(about!.click).toBeUndefined(); return }
// Windows reuses the dimmed update dialog because Electron's fallback is a bare message box.
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
;(about!.click as () => void)()
await vi.advanceTimersByTimeAsync(0)
const zh = locale === 'zh-CN'
expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({
type: 'info', title: zh ? '关于 DeepSeek Harness' : 'About DeepSeek Harness', message: 'DeepSeek Harness',
detail: zh ? '版本 V1.0.0' : 'Version V1.0.0', buttons: [zh ? '确定' : 'OK'], cancelId: 0,
}))
// A dialog that cannot open is logged, not surfaced as an unhandled rejection.
harness.dialog.showMessageBox.mockRejectedValueOnce(new Error('overlay unavailable'))
;(about!.click as () => void)()
await vi.advanceTimersByTimeAsync(0)
expect(console.error).toHaveBeenLastCalledWith(expect.objectContaining({ message: 'overlay unavailable' }))
})
it('shows one explained startup login before Host readiness and joins concurrent checks without reopening it', async () => {
@@ -1686,3 +1703,21 @@ it.each(['failed', 'expired'] as const)('focuses DSH once when browser authoriza
harness.publishAccount(state)
expect(window.focus).toHaveBeenCalledTimes(1)
})
it.each([['light', false], ['dark', true]] as const)('opens Platform authorization in the effective %s palette', async (theme, shouldUseDarkColors) => {
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
harness.hosts[0]!.ready.resolve()
await Promise.resolve(invoke(DESKTOP_IPC.boot))
harness.nativeTheme.shouldUseDarkColors = shouldUseDarkColors
const state: AccountView = {
status: 'signed-out', links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' },
attempt: { id: 'test-theme-attempt' as NonNullable<AccountView['attempt']>['id'], phase: 'waiting-browser',
authorizeUrl: 'https://platform.deepseek.com/dsh/authorize?state=state-1' },
}
harness.publishAccount(state)
harness.publishAccount(state)
expect(harness.openExternal).toHaveBeenCalledExactlyOnceWith(`https://platform.deepseek.com/dsh/authorize?state=state-1&theme=${theme}`)
})
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest'
import { DESKTOP_IPC } from '../src/ipc.ts'
import { syncNativeTheme } from '../src/preload-theme.ts'
const send = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({ ipcRenderer: { send } }))
/** Observers created by the module under test; jsdom keeps one document per file. */
const observers = new Set<MutationObserver>()
const NativeMutationObserver = MutationObserver
/** Records every observer so teardown detaches it from the shared document. */
class TrackedMutationObserver extends NativeMutationObserver {
constructor(callback: MutationCallback) {
super(callback)
observers.add(this)
}
}
afterEach(() => {
for (const observer of observers) observer.disconnect()
observers.clear()
document.documentElement.removeAttribute('data-ds-theme-source')
vi.unstubAllGlobals()
vi.restoreAllMocks()
send.mockClear()
})
it.each(['darwin', 'win32'] as const)('mirrors the application theme source on %s', async (platform) => {
vi.stubGlobal('MutationObserver', TrackedMutationObserver)
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
document.documentElement.setAttribute('data-ds-theme-source', 'dark')
syncNativeTheme()
expect(send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.nativeThemeSet, 'dark')
document.documentElement.setAttribute('data-ds-theme-source', 'system')
await vi.waitFor(() => { expect(send).toHaveBeenLastCalledWith(DESKTOP_IPC.nativeThemeSet, 'system') })
expect(send).toHaveBeenCalledTimes(2)
})
it('defers observation until the document root exists', () => {
vi.stubGlobal('MutationObserver', TrackedMutationObserver)
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
vi.spyOn(document, 'readyState', 'get').mockReturnValue('loading')
syncNativeTheme()
expect(send).not.toHaveBeenCalled()
document.documentElement.setAttribute('data-ds-theme-source', 'light')
window.dispatchEvent(new Event('DOMContentLoaded'))
expect(send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.nativeThemeSet, 'light')
})
+48 -1
View File
@@ -6,26 +6,73 @@ import { describe, expect, it, vi } from 'vitest'
const preload = (name: string): string => fileURLToPath(new URL(`../lib/${name}.cjs`, import.meta.url))
/**
* Browser globals a sandboxed Electron preload sees. A preload runs while the
* document is still loading, so the theme preload defers its first read to
* `DOMContentLoaded` and observes later `html[data-ds-theme-source]` changes.
* @returns context globals plus the root attribute transitions the Web UI performs.
*/
function browserEnvironment() {
const attributes = new Map<string, string>()
const listeners = new Map<string, Array<() => void>>()
const observers: Array<() => void> = []
const document = {
readyState: 'loading',
documentElement: {
dataset: {} as Record<string, string>,
getAttribute: (name: string): string | null => attributes.get(name) ?? null,
setAttribute: (name: string, value: string): void => { attributes.set(name, value) },
},
}
const window = {
addEventListener: (type: string, listener: () => void): void => {
listeners.set(type, [...listeners.get(type) ?? [], listener])
},
}
class SandboxMutationObserver {
constructor(callback: () => void) { observers.push(callback) }
observe(): void {}
}
return {
globals: { document, window, MutationObserver: SandboxMutationObserver },
/** Writes the theme root attribute and settles the document, as the theme bootstrap does before DOMContentLoaded. */
loadTheme(value: string): void {
attributes.set('data-ds-theme-source', value)
for (const listener of listeners.get('DOMContentLoaded') ?? []) listener()
},
/** Applies a later theme preference change through the observer the preload registered. */
changeTheme(value: string): void {
attributes.set('data-ds-theme-source', value)
for (const observer of observers) observer()
},
}
}
describe.skipIf(!existsSync(preload('preload-app')))('built sandboxed Desktop preloads', () => {
it.each(['preload-app', 'preload-welcome'])('%s loads without filesystem module access', (name) => {
const exposed = new Map<string, Record<string, unknown>>()
const invoke = vi.fn(() => Promise.resolve({ languages: ['en-US'], preference: 'zh' }))
const send = vi.fn()
const browser = browserEnvironment()
const electron = {
contextBridge: { exposeInMainWorld: (key: string, value: Record<string, unknown>) => { exposed.set(key, value) } },
ipcRenderer: { invoke, send, on: vi.fn(), off: vi.fn() },
}
runInNewContext(readFileSync(preload(name), 'utf8'), {
...browser.globals,
require: (id: string) => {
if (id !== 'electron') throw new Error(`sandbox cannot load ${id}`)
return electron
},
process: { argv: ['electron', '--dsh-welcome-locale=en'] },
location: new URL('dsh-app://app/'),
document: { documentElement: { dataset: {} } },
exports: {},
})
if (name === 'preload-app') {
browser.loadTheme('dark')
expect(send).toHaveBeenCalledWith('dsh-desktop:native-theme-set', 'dark')
browser.changeTheme('light')
expect(send).toHaveBeenCalledWith('dsh-desktop:native-theme-set', 'light')
const bridge = exposed.get('__DSH_LOCALE__') as { read(): unknown; onChange(locale: string): void }
bridge.read()
expect(invoke).toHaveBeenCalledWith('dsh-desktop:locale-bootstrap')
+8 -3
View File
@@ -31,6 +31,7 @@ const state = vi.hoisted(() => ({
windowOptions: undefined as BrowserWindowConstructorOptions | undefined,
menu: vi.fn(),
operations: undefined as WelcomeOperations | undefined,
nativeTheme: { themeSource: 'system', shouldUseDarkColors: false },
}))
vi.mock('../src/crash-report.ts', async importOriginal => ({
@@ -74,7 +75,7 @@ vi.mock('electron', () => ({
async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() }
},
net: { fetch: vi.fn() },
nativeTheme: { themeSource: 'system' },
nativeTheme: state.nativeTheme,
session: { defaultSession: {
setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() },
} },
@@ -184,11 +185,15 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho
attempt: { id: attemptId, phase: 'waiting-browser', authorizeUrl: 'https://example.test/login' } }
state.accountState.mockResolvedValue(account)
await state.operations!.copySignInLink(attemptId)
expect(state.copy).toHaveBeenCalledExactlyOnceWith('https://example.test/login')
expect(state.copy).toHaveBeenCalledExactlyOnceWith('https://example.test/login?theme=light')
state.nativeTheme.shouldUseDarkColors = true
await state.operations!.copySignInLink(attemptId)
expect(state.copy).toHaveBeenLastCalledWith('https://example.test/login?theme=dark')
state.nativeTheme.shouldUseDarkColors = false
await expect(state.operations!.copySignInLink('stale' as typeof attemptId)).rejects.toThrow('login link is unavailable')
state.accountState.mockResolvedValue({ ...account, attempt: { id: attemptId, phase: 'expired' } })
await expect(state.operations!.copySignInLink(attemptId)).rejects.toThrow('login link is unavailable')
expect(state.copy).toHaveBeenCalledOnce()
expect(state.copy).toHaveBeenCalledTimes(2)
await state.operations!.skip()
expect(state.loadWorkspace).not.toHaveBeenCalled()
expect(state.showWorkspace).toHaveBeenCalledOnce()
+14 -11
View File
@@ -121,24 +121,27 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
within(contextPanel).getByText('Tool definitions')
within(contextPanel).getByText('Messages')
// The write/edit turns render a real diff card through the assembled graph
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
// fixture's raw text. The card is collapsed by default, so expand each edit/
// write row first. The write turn's `hello fixture\n` proves the terminator
// rule end to end: a trailing newline terminates its line, so the footer reads
// `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS
// ::before, so it is absent from textContent — assert on the line body and the
// footer.
const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')]
// The built ToolRow and DiffBlock share the write fixture's trailing-newline
// semantics: `hello fixture\n` contributes one addition. Counts belong to the
// tool row; the diff's line markers are CSS pseudo-elements, absent from textContent.
const mutationRows = [...document.querySelectorAll<HTMLElement>('[data-variant="write"],[data-variant="edit"]')]
expect(mutationRows.length).toBeGreaterThan(0)
const writeRow = mutationRows.find(row => row.getAttribute('data-variant') === 'write'
&& row.textContent?.includes('new-demo.txt'))
if (writeRow === undefined) throw new Error('fixture write row missing')
expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1)
expect(writeRow.querySelector('[data-diff]')).toBeNull()
for (const row of mutationRows) {
const toggle = row.querySelector('[data-expandable]')
if (toggle !== null) act(() => { fireEvent.click(toggle) })
}
const diffCards = [...document.querySelectorAll('[data-diff]')]
expect(diffCards.length).toBeGreaterThan(0)
const footers = diffCards.map(card => card.textContent ?? '')
expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
const writeDiff = writeRow.querySelector<HTMLElement>('[data-diff]')
if (writeDiff === null) throw new Error('expanded fixture write diff missing')
within(writeDiff).getByText('hello fixture', { exact: true })
expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1)
expect(writeDiff.textContent).not.toContain('+1 -0')
// The web render intent reaches the assembled boot graph: the fixture's
// web_search / web_fetch turns render their keyed WebRow cards, proving the
+48
View File
@@ -482,6 +482,54 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('keeps the file menu aligned while dragging the floating review', async () => {
const column = page.locator('[data-rightbar-col]')
const tab = column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' })
const tabBox = await tab.boundingBox()
if (tabBox === null) throw new Error('review tab is not rendered')
await page.mouse.move(tabBox.x + 6, tabBox.y + tabBox.height / 2)
await page.mouse.down()
try {
await page.mouse.move(360, 140, { steps: 8 })
} finally {
await page.mouse.up()
}
const floating = page.locator('[data-dockkit-float]').filter({ has: page.locator('[data-changes-review]') })
await floating.waitFor({ state: 'visible' })
const selector = floating.getByRole('button', { name: '选择要查看的文件' })
await selector.click()
const menu = page.getByRole('menu')
await menu.waitFor({ state: 'visible' })
const grip = floating.locator('[data-dockkit-float-grip]')
for (const delta of [{ x: 120, y: 60 }, { x: -60, y: -30 }]) {
const handle = await grip.boundingBox()
const before = await selector.boundingBox()
if (handle === null || before === null) throw new Error('floating review controls are not rendered')
const start = { x: handle.x + 12, y: handle.y + handle.height / 2 }
await page.mouse.move(start.x, start.y)
await page.mouse.down()
try {
await page.mouse.move(start.x + delta.x, start.y + delta.y, { steps: 8 })
await expect.poll(async () => {
const anchor = await selector.boundingBox()
const list = await menu.boundingBox()
if (anchor === null || list === null) throw new Error('review file menu disappeared during drag')
return Math.max(
Math.abs(anchor.x - before.x - delta.x), Math.abs(anchor.y - before.y - delta.y),
Math.abs(list.x - anchor.x), Math.abs(list.y - anchor.y - anchor.height - 4),
)
}).toBeLessThanOrEqual(1)
} finally {
await page.mouse.up()
}
}
await page.keyboard.press('Escape')
await floating.locator('[data-dockkit-float-dock]').click()
await floating.waitFor({ state: 'detached' })
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it('keeps edge gestures inside either diff column and accepts reverse scrolling', async () => {
const review = page.locator('[data-changes-review]')
await review.getByRole('button', { name: '自动换行' }).click()
@@ -454,8 +454,8 @@ describe('web e2e: clickable links gallery', () => {
await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL)
await openSettings(page, 'en')
await page.getByRole('button', { name: 'Built-in browser', exact: true }).click()
await page.getByRole('menuitem', { name: 'New browser tab', exact: true }).click()
await page.getByRole('button', { name: 'In-App Sidebar', exact: true }).click()
await page.getByRole('menuitem', { name: 'Default Browser', exact: true }).click()
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ linkOpening: 'new-tab' })
await page.keyboard.press('Escape')
const popupPromise = page.waitForEvent('popup')
@@ -471,8 +471,8 @@ describe('web e2e: clickable links gallery', () => {
await page.reload()
await openSettings(page, 'en')
await page.getByRole('button', { name: 'New browser tab', exact: true }).click()
await page.getByRole('menuitem', { name: 'Built-in browser', exact: true }).click()
await page.getByRole('button', { name: 'Default Browser', exact: true }).click()
await page.getByRole('menuitem', { name: 'In-App Sidebar', exact: true }).click()
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ linkOpening: 'sidebar' })
await page.keyboard.press('Escape')
await guideLink.click()
+160
View File
@@ -0,0 +1,160 @@
/** Browser geometry for read offsets and horizontally scrolled diff fills. */
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts'
import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
const LONG_LINE = 'source_columns_'.repeat(80)
const OFFSETS = [999, 99_999]
/** Seed durable tool results without invoking a model or allocating a large file. */
function layoutFixture(): string {
const calls: { name: string; args: Record<string, JsonValue>; result: string; meta: JsonValue }[] = OFFSETS.map((offset) => {
const path = `offset-${offset}.txt`
const lines = [{ number: offset, text: 'short' }, { number: offset + 1, text: LONG_LINE }]
return {
name: 'read', args: { file_path: path, offset },
result: `<path>${path}</path>\n<type>file</type>\n<content>\n${lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n</content>`,
meta: { path, offset, lines, totalLines: offset + 2 },
}
})
const oldText = `old_${LONG_LINE}\nshort old`
const newText = `new_${LONG_LINE}\nshort new`
calls.push({
name: 'edit', args: { file_path: 'long.txt', old_string: oldText, new_string: newText },
result: 'Edited long.txt', meta: { diffs: [{ path: 'long.txt', oldText, newText }] },
})
const session = Session.create(SessionId('code-card-layout-source'))
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Inspect the source windows and edit the long lines.' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
const namedCalls = calls.map((call, index) => ({ ...call, id: ToolCallId(`layout-${index}`), arguments: JSON.stringify(call.args) }))
session.append('assistant/message', {
stream: [], turn: 1, step: 1,
message: createAssistantMessage({
content: namedCalls.map(call => ({ type: 'tool-call', id: call.id, name: call.name, arguments: call.arguments })),
source: { provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
for (const call of namedCalls) {
const source = session.append('tool/call', { turn: 1, step: 1, callId: call.id, name: call.name, arguments: call.arguments })
session.append('tool/result', {
turn: 1, step: 1, meta: call.meta,
message: createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: call.result }], isError: false }),
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
}
session.append('step/end', { turn: 1, step: 1 })
session.append('step/start', { turn: 1, step: 2 })
session.append('assistant/message', {
stream: [], turn: 1, step: 2,
message: createAssistantMessage({
content: [{ type: 'text', text: 'CODE_LAYOUT_DONE' }], source: { provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 2 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0 }),
...session.snapshotEvents().map(event => JSON.stringify(event)), '',
].join('\n')
}
describe('web e2e: code-card layout', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, layoutFixture(), 'code-card-layout')
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.locator('[role="treeitem"]').first().click()
await page.locator('[role="treeitem"]').nth(1).click()
await page.getByText('CODE_LAYOUT_DONE', { exact: true }).waitFor()
await expandTurnProcesses(page)
for (const row of await page.locator('[data-variant="read"], [data-variant="edit"]').all()) {
await row.locator('[data-expandable]').click()
}
await page.locator('[data-read]').nth(1).waitFor()
await page.locator('[data-diff]').waitFor()
})
afterAll(async () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it.each(OFFSETS)('keeps offset %i line numbers clear of aligned source in both wrap modes', async (offset) => {
onTestFailed(() => saveFailureShot(page, `code-card-offset-${offset}`))
const card = page.locator('[data-read]').filter({ hasText: `offset-${offset}.txt` })
const wrap = card.getByRole('button', { name: 'Wrap lines', exact: true })
const heights: number[] = []
for (const wrapped of [false, true]) {
if (wrapped) await wrap.click()
expect(await wrap.getAttribute('aria-pressed')).toBe(String(wrapped))
const rows = await card.locator('[class*="gutter"]').evaluateAll(gutters => gutters.map((gutter) => {
const content = gutter.nextElementSibling!
const range = document.createRange()
range.selectNodeContents(gutter)
const glyphs = range.getBoundingClientRect()
const slot = gutter.getBoundingClientRect()
const source = content.getBoundingClientRect()
return {
fits: glyphs.left >= slot.left - 1 && glyphs.right <= slot.right + 1,
gap: source.left - glyphs.right, left: source.left, height: source.height,
}
}))
expect(rows).toHaveLength(2)
expect(rows.every(row => row.fits && row.gap >= 11)).toBe(true)
expect(rows[0]!.left).toBe(rows[1]!.left)
heights.push(rows[1]!.height)
}
expect(heights[1]!).toBeGreaterThan(heights[0]!)
await wrap.click()
})
it.each([false, true])('fills long diff rows through horizontal scrolling with dark=%s', async (dark) => {
onTestFailed(() => saveFailureShot(page, `code-card-diff-${dark}`))
await page.evaluate(value => document.body.toggleAttribute('data-ds-dark-theme', value), dark)
const card = page.locator('[data-diff]')
const body = card.locator(':scope > div').nth(1)
const wrap = card.getByRole('button', { name: 'Wrap lines', exact: true })
for (const wrapped of [false, true]) {
if (wrapped) await wrap.click()
const result = await body.evaluate((element) => {
element.scrollLeft = element.scrollWidth
const viewport = element.getBoundingClientRect()
const rows = [...element.children].filter(row => ['+ ', '- '].includes(getComputedStyle(row, '::before').content.replaceAll('"', '')))
return {
overflow: element.scrollWidth - element.clientWidth,
fills: rows.map(row => ({
width: row.getBoundingClientRect().width, right: row.getBoundingClientRect().right,
fill: getComputedStyle(row).backgroundColor, marker: getComputedStyle(row).boxShadow,
})),
scrollWidth: element.scrollWidth, right: viewport.right,
}
})
expect(result.fills).toHaveLength(4)
if (wrapped) expect(result.overflow).toBeLessThanOrEqual(1)
else expect(result.overflow).toBeGreaterThan(100)
for (const row of result.fills) {
expect(row.width).toBeGreaterThanOrEqual(result.scrollWidth - 1)
expect(row.right).toBeGreaterThanOrEqual(result.right - 1)
expect(row.fill).toMatch(dark ? /0\.12\)$/ : /0\.08\)$/)
expect(row.marker).toContain('inset')
}
}
await wrap.click()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
+8 -8
View File
@@ -73,7 +73,7 @@ describe('web e2e: Desktop update workspace chrome', () => {
const progress = presentDesktopUpdate({ phase: 'downloading', version, percent: 58 }, en)
await publish(progress)
const downloading = page.getByRole('button', { name: '58%…', exact: true })
const downloading = page.getByRole('button', { name: '58%', exact: true })
await downloading.waitFor()
expect(await downloading.getAttribute('aria-disabled')).toBe('true')
expect(await downloading.locator('svg').count()).toBe(1)
@@ -86,20 +86,20 @@ describe('web e2e: Desktop update workspace chrome', () => {
const expand = locale === 'zh-CN' ? '打开侧边栏' : 'Open sidebar'
await page.getByRole('button', { name: collapse, exact: true }).click()
const toggle = page.getByRole('button', { name: expand, exact: true })
const badge = toggle.getByRole('img', { name: '58%…', exact: true })
const badge = toggle.getByRole('img', { name: '58%', exact: true })
await badge.waitFor()
await expect.poll(() => downloading.count()).toBe(0)
expect(await page.getByRole('img', { name: '58%…', exact: true }).count()).toBe(1)
const blue = await badge.evaluate(element => getComputedStyle(element).backgroundColor)
expect(await page.getByRole('img', { name: '58%', exact: true }).count()).toBe(1)
const updateBackground = await badge.evaluate(element => getComputedStyle(element).backgroundColor)
await page.screenshot({ path: join(evidence, 'collapsed.png') })
const error = presentDesktopUpdate({ phase: 'error', version, failedOperation: 'download', message: 'HTTP 503' }, en)
await publish(error)
const errorBadge = toggle.getByRole('img', { name: retryLabel, exact: true })
await errorBadge.waitFor()
expect(await errorBadge.getAttribute('data-error')).toBe('true')
const red = await errorBadge.evaluate(element => getComputedStyle(element).backgroundColor)
expect(red).not.toBe(blue)
// A failure keeps the update palette; only the label distinguishes it.
const failedBackground = await errorBadge.evaluate(element => getComputedStyle(element).backgroundColor)
expect(failedBackground).toBe(updateBackground)
await errorBadge.hover()
const tooltip = page.getByRole('tooltip', { name: errorDetail, exact: true })
await expect.poll(() => tooltip.evaluateAll(elements => elements.map(element => getComputedStyle(element).opacity))).toEqual(['1'])
@@ -132,7 +132,7 @@ describe('web e2e: Desktop update workspace chrome', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
expect(await page.evaluate(() => (window as FixtureWindow).updateFixture.listeners.size)).toBe(1)
await writeFile(join(evidence, 'result.json'), JSON.stringify({ locale, geometry, blue, red,
await writeFile(join(evidence, 'result.json'), JSON.stringify({ locale, geometry, updateBackground, failedBackground,
passed: true, explicitActions: 3, carrier: 'substituted', host: 'real Web composition',
electron: false, installerExecuted: false }, null, 2) + '\n')
console.log(`Desktop workspace chrome evidence: ${evidence}`)
+2 -1
View File
@@ -55,7 +55,8 @@ describe.skipIf(MODE === 'record').each(CASES)('web e2e: $name', (scenario) => {
const card = edit.locator('[data-diff]')
await card.waitFor()
expect(await card.getByText(scenario.shared, { exact: true }).count()).toBe(1)
expect(await card.textContent()).toContain(`${scenario.totals} · 1 file`)
expect(await edit.getByText(scenario.totals, { exact: true }).count()).toBe(1)
expect(await card.textContent()).not.toContain(scenario.totals)
const snapshotDir = `${ROOT}/web/${scenario.name}`
await compareOrRefreshGolden(`${snapshotDir}/ui.expected.md`,
await captureStableAria(page, '[data-variant="edit"]', scaffold.workspaceCwd), MODE)
+58 -1
View File
@@ -104,6 +104,58 @@ async function hideDocumentZoom(page: Page, preview: Locator): Promise<void> {
await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull()
}
it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while retaining the selected cell', async () => {
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
let browser: Browser | undefined
try {
browser = await chromium.launch()
const page = await newEnglishPage(browser)
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${process.pid}`) })
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
await input.fill(PROMPT)
await input.press('Enter')
const sessionId = await settled
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
await writeFile(join(cwd, 'budget.xlsx'), await excelFixture())
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
const column = page.locator('[data-rightbar-col]')
await page.locator('[data-sidebar-right-expand]').click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'budget.xlsx', exact: true }).click()
const excel = column.locator('[data-excel-preview]')
await excel.locator('.luckysheet-sheets-item-name').getByText('公式与格式', { exact: true }).click()
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 140, y: 30 } })
const formula = excel.locator('.fortune-fx-input')
await expect.poll(() => formula.innerText()).toBe('46281')
const canvas = excel.locator('canvas').first()
const originalCanvas = await canvas.elementHandle()
const original = await canvas.boundingBox()
if (original === null || originalCanvas === null) throw new Error('spreadsheet canvas is unavailable')
const panel = page.locator('[data-sidebar-right-panel]')
const panelBounds = await panel.boundingBox()
if (panelBounds === null) throw new Error('sidebar panel is unavailable')
const viewport = page.viewportSize()
const layout = await page.addStyleTag({ content: `[data-sidebar-right-panel] { width: ${panelBounds.width + 160}px !important; }` })
await expect.poll(async () => Math.round((await canvas.boundingBox())!.width - original.width)).toBe(160)
await expect.poll(() => formula.innerText()).toBe('46281')
expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true)
expect(page.viewportSize()).toEqual(viewport)
await successShot(page, 'excel-resize-wide')
await layout.evaluate((node) => { node.textContent = '' })
await expect.poll(async () => Math.round((await canvas.boundingBox())!.width)).toBe(Math.round(original.width))
expect(await formula.innerText()).toBe('46281')
await successShot(page, 'excel-resize-restored')
await originalCanvas.dispose()
} finally {
try { await browser?.close() } finally { await scaffold.close() }
}
})
describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -799,7 +851,12 @@ else process.exit(1);
})).toEqual({ scrollTop, scrollportBelowBanner: true, firstLineAbove: true })
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
await page.evaluate(() => navigator.clipboard.writeText(''))
await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click()
const copyCode = codeBlock.getByRole('button', { name: 'Copy', exact: true })
expect(await codeBlock.getByRole('button').count()).toBe(1)
expect(await copyCode.textContent()).toBe('')
await copyCode.hover()
await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor()
await copyCode.click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(codeLines.join('\n'))
sections.push([
'## Code paging', '',
+92
View File
@@ -0,0 +1,92 @@
/** Independent-writer XLSX previews through a replayed Session and the shipped browser Worker. */
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { excelOpeningCases, excelOpeningFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-opening-fixture.ts'
import { excelOpcCases, excelOpcFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-opc-fixture.ts'
import { excelXmlCases, excelXmlFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-xml-fixture.ts'
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
const snapshotDirectory = fileURLToPath(new URL('../../../snapshots/web/excel-opc', import.meta.url))
const mode = webSnapshotMode()
describe.skipIf(mode === 'record')('web e2e: independent-writer Excel previews', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let consoleErrors: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({
developerTools: false,
replayFixture: fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)),
compareReplaySession: false,
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
consoleErrors = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
})
afterAll(async () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it('opens comments, Tables and equivalent OPC encodings without hiding cell data', async () => {
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
await input.fill('Reply with the single word LIGHTHOUSE and stop.')
await input.press('Enter')
const sessionId = await settled
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
if (cwd === undefined) throw new Error('Excel preview Session has no workspace')
for (const name of excelOpcCases) await writeFile(join(cwd, `${name}.xlsx`), await excelOpcFixture(name))
for (const name of excelOpeningCases) await writeFile(join(cwd, `${name}.xlsx`), await excelOpeningFixture(name))
for (const name of excelXmlCases) await writeFile(join(cwd, `${name}.xlsx`), await excelXmlFixture(name))
const column = page.locator('[data-rightbar-col]')
await page.locator('[data-sidebar-right-expand]').click()
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
await column.locator('[data-files-state="tree"]').waitFor()
await column.locator('[data-files-reload]').click()
const files = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
const preview = column.locator('[data-textpreview-url]')
const excel = preview.locator('[data-excel-preview]')
const sheets = excel.locator('.luckysheet-sheets-item-name')
const notice = excel.locator('[data-excel-unsupported-notice]')
const rows = ['# Independent-writer Excel previews', '']
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
for (const name of [...excelOpcCases, ...excelOpeningCases, ...excelXmlCases]) {
await files.click()
await column.locator('[data-files-entry="file"]').getByRole('button', { name: `${name}.xlsx`, exact: true }).click()
await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}.xlsx`)).toBe(true)
await sheets.getByText('数据', { exact: true }).waitFor()
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('Item')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toMatch(/^Item\r?\n$/)
const rich = excelXmlCases.some(candidate => candidate === name)
const opening = excelOpeningCases.some(candidate => candidate === name)
expect(await notice.count()).toBe(name === 'combined' || rich || opening ? 1 : 0)
if (name === 'combined' || rich || opening) {
const features = rich ? 'charts, images, shapes, conditional formatting'
: name === 'orphan-drawing' ? 'conditional formatting' : 'charts, conditional formatting'
expect(await notice.innerText()).toBe(`This preview does not support ${features} in this workbook. Open it in a system application for the full experience.`)
}
rows.push(`- ${name}.xlsx: 数据 opened; Item copied`)
}
expect(await sheets.getByText('隐藏页', { exact: true }).isVisible()).toBe(false)
const warning = await notice.innerText()
expect(warning).toBe('This preview does not support charts, images, shapes, conditional formatting in this workbook. Open it in a system application for the full experience.')
rows.push('- Relocated workbook: hidden sheet omitted', `- Relocated workbook notice: ${warning}`)
expect(consoleErrors.pageErrors).toEqual([])
expect(consoleErrors.warnings).toEqual([])
await compareOrRefreshGolden(join(snapshotDirectory, 'excel.expected.md'), rows.join('\n'), mode)
await assertFixtureInventory(snapshotDirectory, ['excel.expected.md'])
})
})
@@ -34,8 +34,10 @@
- text: Edit
- button "src/tokens.css"
- text: +1 -1
- text: css
- button "Wrap lines"
- button "Copy"
- text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5; └ +1 -1 · 1 file"
- text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5;"
- button "Inspect"
- button "Tool call str_replace_editor · create" [expanded]
- text: "IN { \"command\": \"create\", \"path\": \"docs/press.md\", \"file_text\": \"# Press kit\\n\" } OUT Created docs/press.md"
@@ -45,7 +47,8 @@
- button "Read docs/guide.md" [expanded]:
- text: Read
- button "docs/guide.md"
- text: docs/guide.md
- text: Code block docs/guide.md
- button "Wrap lines"
- button "Copy"
- text: "# Link style guide guide line 2 guide line 3 guide line 4"
- button "Expand 4 more lines": … 4 more lines
@@ -136,6 +139,7 @@
- paragraph:
- img "Token preview"
- text: css
- button "Wrap lines" [pressed]
- button "Copy"
- code: "--inline-code: #F5F5F5;"
- paragraph: LINK_GALLERY_DONE
@@ -23,6 +23,7 @@
- tab "Host" [selected]
- tabpanel "Host":
- text: javascript
- button "Wrap lines" [pressed]
- button "Copy"
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
- text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings
@@ -0,0 +1,4 @@
- text: Code block
- button "Wrap lines" [pressed]
- button "Copy"
- code: echo WATERFALL
@@ -1,5 +1,5 @@
- banner:
- navigation "Session hierarchy": /user-invoke-demo @"meeting notes.md" an
- navigation "Session hierarchy": /user-invoke-demo @"meeting notes-this-i
- text: Standard mode
- button "More actions"
- button "Open right sidebar"
@@ -7,7 +7,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- button "/user-invoke-demo"
- button "meeting notes.md"
- button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md"
- text: and confirm the fixture wiring {{clock}}
- button "Copy"
- status: Worked
@@ -1,5 +1,5 @@
- banner:
- navigation "Session hierarchy": /user-invoke-demo @"meeting notes.md" an
- navigation "Session hierarchy": /user-invoke-demo @"meeting notes-this-i
- text: Standard mode
- button "More actions"
- button "Open right sidebar"
@@ -7,7 +7,7 @@
- tab "Chat" [selected]
- tab "Trajectory"
- button "/user-invoke-demo"
- button "meeting notes.md"
- button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md"
- text: and confirm the fixture wiring {{clock}}
- button "Copy"
- status: Worked
@@ -214,6 +214,7 @@
- text: .
- math: a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + 1 1 + 1 x
- text: typescript
- button "Wrap lines" [pressed]
- button "Copy"
- code: const value = "reasoning code"
- paragraph: {{longToken}}
+25
View File
@@ -27,6 +27,8 @@ const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md')
const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url))
const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'navigation-panes-web-e2e'
const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl`
@@ -218,6 +220,28 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
}, 90_000)
it.skipIf(MODE === 'record')('renders recorded Markdown with icon tooltips and switchable wrapping', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-code-card'))
await ensureSeedOpen(page)
const selector = '.md-code-block:has-text("echo WATERFALL")'
const block = page.locator(selector)
await block.waitFor({ timeout: 15_000 })
await compareOrRefreshGolden(CODE_CARD_EXPECTED, await captureStableAria(page, selector, scaffold.workspaceCwd), MODE)
const code = block.locator('pre')
const source = await code.textContent()
const copy = block.getByRole('button', { name: 'Copy', exact: true })
await copy.hover()
await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor()
const unwrap = block.getByRole('button', { name: 'Wrap lines', exact: true })
await unwrap.hover()
await page.getByRole('tooltip', { name: 'Do not wrap lines', exact: true }).waitFor()
await unwrap.click()
await expect.poll(() => code.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre')
await block.getByRole('button', { name: 'Wrap lines', exact: true }).click()
await expect.poll(() => code.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap')
expect(await code.textContent()).toBe(source)
}, 60_000)
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
await ensureSeedOpen(page)
@@ -521,6 +545,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
}, 60_000)
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
'terminal-card.expected.md', 'timing.expected.md',
+6 -2
View File
@@ -34,6 +34,10 @@ const MODE = webSnapshotMode()
const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
const REMOVE = 'Queue item to remove'
const EDIT = 'Queue item to edit'
// The edited value is deliberately multi-line: the inline editor must keep the
// line breaks through the queue mutation and into the model-bound user message.
const EDITED_CONTENT = 'Edited\nqueue item'
// The dock preview flattens the queued text, so row locators match this form.
const EDITED = 'Edited queue item'
const TAIL = 'Queue item preserved after stop'
const WAKE = 'Wake the preserved queue'
@@ -187,7 +191,7 @@ describe('web e2e: queue row actions', () => {
const editRow = page.locator('[data-queue-dock] li', { hasText: EDIT })
await editRow.getByRole('button', { name: 'Edit queued message' }).click()
const editor = page.getByRole('textbox', { name: 'Edit queued message' })
await editor.fill(EDITED)
await editor.fill(EDITED_CONTENT)
const save = page.getByRole('button', { name: 'Save queued message' })
await save.hover()
const saveTooltip = page.getByRole('tooltip', { name: 'Save queued message', exact: true })
@@ -308,7 +312,7 @@ describe('web e2e: queue row actions', () => {
.toEqual(['aborted', 'completed', 'completed', 'completed'])
expect(sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])).toEqual([ACTIVE_PROMPT, EDITED, TAIL, WAKE])
: [])).toEqual([ACTIVE_PROMPT, EDITED_CONTENT, TAIL, WAKE])
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
+16 -2
View File
@@ -30,7 +30,8 @@ const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md')
const MODE = webSnapshotMode()
const SKILL_NAME = 'user-invoke-demo'
const ARGS_TEXT = '@"meeting notes.md" and confirm the fixture wiring'
const FILE_NAME = 'meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md'
const ARGS_TEXT = `@"${FILE_NAME}" and confirm the fixture wiring`
const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.'
async function seedUserOnlySkill(workspaceCwd: string): Promise<void> {
@@ -78,7 +79,7 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
paceMs: 10,
})
await seedUserOnlySkill(scaffold.workspaceCwd)
await writeFile(join(scaffold.workspaceCwd, 'workspace', 'meeting notes.md'), '# Meeting notes\n\nSent reference preview.\n')
await writeFile(join(scaffold.workspaceCwd, 'workspace', FILE_NAME), '# Meeting notes\n\nSent reference preview.\n')
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
@@ -162,6 +163,19 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
await skill.click()
await expect.poll(() => preview.textContent(), { timeout: 10_000 }).toContain('Reply with the fixture acknowledgement line.')
const file = page.locator('[data-chat-flow-kind="user"] [data-ref-chip="file"]').first()
const bounds = await file.evaluate((element) => {
const bubble = element.closest('[class*="bubble"]')
if (bubble === null) throw new Error('file reference has no user bubble')
const label = element.getBoundingClientRect()
const container = bubble.getBoundingClientRect()
return {
left: label.left - container.left, right: container.right - label.right,
height: label.height, lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight),
}
})
expect(bounds.left).toBeGreaterThanOrEqual(0)
expect(bounds.right).toBeGreaterThanOrEqual(0)
expect(bounds.height).toBeGreaterThan(bounds.lineHeight)
await file.hover()
expect(await file.evaluate(el => getComputedStyle(el).textDecorationStyle)).toBe('dotted')
await file.click()
@@ -11,6 +11,7 @@
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- text: ts
- button "Wrap lines" [pressed]
- button "Copy"
- code: "const first: number = 1 const second = \"two\" let tail"
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -85,7 +85,7 @@ async function fenceTree(block: ReturnType<Page['locator']>): Promise<FenceTree>
const pre = element.querySelector<HTMLPreElement>('pre.shiki')
if (pre === null) throw new Error('streaming fence did not render through the shiki arm')
return {
language: element.querySelector('[class*="infostring"]')?.textContent ?? '',
language: element.querySelector('[data-code-block-banner] [class*="language"]')?.textContent ?? '',
pre: {
className: pre.className,
style: pre.style.cssText,
+9 -3
View File
@@ -170,6 +170,8 @@ describe('web e2e: secondary Thinking Markdown', () => {
await page.evaluate(async () => { await document.fonts.ready })
const styles = await markdown.evaluate((root, secondary) => {
const elements = [...root.querySelectorAll<HTMLElement>('h1,h2,h3,h4,h5,h6,p,li,a,strong,em,pre,pre code,pre span,th,td,.katex')]
const prose = elements.filter(element => element.closest('pre') === null)
const code = elements.filter(element => element.closest('pre') !== null)
const scrollers = [...root.querySelectorAll<HTMLElement>('[class*="tableScroll"],.katex-display,p:has(.katex),ul:has(.katex)')]
const shortFormulas = [...root.querySelectorAll('p')].filter(element => element.textContent?.startsWith('Short '))
// Native KaTeX baselines vary with host fonts; compact adds one pixel for descender ink.
@@ -190,9 +192,13 @@ describe('web e2e: secondary Thinking Markdown', () => {
const banner = root.querySelector('[data-code-block-banner]')?.parentElement
const loose = [...root.querySelectorAll('li p')].find(element => element.textContent === 'Loose middle paragraph.')
return {
secondarySize: elements.every(element => getComputedStyle(element).fontSize === secondary.fontSize),
secondaryLine: elements.filter(element => !element.classList.contains('katex'))
secondarySize: prose.every(element => getComputedStyle(element).fontSize === secondary.fontSize),
secondaryLine: prose.filter(element => !element.classList.contains('katex'))
.every(element => getComputedStyle(element).lineHeight === secondary.lineHeight),
codeTypography: code.length > 0 && code.every((element) => {
const style = getComputedStyle(element)
return style.fontSize === '11px' && style.lineHeight === '19px'
}),
tertiaryColor: elements.every(element => getComputedStyle(element).color === secondary.color),
tertiaryMarkers: [...root.querySelectorAll('li')]
.every(element => getComputedStyle(element, '::marker').color === secondary.color),
@@ -216,7 +222,7 @@ describe('web e2e: secondary Thinking Markdown', () => {
}
}, summaryStyle)
expect(styles, `Thinking typography at ${String(width)}px`).toEqual({
secondarySize: true, secondaryLine: true, tertiaryColor: true, tertiaryMarkers: true,
secondarySize: true, secondaryLine: true, codeTypography: true, tertiaryColor: true, tertiaryMarkers: true,
contained: true, bannerStatic: true, looseSpacing: true,
wideContentBounded: true, displayMathScrolls: true,
shortMathNaturalHeight: true, shortMathNoScrollbar: true, longAtomScrolls: true, mathListMarkerSpace: true,
+2
View File
@@ -26,6 +26,7 @@
"tests/server-restart.e2e.ts",
"tests/default-product-isolation.e2e.ts",
"tests/diff-context.e2e.ts",
"tests/code-card-layout.e2e.ts",
"tests/scaffold.ts",
"tests/voice-input.e2e.ts",
"tests/voice-setup.e2e.ts",
@@ -53,6 +54,7 @@
"tests/details-session-lifecycle.e2e.ts",
"tests/sessionless-header.e2e.ts",
"tests/document-preview.e2e.ts",
"tests/excel-opc.e2e.ts",
"tests/office-fixture.ts",
"tests/sidebar-browser.e2e.ts",
"tests/plugin-config.e2e.ts",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 573d16f37b9ce3c123c55c366d8ee6424feb3d64
config-catalog.zh.md: 9d827a4c65ec198239d7d1ef29735747a39838b9
config-catalog.md: fc5a8e12cf94715934d484121dfbbccc7ffc4252
config-catalog.zh.md: 26c7807c997c41a208e047965eec59eeec925a9c
+12 -14
View File
@@ -679,7 +679,7 @@ Requires: `credentials` · `authorization`
export interface Config {
/** Platform origin serving auth-api and browser pages. */
platformOrigin?: string
/** Native desktop identity for Host API and embedded Platform requests; null omits the client platform header. */
/** Native desktop identity for Host API and embedded Platform requests; null identifies the client as web. */
desktopPlatform?: 'darwin' | 'win32' | null
/** Optional frontend deployment selector for embedded Usage and Top-up pages. */
embeddedPageDist?: string
@@ -2781,18 +2781,14 @@ Source: [`packages/spill/spill-local/src/index.ts:31`](../packages/spill/spill-l
Requires: `tools`
```ts config-catalog
/** Plugin config. */
/** Optional result-retention budget. */
export interface Config {
/**
* The model-facing context cap for a plain-text tool result, in UTF-8 bytes.
* Omitted disables the policy entirely (no-op). When set, a result larger than
* this is spilled and replaced with a preview derived from this same budget.
*/
maxInlineBytes?: number
/** Maximum estimated tokens in a retained result, including image descriptors and omission notices. Omitted disables retention. */
maxInlineTokens?: number
}
```
Source: [`packages/spill/spill-policy/src/index.ts:61`](../packages/spill/spill-policy/src/index.ts)
Source: [`packages/spill/spill-policy/src/index.ts:25`](../packages/spill/spill-policy/src/index.ts)
<a id="deepseek-aidsh-ssh"></a>
@@ -3409,9 +3405,11 @@ export interface Config {
completionDelivery?: CompletionDelivery
/**
* Turns one owner may have opened by completion wakes before the next
* notice degrades to injection, reset by any user-authored input (default 3).
* Bounds the self-exciting chain where a woken turn starts the job whose
* completion wakes it again.
* notice degrades to injection, reset by any user-authored input. Absent by
* default: every idle completion wakes its owner. Set it to bound the
* self-exciting chain where a woken turn starts the job whose completion
* wakes it again, at the cost of notices past the cap waiting silently for
* the next user input.
*/
maxConsecutiveWakes?: number
}
@@ -3511,7 +3509,7 @@ export interface Config {
}
```
Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts)
Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:455`](../packages/shell/tool-pwsh-persistent/src/index.ts)
<a id="deepseek-aidsh-tool-ralph"></a>
@@ -3808,7 +3806,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'ptc' | 'both'
```
Source: [`packages/core/tools/src/index.ts:663`](../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:673`](../packages/core/tools/src/index.ts)
<a id="deepseek-aidsh-typert-loader"></a>
+11 -13
View File
@@ -681,7 +681,7 @@ export interface Config {
export interface Config {
/** Platform origin serving auth-api and browser pages. */
platformOrigin?: string
/** Native desktop identity for Host API and embedded Platform requests; null omits the client platform header. */
/** Native desktop identity for Host API and embedded Platform requests; null identifies the client as web. */
desktopPlatform?: 'darwin' | 'win32' | null
/** Optional frontend deployment selector for embedded Usage and Top-up pages. */
embeddedPageDist?: string
@@ -2783,18 +2783,14 @@ export interface Config {
需要: `tools`
```ts config-catalog
/** Plugin config. */
/** Optional result-retention budget. */
export interface Config {
/**
* The model-facing context cap for a plain-text tool result, in UTF-8 bytes.
* Omitted disables the policy entirely (no-op). When set, a result larger than
* this is spilled and replaced with a preview derived from this same budget.
*/
maxInlineBytes?: number
/** Maximum estimated tokens in a retained result, including image descriptors and omission notices. Omitted disables retention. */
maxInlineTokens?: number
}
```
来源: [`packages/spill/spill-policy/src/index.ts:61`](../packages/spill/spill-policy/src/index.ts)
来源: [`packages/spill/spill-policy/src/index.ts:25`](../packages/spill/spill-policy/src/index.ts)
<a id="deepseek-aidsh-ssh"></a>
@@ -3411,9 +3407,11 @@ export interface Config {
completionDelivery?: CompletionDelivery
/**
* Turns one owner may have opened by completion wakes before the next
* notice degrades to injection, reset by any user-authored input (default 3).
* Bounds the self-exciting chain where a woken turn starts the job whose
* completion wakes it again.
* notice degrades to injection, reset by any user-authored input. Absent by
* default: every idle completion wakes its owner. Set it to bound the
* self-exciting chain where a woken turn starts the job whose completion
* wakes it again, at the cost of notices past the cap waiting silently for
* the next user input.
*/
maxConsecutiveWakes?: number
}
@@ -3513,7 +3511,7 @@ export interface Config {
}
```
来源: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts)
来源: [`packages/shell/tool-pwsh-persistent/src/index.ts:455`](../packages/shell/tool-pwsh-persistent/src/index.ts)
<a id="deepseek-aidsh-tool-ralph"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: fc5c06727e58705ac6d9b44470b1b37010dc3223
module-graph.zh.md: 6911d9820932569c7a666b47a8bd53696fdb6393
module-graph.md: bfe3e5faa7168b03848ac76d2dd2584d4aa31f5e
module-graph.zh.md: bfa0f83e14cbcf89599fa207ec919195128db49e
+9 -6
View File
@@ -806,11 +806,6 @@ flowchart TD
pkg_tool_web --> pkg_system_prompt
pkg_tool_web --> pkg_tools
pkg_tool_web --> pkg_web
pkg_spill_policy --> pkg_llm
pkg_spill_policy --> pkg_output_retention
pkg_spill_policy --> pkg_session
pkg_spill_policy --> pkg_spill
pkg_spill_policy --> pkg_tools
pkg_tool_todo --> pkg_agent
pkg_tool_todo --> pkg_invariants
pkg_tool_todo --> pkg_session
@@ -1101,6 +1096,14 @@ flowchart TD
pkg_subagent --> pkg_user_approval
pkg_subagent --> pkg_util_time
pkg_subagent --> pkg_workspace
pkg_spill_policy --> pkg_attachment
pkg_spill_policy --> pkg_fs
pkg_spill_policy --> pkg_llm
pkg_spill_policy --> pkg_output_retention
pkg_spill_policy --> pkg_session
pkg_spill_policy --> pkg_spill
pkg_spill_policy --> pkg_token_meter
pkg_spill_policy --> pkg_tools
pkg_session_query_sqlite --> pkg_session
pkg_session_query_sqlite --> pkg_session_persistence
pkg_session_query_sqlite --> pkg_session_query
@@ -1566,7 +1569,6 @@ flowchart TD
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
@@ -1619,6 +1621,7 @@ flowchart TD
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
+9 -6
View File
@@ -808,11 +808,6 @@ flowchart TD
pkg_tool_web --> pkg_system_prompt
pkg_tool_web --> pkg_tools
pkg_tool_web --> pkg_web
pkg_spill_policy --> pkg_llm
pkg_spill_policy --> pkg_output_retention
pkg_spill_policy --> pkg_session
pkg_spill_policy --> pkg_spill
pkg_spill_policy --> pkg_tools
pkg_tool_todo --> pkg_agent
pkg_tool_todo --> pkg_invariants
pkg_tool_todo --> pkg_session
@@ -1103,6 +1098,14 @@ flowchart TD
pkg_subagent --> pkg_user_approval
pkg_subagent --> pkg_util_time
pkg_subagent --> pkg_workspace
pkg_spill_policy --> pkg_attachment
pkg_spill_policy --> pkg_fs
pkg_spill_policy --> pkg_llm
pkg_spill_policy --> pkg_output_retention
pkg_spill_policy --> pkg_session
pkg_spill_policy --> pkg_spill
pkg_spill_policy --> pkg_token_meter
pkg_spill_policy --> pkg_tools
pkg_session_query_sqlite --> pkg_session
pkg_session_query_sqlite --> pkg_session_persistence
pkg_session_query_sqlite --> pkg_session_query
@@ -1568,7 +1571,6 @@ flowchart TD
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
@@ -1621,6 +1623,7 @@ flowchart TD
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
persistence-catalog.md: bb3ee2c998189533ed87e149422eae8123fb07b1
persistence-catalog.zh.md: 3dabca40d2975e090935d471cd187def826ea4df
persistence-catalog.md: 022944bea076f49e054e7194e6b475cf6dfca2bc
persistence-catalog.zh.md: 096bf746085d329427dfb5516db4c6387f0974fe
+1 -1
View File
@@ -4929,7 +4929,7 @@ Sources: [`packages/llm/llm/src/types.ts:127`](../packages/llm/llm/src/types.ts)
SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f`
Sources: [`packages/core/tools/src/index.ts:689`](../packages/core/tools/src/index.ts)
Sources: [`packages/core/tools/src/index.ts:699`](../packages/core/tools/src/index.ts)
| Property | Presence | Type |
|---|---|---|
+1 -1
View File
@@ -4931,7 +4931,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8`
SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f`
来源:[`packages/core/tools/src/index.ts:689`](../packages/core/tools/src/index.ts)
来源:[`packages/core/tools/src/index.ts:699`](../packages/core/tools/src/index.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+1 -1
View File
@@ -36275,7 +36275,7 @@
"packages/core/tools/src/index.ts#ToolRestriction"
],
"sources": [
"packages/core/tools/src/index.ts:689"
"packages/core/tools/src/index.ts:699"
]
},
{
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/spill.md
spill.md: 577f3ff14bade2b0c03a0aeaa975d12ae96803b8
spill.zh.md: 1bc238b8cdc0ef81e966bc5ec9c11b2dd31b24da
spill.md: 5a6eb91c8dc3932ab56bd7afc4a4cc60b509180d
spill.zh.md: 0eebf93d041a2689e17c70053d34b93b34b51c2f
+1 -1
View File
@@ -89,7 +89,7 @@ type SpillLocator = Branded<'SpillLocator'>
`SpillStore` (`ctx.spillStore`, defined in [`packages/spill/spill/src/index.ts`](../../packages/spill/spill/src/index.ts)) is a one-method abstract service: `saveText(input) → Promise<SpillRef>`. It persists the FULL `content` and REJECTS on a real storage failure (permissions, ENOSPC, backend unavailable). The seam owns storage only: no retention policy, no tool-result replacement, no retrieval/search API.
The local backend ([dsh-spill-local](../../packages/spill/spill-local)) writes under `<root>/session-<hash>/<random>-<safeName>` — a configured or lazily-created private (0700) root, a `sha256(sessionId)` session subdir, and an exclusive owner-only (`open(path, 'wx', 0o600)`) write so a planted symlink cannot redirect it. Its `locator` is the local path and its `retrievalHint` tells the model to use `read` or `grep` on that path. The policy consumer ([dsh-spill-policy](../../packages/spill/spill-policy)) replaces an over-`maxInlineBytes` plain-text final result with a retention-library head/tail preview plus the spill reference, best-effort: a save failure keeps the original inline result rather than turning a successful call into an `isError`.
The local backend ([dsh-spill-local](../../packages/spill/spill-local)) writes under `<root>/session-<hash>/<random>-<safeName>` — a configured or lazily-created private (0700) root, a `sha256(sessionId)` session subdir, and an exclusive owner-only (`open(path, 'wx', 0o600)`) write so a planted symlink cannot redirect it. Its `locator` is the local path and its `retrievalHint` tells the model to use `read` or `grep` on that path. The policy consumer ([dsh-spill-policy](../../packages/spill/spill-policy)) replaces an over-`maxInlineTokens` text/image result with ordered head/tail content and a spill address, best-effort: a save failure keeps the original inline result rather than turning a successful call into an `isError`.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+1 -1
View File
@@ -89,7 +89,7 @@ type SpillLocator = Branded<'SpillLocator'>
`SpillStore`(`ctx.spillStore`,定义于 [`packages/spill/spill/src/index.ts`](../../packages/spill/spill/src/index.ts))是只有一个方法的抽象服务:`saveText(input) → Promise<SpillRef>`。它持久保存完整的 `content`,并在实际存储失败(权限、ENOSPC、后端不可用)时拒绝。该 seam 只负责存储:不负责保留策略、工具结果替换或检索/搜索 API。
本地后端([dsh-spill-local](../../packages/spill/spill-local))写入 `<root>/session-<hash>/<random>-<safeName>`:根目录是已配置或延迟创建的私有(0700)目录,会话子目录采用 `sha256(sessionId)`,并通过排他且仅所有者可访问的写入(`open(path, 'wx', 0o600)`)防止预先植入的符号链接重定向写入。其 `locator` 是本地路径,`retrievalHint` 则告知模型在该路径上使用 `read` 或 `grep`。策略消费方([dsh-spill-policy](../../packages/spill/spill-policy))会把超过 `maxInlineBytes` 的纯文本最终结果替换为保留库生成的首尾预览和 spill 引用;该过程尽力而为:保存失败时保留原始内联结果,而不会把成功的调用变成 `isError`。
本地后端([dsh-spill-local](../../packages/spill/spill-local))写入 `<root>/session-<hash>/<random>-<safeName>`:根目录是已配置或延迟创建的私有(0700)目录,会话子目录采用 `sha256(sessionId)`,并通过排他且仅所有者可访问的写入(`open(path, 'wx', 0o600)`)防止预先植入的符号链接重定向写入。其 `locator` 是本地路径,`retrievalHint` 则告知模型在该路径上使用 `read` 或 `grep`。策略消费方([dsh-spill-policy](../../packages/spill/spill-policy))会把超过 `maxInlineTokens` 的图文结果替换为按原顺序保留的首尾内容和 spill 地址;该过程尽力而为:保存失败时保留原始内联结果,而不会把成功的调用变成 `isError`。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/tools.md
tools.md: 396deae037f9026c56102d080b61a3f3fbde7a05
tools.zh.md: ae39bade0f9bb7c4b0008a0f115d97ed2e033189
tools.md: 348cda65733b1bf60c5e6e44d14507b7c91ecbd6
tools.zh.md: ae928e573fa4d914713357ca2de94470fc8a2c7c
+12 -2
View File
@@ -8,7 +8,7 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index
## `ToolDefinition` — a registered tool
A `ToolSchema` (the model-facing fields) plus a mandatory canonical output declaration, the `execute` function, host-only scheduler metadata, an optional final-content callback, and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `output`/`execute`/`finalizeContent`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` must never leak into a model request.
A `ToolSchema` (the model-facing fields) plus a mandatory canonical output declaration, the `execute` function, host-only scheduler metadata, an optional final-content callback, and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `output`/`execute`/`projectContent`/`finalizeContent`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` must never leak into a model request.
```ts type-equiv
/** Tool-owned canonical output contract used after the body returns a JSON value. */
@@ -38,6 +38,16 @@ interface ToolDefinition extends ToolSchema {
* @returns the canonical value declared by `output.schema`.
*/
execute(args: unknown, exec: ToolRunContext): Promise<unknown>
/**
* Install execution-prepared content before `tools/post-execute` policies.
* The callback is captured when the call starts and runs once for a
* normalized outcome entering post-execute. Policy replacements remain
* authoritative; pipeline failures that bypass post-execute skip projection.
* @param exec - immutable execution identity and arguments.
* @param result - normalized result before post-execute policy.
* @returns replacement content, or undefined to preserve the renderer output.
*/
projectContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
/**
* Synchronous last-mile transform for model-facing content. The registry
* snapshots this callback when execution starts and invokes it exactly once
@@ -169,7 +179,7 @@ interface ToolRestriction {
## Execution: extensible waterfalls plus monotonic policy
`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput` with a required readonly `signal`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → optional definition-owned `finalizeContent` → `tools/result` (the immutable authoritative outcome). Only the `tools/execute` view may replace the required signal. The outcome is a `ToolExecutionResult`.
`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput` with a required readonly `signal`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `projectContent` → `tools/post-execute` (inspect/replace the result) → optional definition-owned `finalizeContent` → `tools/result` (the immutable authoritative outcome). Only the `tools/execute` view may replace the required signal. The outcome is a `ToolExecutionResult`.
```ts type-equiv
/** Opaque call identity that permits correlation without exposing mutable execution state. */
+12 -2
View File
@@ -8,7 +8,7 @@
## `ToolDefinition` — 一个已注册的工具
由一个 `ToolSchema`(面向模型的字段)、必需的规范输出声明、`execute` 函数、仅供宿主使用的调度器元数据、可选的最终内容回调和可选 UI 展示函数组成。注册表持有这些定义,循环通过它们分派调用。注册表的 `schemas()` 通过显式允许列表构建面向模型的 `ToolSchema[]`;`output`/`execute`/`finalizeContent`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` 绝不能泄漏到模型请求中。
由一个 `ToolSchema`(面向模型的字段)、必需的规范输出声明、`execute` 函数、仅供宿主使用的调度器元数据、可选的最终内容回调和可选 UI 展示函数组成。注册表持有这些定义,循环通过它们分派调用。注册表的 `schemas()` 通过显式允许列表构建面向模型的 `ToolSchema[]`;`output`/`execute`/`projectContent`/`finalizeContent`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` 绝不能泄漏到模型请求中。
```ts type-equiv
/** Tool-owned canonical output contract used after the body returns a JSON value. */
@@ -38,6 +38,16 @@ interface ToolDefinition extends ToolSchema {
* @returns the canonical value declared by `output.schema`.
*/
execute(args: unknown, exec: ToolRunContext): Promise<unknown>
/**
* Install execution-prepared content before `tools/post-execute` policies.
* The callback is captured when the call starts and runs once for a
* normalized outcome entering post-execute. Policy replacements remain
* authoritative; pipeline failures that bypass post-execute skip projection.
* @param exec - immutable execution identity and arguments.
* @param result - normalized result before post-execute policy.
* @returns replacement content, or undefined to preserve the renderer output.
*/
projectContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
/**
* Synchronous last-mile transform for model-facing content. The registry
* snapshots this callback when execution starts and invokes it exactly once
@@ -169,7 +179,7 @@ interface ToolRestriction {
## 执行:可扩展的 waterfall(瀑布式事件)加单调策略
`ctx.tools.execute()` 接受由调用方拥有且包含必需 readonly `signal` 的 `ToolExecutionInput`,将其解析后的 JSON 参数一次性物化为流水线拥有的 `ToolExecution`,然后让调用依次经过 `tools/pre-execute`(可重排的 allow/deny/ask waterfall)→ 已注册的单调 guard → `tools/execute`(环绕分派包装层)→ `tools/post-execute`(检查/替换结果)→ 可选且由定义拥有的 `finalizeContent` → `tools/result`(不可变的权威结果)。只有 `tools/execute` 视图可以替换必需的 signal。最终产出为 `ToolExecutionResult`。
`ctx.tools.execute()` 接受由调用方拥有且包含必需 readonly `signal` 的 `ToolExecutionInput`,将其解析后的 JSON 参数一次性物化为流水线拥有的 `ToolExecution`,然后让调用依次经过 `tools/pre-execute`(可重排的 allow/deny/ask waterfall)→ 已注册的单调 guard → `tools/execute`(环绕分派包装层)→ `projectContent` → `tools/post-execute`(检查/替换结果)→ 可选且由定义拥有的 `finalizeContent` → `tools/result`(不可变的权威结果)。只有 `tools/execute` 视图可以替换必需的 signal。最终产出为 `ToolExecutionResult`。
```ts type-equiv
/** Opaque call identity that permits correlation without exposing mutable execution state. */
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/tool-execution-pipeline.md
tool-execution-pipeline.md: f9d3d145bb3d7c271882942b4adc100f53a55290
tool-execution-pipeline.zh.md: 3bfa6f358ee8482feaa9d26541e495b86a02e515
tool-execution-pipeline.md: 53b7481183145289d2b2a2502ef71b5a1b7a3796
tool-execution-pipeline.zh.md: 89d2ba505e454c50a67095d10ef3014d26009e6c
+6 -3
View File
@@ -3,7 +3,7 @@
# Tool Execution Pipeline
This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `finalizeContent` and `tools/result` run afterward.
This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward.
```mermaid
flowchart TD
@@ -18,6 +18,7 @@ flowchart TD
toolBody["Registered tool execute() body"]
fsGate["<code>fs/write-intent</code> or <code>fs/edit-intent</code><br/>tool-fs mutations only"]
owned["Tool-owned session events<br/><code>todo/write</code>, <code>fs/observed</code>, <code>hook/invoked</code>, <code>hook/result</code>, <code>tool/ptc-dispatch</code>"]
project["ToolDefinition.projectContent<br/>execution-prepared text and images"]
post["<code>tools/post-execute</code> waterfall<br/>accept, block, replace, add context"]
normalized["Registry outer normalization<br/>pipeline/result snapshot throws become isError"]
finalize["ToolDefinition.finalizeContent<br/>last content-only invariant"]
@@ -39,13 +40,15 @@ flowchart TD
approval -->|allowed-once| guards
approval -->|rejected, cancelled, unavailable| denied
approval -.->|throw| normalized
denied --> post
denied --> project
pre -.->|throw| normalized
toolBody --> fsGate
fsGate --> toolBody
toolBody --> owned
toolBody --> around
around --> post
around --> project
project --> post
project -.->|throw| normalized
around -.->|wrapper throws| normalized
post -.->|throw| normalized
post --> finalize
+6 -3
View File
@@ -5,7 +5,7 @@
[English](tool-execution-pipeline.md) | 中文
此图展示策略、钩子、沙箱、文件系统守卫、结果重写、最终结果观察和 UI 渲染在不改变循环的情况下何时运行。`tools/pre-execute` waterfall(瀑布式事件)首先运行,随后是单调守卫,然后运行 `tools/execute` 和 `tools/post-execute` waterfall;这三个 waterfall 可以改写一次调用。由定义自身控制的 `finalizeContent` 和 `tools/result` 在此之后运行。
此图展示策略、钩子、沙箱、文件系统守卫、结果重写、最终结果观察和 UI 渲染在不改变循环的情况下何时运行。`tools/pre-execute` waterfall(瀑布式事件)首先运行,随后是单调守卫,然后运行 `tools/execute` 和 `tools/post-execute` waterfall;这三个 waterfall 可以改写一次调用。由定义自身控制的 `projectContent` 在执行后策略之前安装已准备内容;`finalizeContent` 和 `tools/result` 在此之后运行。
```mermaid
flowchart TD
@@ -20,6 +20,7 @@ flowchart TD
toolBody["Registered tool execute() body"]
fsGate["<code>fs/write-intent</code> or <code>fs/edit-intent</code><br/>tool-fs mutations only"]
owned["Tool-owned session events<br/><code>todo/write</code>, <code>fs/observed</code>, <code>hook/invoked</code>, <code>hook/result</code>, <code>tool/ptc-dispatch</code>"]
project["ToolDefinition.projectContent<br/>execution-prepared text and images"]
post["<code>tools/post-execute</code> waterfall<br/>accept, block, replace, add context"]
normalized["Registry outer normalization<br/>pipeline/result snapshot throws become isError"]
finalize["ToolDefinition.finalizeContent<br/>last content-only invariant"]
@@ -41,13 +42,15 @@ flowchart TD
approval -->|allowed-once| guards
approval -->|rejected, cancelled, unavailable| denied
approval -.->|throw| normalized
denied --> post
denied --> project
pre -.->|throw| normalized
toolBody --> fsGate
fsGate --> toolBody
toolBody --> owned
toolBody --> around
around --> post
around --> project
project --> post
project -.->|throw| normalized
around -.->|wrapper throws| normalized
post -.->|throw| normalized
post --> finalize
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/providers.md
providers.md: 81ccba8b0665fa1f7666b5724aab4a8b3d183bbf
providers.zh.md: 1afb7e7bf616206290e5cc4a90ef45b4250a9596
providers.md: 773ff6722a8558720a458f7574940972cf976958
providers.zh.md: 6d618ed1e9eec17e8c4a000d00cc6987d0a6af14
+16 -14
View File
@@ -20,7 +20,7 @@ Providers that sign in with OAuth, such as Codex, are not supported here yet.
## Add a custom model API
Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers three: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, stored in `settings.yaml` as `openai-completions`, `openai-responses`, and `anthropic-messages`. A provider speaks one protocol, so a gateway that serves two needs two providers.
Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers three: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, stored in the active profile's `cordis.patch.yml` as `openai-completions`, `openai-responses`, and `anthropic-messages`. A provider speaks one protocol, so a gateway that serves two needs two providers.
![The custom model API form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png)
@@ -44,6 +44,8 @@ The generated [plugin configuration catalog](../../config-catalog.md) lists ever
::: tip Additional settings
The Models page exposes the API key, display name, base URL, API protocol, and each model's id, display name, context window, max output tokens, and input types. Configure reasoning effort levels, request-compatibility switches, headers, timeouts, and retry policy in `$DSH_HOME/profiles/<profile>/cordis.patch.yml`, the same document the page writes. Edit it directly, or, when the browser runs on the same machine as the server, open it with **Open configuration file** in the Settings header; the adapters re-read it on the next request, so nothing needs a restart. The subsections below cover the fields most gateways need.
For the standard Web UI launch with `dsh web`, `<profile>` is `web`, so the path is `$DSH_HOME/profiles/web/cordis.patch.yml`. If you launch a custom profile, use the name selected at startup instead.
:::
### Image input
@@ -132,14 +134,14 @@ Each key is a level the menu offers, and its value is the spelling sent on the w
An `off` left empty sends nothing, which only stops a model that thinks on request; an `off` given a value sends that value as `reasoning_effort` instead. A model that thinks unless told not to — DeepSeek V4 behind an OpenAI-compatible gateway, for example — needs `compat.thinkingFormat: deepseek`, which makes `off` send `thinking: {type: disabled}` and every other level send `thinking: {type: enabled}` beside the effort:
```yaml
models:
- id: deepseek-v4-pro
compat:
thinkingFormat: deepseek
reasoningEfforts:
off:
high: high
max: max
models:
- id: deepseek-v4-pro
compat:
thinkingFormat: deepseek
reasoningEfforts:
off:
high: high
max: max
```
A built-in provider's model whose gateway does not reason loses its levels with `reasoningEfforts: false` under `modelOverrides`; selecting an effort for it is then refused as `UNSUPPORTED_REASONING_EFFORT`. DeepSeek's own route needs none of this: its models already offer `off`, `low`, `high`, and `max`, and `llm-deepseek.reasoningEffort` sets the default the picker starts from:
@@ -174,11 +176,11 @@ Two account for most of it. A model that declares reasoning has its system promp
A route's `compat` is the default for its models, and a model's own wins field by field, so one model can be corrected without restating the route:
```yaml
models:
- id: my-model
- id: my-reasoner
compat:
thinkingFormat: deepseek
models:
- id: my-model
- id: my-reasoner
compat:
thinkingFormat: deepseek
```
What neither sets keeps the installed catalog's value for that model, and what the catalog does not describe falls to pi-ai's detection. Give every switch you name a value: a key left empty (`supportsDeveloperRole:`) is refused rather than ignored, because an empty value would erase what the catalog knows while saying nothing in its place. A name no protocol accepts is refused too, and the message lists the ones that are available.
+16 -14
View File
@@ -20,7 +20,7 @@
## 添加自定义模型 API
对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供三种:OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages,在 `settings.yaml` 中分别存为 `openai-completions`、`openai-responses` 和 `anthropic-messages`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。
对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供三种:OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages,在当前 profile 的 `cordis.patch.yml` 中分别存为 `openai-completions`、`openai-responses` 和 `anthropic-messages`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。
![自定义模型 API 表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png)
@@ -44,6 +44,8 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和
::: tip 其他设置
模型页提供 API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口、最大输出 token 数和输入类型。推理等级、请求兼容性开关、请求头、超时和重试策略在 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。
按常规方式通过 `dsh web` 启动 Web UI 时,`<profile>` 就是 `web`,完整路径为 `$DSH_HOME/profiles/web/cordis.patch.yml`。如果使用自定义 profile,请替换为启动时指定的名称。
:::
### 图片输入
@@ -132,14 +134,14 @@ DeepSeek 将省略的 `inputModalities` 视为纯文本,并拒绝空列表。
留空的 `off` 什么都不发送,这只能让「按请求才思考」的模型停下来;给 `off` 一个值,则会把该值作为 `reasoning_effort` 发送。对于「不明确关闭就会思考」的模型——例如 OpenAI 兼容网关后面的 DeepSeek V4——需要 `compat.thinkingFormat: deepseek`:它让 `off` 发送 `thinking: {type: disabled}`,其他每个等级则在 effort 之外再发送 `thinking: {type: enabled}`:
```yaml
models:
- id: deepseek-v4-pro
compat:
thinkingFormat: deepseek
reasoningEfforts:
off:
high: high
max: max
models:
- id: deepseek-v4-pro
compat:
thinkingFormat: deepseek
reasoningEfforts:
off:
high: high
max: max
```
网关并不提供推理能力的内置提供商模型,可在 `modelOverrides` 下用 `reasoningEfforts: false` 去掉其等级;之后再为它选择等级会被拒绝并报 `UNSUPPORTED_REASONING_EFFORT`。DeepSeek 自身的路由不需要以上任何配置:其模型已经提供 `off`、`low`、`high` 和 `max`,`llm-deepseek.reasoningEffort` 设置选择器的起始默认值:
@@ -174,11 +176,11 @@ DeepSeek 将省略的 `inputModalities` 视为纯文本,并拒绝空列表。
路由的 `compat` 是其模型的默认值,模型自身的则逐字段胜出,因此更正某一个模型无需重述整条路由:
```yaml
models:
- id: my-model
- id: my-reasoner
compat:
thinkingFormat: deepseek
models:
- id: my-model
- id: my-reasoner
compat:
thinkingFormat: deepseek
```
两者都未设置的字段,沿用已安装 catalog 为该模型记录的值;catalog 也未描述的,落到 pi-ai 的检测。凡是写下的开关都要给值:冒号后留空的键(`supportsDeveloperRole:`)会被拒绝而不是被忽略,因为空值会抹掉 catalog 已知的信息,却又没有给出任何替代。任何协议都不接受的名字同样会被拒绝,报错会列出可用的那些。
+1
View File
@@ -214,6 +214,7 @@
"@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp": "workspace:^",
"@deepseek-ai/dsh-experimental-computer-use-cua-driver-native": "workspace:^",
"@deepseek-ai/dsh-llm-replay": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-tool-session-query": "workspace:^",
"@deepseek-ai/dsh-web-fetch-http": "workspace:^",
"@stylistic/eslint-plugin": "^5.10.0",
+1 -1
View File
@@ -406,7 +406,7 @@
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
maxInlineTokens: 12500
# Durability checkpoints before each model request and top-level dispatch.
- id: session-checkpoint-policy
+3
View File
@@ -7,6 +7,9 @@ export const en = {
'close': 'Close',
'copy': 'Copy',
'copied': 'Copied',
'codeBlock.title': 'Code block',
'codeBlock.wrap': 'Wrap lines',
'codeBlock.unwrap': 'Do not wrap lines',
'copy.failed': 'Copy failed',
'copy.value': 'Copy value',
'copy.json': 'Copy JSON',
+3
View File
@@ -5,6 +5,9 @@ export const zh = {
'close': '关闭',
'copy': '复制',
'copied': '复制成功',
'codeBlock.title': '代码块',
'codeBlock.wrap': '自动换行',
'codeBlock.unwrap': '取消自动换行',
'copy.failed': '复制失败',
'copy.value': '复制值',
'copy.json': '复制 JSON',
@@ -1,6 +1,7 @@
/** Read-only help stays local to Settings and never changes the selected preset. */
import { useId, useLayoutEffect, useRef, useState } from 'react'
import type { KeyboardEvent, ReactNode } from 'react'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { Button, IconCloseOutlineRegular, IconListPenOutlineRegular, MarkdownText, Modal, SegmentedTabs, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AgentPresetSettingsKey } from './locales.ts'
import css from './PresetGuideDialog.module.css'
@@ -35,10 +36,10 @@ export function presetGuide(id: string, trust: string): PresetGuide | undefined
/** Curated usage dictionaries contain only level-three example sections. */
function GuideUsage({ text, t }: {
text: string
t: (key: AgentPresetSettingsKey) => string
t: TranslateNS<'settings.agentPreset'>
}): ReactNode {
const labels = {
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') },
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('guideFootnotes'),
}
return text.split(/(?=^### )/m).map((section) => {
@@ -67,7 +68,7 @@ function GuideUsage({ text, t }: {
export function PresetGuideDialog({ guide, initialPage, t, onClose }: {
guide: PresetGuide
initialPage: PresetGuidePage
t: (key: AgentPresetSettingsKey) => string
t: TranslateNS<'settings.agentPreset'>
onClose: () => void
}): ReactNode {
const [page, setPage] = useState(initialPage)
@@ -144,7 +145,7 @@ export function PresetGuideDialog({ guide, initialPage, t, onClose }: {
<MarkdownText
text={t(guide.explanation)}
labels={{
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') },
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('guideFootnotes'),
}}
/>
+2 -2
View File
@@ -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-chat/README.md
README.md: 14a5260e23640d58c73779cff76bd27853bf4685
README.zh.md: 94e1442c5a1afbe3e57df8e2ac2e22181becf253
README.md: 285242c9dc9b8225711096aed03f183f5a8e36b7
README.zh.md: 8844c9c361f7404af4ea22c645f18e2c9c4e1799
+1 -1
View File
@@ -33,7 +33,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state.
Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: Built-in browser (default) opens a new right-Sidebar Browser tab, while New browser tab opens an external tab. The setting is shown only while the built-in browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: In-App Sidebar (default) opens a new right-Sidebar Browser tab, while Default Browser opens an external tab. The setting is shown only while the Sidebar Browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
<a id="system-prompt-row"></a>
## Hidden Chat rows
+1 -1
View File
@@ -33,7 +33,7 @@ kind: "package-reference"
Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。
设置 → 通用设置 → 聊天链接打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「内置浏览器」(默认)打开新的右侧 Sidebar Browser tab,「浏览器新标签页」打开外部标签页。该设置项仅在内置浏览器可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
设置 → 通用设置 → 网页链接默认打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「应用内侧边栏」(默认)打开新的右侧 Sidebar Browser tab,「默认浏览器」打开外部标签页。该设置项仅在 Sidebar Browser 可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
<a id="system-prompt-row"></a>
## Chat 隐藏的行
+7 -7
View File
@@ -74,10 +74,10 @@ export const zh = {
'settings.performance.description': '选择性能与用量信息展示的详细程度',
'settings.performance.compact': '简洁',
'settings.performance.detailed': '详细',
'settings.links.title': '聊天链接打开方式',
'settings.links.description': '选择聊天中 HTTP(S) 链接的打开位置',
'settings.links.sidebar': '内置浏览器',
'settings.links.newTab': '浏览器新标签页',
'settings.links.title': '网页链接默认打开方式',
'settings.links.description': '对话中网页链接的打开位置',
'settings.links.sidebar': '应用内侧边栏',
'settings.links.newTab': '默认浏览器',
'settings.transcript.title': '工作过程展示',
'settings.transcript.description': '控制轮次和步骤的默认展开方式',
'settings.transcript.compact': '简洁',
@@ -233,9 +233,9 @@ export const en = {
'settings.performance.compact': 'Compact',
'settings.performance.detailed': 'Detailed',
'settings.links.title': 'Open chat links in',
'settings.links.description': 'Choose where HTTP(S) links in chat open',
'settings.links.sidebar': 'Built-in browser',
'settings.links.newTab': 'New browser tab',
'settings.links.description': 'Choose where to open web links',
'settings.links.sidebar': 'In-App Sidebar',
'settings.links.newTab': 'Default Browser',
'settings.transcript.title': 'Work details',
'settings.transcript.description': 'Controls how turns and steps expand by default',
'settings.transcript.compact': 'Compact',
@@ -10,7 +10,7 @@ import type { ChatViewSlotProps } from './contract/slots.ts'
*/
export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels {
return {
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
code: { copyLabel: t('copy'), copiedLabel: t('copied'), toolbarLabels: { codeLabel: t('codeBlock.title'), wrapLabel: t('codeBlock.wrap'), unwrapLabel: t('codeBlock.unwrap') } },
footnotes: t('markdown.footnotes'),
}
}
@@ -127,16 +127,16 @@ describe('LinkOpeningRow', () => {
/>)
expect(screen.queryByText('Open chat links in')).toBeNull()
act(() => { browserAvailable.set(true) })
expect(screen.getByRole('button', { name: 'New browser tab' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Default Browser' })).toBeDefined()
act(() => { browserAvailable.set(false) })
expect(screen.queryByText('Open chat links in')).toBeNull()
expect(source.getSnapshot()).toBe('new-tab')
})
it.each([
[en, 'Open chat links in', 'Built-in browser', 'New browser tab'],
[zh, '聊天链接打开方式', '内置浏览器', '浏览器新标签页'],
] as const)('selects either destination using localized labels (%s)', (dictionary, title, sidebar, newTab) => {
[en, 'Open chat links in', 'Choose where to open web links', 'In-App Sidebar', 'Default Browser'],
[zh, '网页链接默认打开方式', '对话中网页链接的打开位置', '应用内侧边栏', '默认浏览器'],
] as const)('selects either destination using localized labels (%s)', (dictionary, title, description, sidebar, newTab) => {
const b = mount('compact', dictionary)
const source = createSnapshotStore<LinkOpening>('sidebar')
const setLinkOpening = vi.fn((destination: LinkOpening) => { source.set(destination) })
@@ -147,6 +147,7 @@ describe('LinkOpeningRow', () => {
setLinkOpening={setLinkOpening}
/>)
expect(screen.getByText(title)).toBeDefined()
expect(screen.getByText(description)).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: sidebar }))
fireEvent.click(screen.getByRole('menuitem', { name: newTab }))
expect(setLinkOpening).toHaveBeenLastCalledWith('new-tab')
@@ -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: a58ae14a450a18ae4bc89dc0dc5cc6fb664d48e0
README.zh.md: 20d6cab01e7b5fc4c9b075a993783c9df8121413
README.md: 48df40483632a7c63ec59d389f920d4dd663155b
README.zh.md: f5c34657264f97d8f0178859dcba5e3fc981f17d
+1 -1
View File
@@ -68,7 +68,7 @@ Active transcripts in the main occurrence expose content-width drag handles in t
The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged.
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
+1 -1
View File
@@ -68,7 +68,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
Loaded 100 of 322 files, more files were not shown because too many files have changed in this diff. Show more