Merge remote-tracking branch 'origin/master' into auto-review-per-call

This commit is contained in:
Chinesezjc committed 2026-09-11 14:27:30 +08:00
commit 358d0bc49c
44 files changed
+427 -153

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-05-prompt-variables-and-tool-guidance-ownership.md
2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 35bb7c6fabc85ae6f93bdbb67e13910eea627ca3
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 53d69cf45c02f6534334561b626d2c2ae6087c05
2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 8f5542cb09930e62fd1e26960166fbdd6d3f6745
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 348ae36cf6549d412adab1d3cdb5cc03f0badd01
@@ -26,7 +26,7 @@ The assembled system prompt had four defects, all of one family: facts the harne
### Prompt variables
Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique.
Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. Sections may set `interpolate: false` to preserve generated documents literally; `tools:sdk` does so because tool descriptions and schemas may document their own `{{…}}` syntax.
`dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own.
@@ -47,7 +47,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect
- **The loop composes an identity line itself** — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and outside the section pipeline it would be a second composition path. (The identity DOES ship as a code literal — but as an ordinary section registered by `dsh-system-prompt`, whose `system-prompt/assemble` waterfall remains the escape valve for a deployment that must drop it.)
- **Inject the model name via the `agent/request` waterfall** — prompt text would be composed in two places and the earlier rendered persona could disagree with the final routed header. The request plugin that owns late routing must also own any earlier prompt claim about that model.
- **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this decision cures.
- **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review.
- **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. Leaving only unknown names unchanged would still substitute registered names inside tool documentation.
- **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, reviving the hand-written-guidance-in-leaf-YAML drift. **Keying wording off the provider NAME** — `providerName` is itself config, so a renamed provider silently gets the wrong words.
- **Resolving the provider at `apply` time (a load-order requirement)** and **section-only subagent wording (lazily resolved at assemble)** — the alternatives to the provider-lifecycle events; both rejected in [the provider-lifecycle-events Agent Note](../../archived/architecture/2026-07-05-subagent-provider-lifecycle-events.md).
@@ -60,7 +60,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect
- The tui-agent prompt renders identity, persona with the interpolated model, then fs/shell/web guidance through one assembly path.
- Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes.
- Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw.
- In interpolated sections, unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw.
- Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request.
## Consequences
@@ -69,4 +69,4 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect
- `{{model}}` reflects `AgentOptions.model` at assembly time. A plugin that switches models in the `agent/request` waterfall makes the prompt's claim stale for that step, and one that SUPPLIES the model there (options.model unset — the loop's documented fallback) leaves the variable valueless at render, failing a `{{model}}` persona before the waterfall runs. Both have the same remedy, and it is the ownership rule itself: the plugin that owns the late-bound model fact states it early on the `system-prompt/assemble` waterfall (`assembly.variables['model'] = …`) — one owner, both statements; a loop test pins the supply path end-to-end. Accepted.
- While a bound provider is absent (not yet activated, unloaded, mid-HMR-reload), the subagent tool does not exist and a model request in that window simply lacks it. That is the honest state — the alternative was a registered tool whose description or execution could not be trusted.
- Strictness means a persona can fail a turn at render (e.g. `{{cwd}}` on a cwd-less session). The failure is contained — the turn ends `error`, the loop survives — and it is an authoring error we WANT loud.
- No escape syntax for a literal `{{name}}` in prompt prose yet; add one if a real prompt ever needs it.
- Inline escapes remain unsupported in interpolated text; literal sections need no escaping. PTC unit tests cover both modes and runtime languages, and the recorded `ptc-turn` scenario preserves tool-template examples in the model-visible prompt.
@@ -26,7 +26,7 @@ Status: implemented
### 提示词变量
插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用未知的自有属性、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。
插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用未知的自有属性、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。段可设置 `interpolate: false` 来原样保留生成的文档;`tools:sdk` 使用此设置,因为工具描述和 schema 可能会介绍自身的 `{{…}}` 语法。
`dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`(= `options.model`)和 `cwd`(= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd,而配置预创建的 stdio agent 没有 cwd(在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下面的 section):它们是本循环驱动的 agent 的运行时事实,替换循环自行提供自己的变量。
@@ -47,7 +47,7 @@ Status: implemented
- **循环自行组合一行 identity 文本**:在必须保持精简的那个包(「用插件,不改循环」)中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。)
- **通过 `agent/request` waterfall 注入模型名称**:提示词文本会在两处组合,更早渲染的 persona 也可能与最终已路由 header 不一致。拥有延迟路由的请求插件还必须拥有该模型在提示词中更早出现的声明。
- **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本决策要治愈的病症。
- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。
- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。仅保留未知名称仍会替换工具文档中的已注册名称。
- **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈在 leaf YAML 中手写指导的漂移。**根据提供方名称选择措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。
- **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](../../archived/architecture/2026-07-05-subagent-provider-lifecycle-events.md)中被否决。
@@ -60,7 +60,7 @@ Status: implemented
- tui-agent 的提示词通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/shell/web 指导。
- fork 和 fresh subagent 的描述反映提供方是否继承已完成的对话轮次;工具随提供方生命周期变化而出现、消失和重新措辞。
- 未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。
- 在启用插值的段中,未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。
- 快照回放与提示词无关:它按轮次和步骤索引已记录的分片流,不比较发出的请求。
## 后果
@@ -69,4 +69,4 @@ Status: implemented
- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,提示词对该步骤的声明就会过时;如果一个插件在那里提供模型(options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。
- 当一个已绑定的提供方不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。
- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们希望明确暴露的撰写错误。
- 目前没有在提示词行文中转义字面 `{{name}}` 的语法;如果真实提示词确实需要,再行添加。
- 插值文本仍不支持行内转义;字面文本段无需转义。PTC 单元测试覆盖两种模式和运行时语言,录制的 `ptc-turn` 场景在模型可见的提示词中保留工具模板示例。
@@ -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-10-connection-indicator-refinements.md
2026-09-10-connection-indicator-refinements.md: 3b6b4c7fd0962edbb87be17c2fa45eded46ee86b
2026-09-10-connection-indicator-refinements.zh.md: 0bd5dc3e0b5b69cf2d9491e2d3ce5711c96898f5
@@ -0,0 +1,29 @@
# Agent Note: Connection indicator state and interaction refinements
Status: implemented
English | [中文](2026-09-10-connection-indicator-refinements.zh.md)
## Problem
The sidebar connection pill hid its affordance behind a hover swap: outage and retry-attempt states replaced their label with **Reconnect now** on hover or focus, so every state had to reserve the widest supplied label to keep the control from resizing. A retry that resolved in under a second flickered the connecting pill in and out, and state changes and unmounts jumped with no transition.
## Decision
**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutline14`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha.
**The shell owns attempt pacing.** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) keeps the connecting pill visible for at least `CONNECTING_MIN_VISIBLE_MS` (800ms) so sub-second retries do not flicker; every attempt, manual or automatic, reads the one label `重新连接中` (`connection.connecting`). The two-second recovery confirmation (`RECOVERY_CONFIRMATION_MS`) starts when the recovered pill becomes visible, so a hold that delays its appearance never shortens the confirmation. Both timings are built-in presentation constants of their owners, not configuration.
## Alternatives considered
**Animating width changes.** A FLIP-style measured pixel transition (remember the old width, pin it, transition to the new measurement) needs a layout effect and imperative style writes; the fade-only change reads calm enough without them.
**Swapping to the retry glyph only on hover.** Showing the retry glyph permanently states the affordance without requiring any pointer interaction, matching the static label; a hover cross-fade adds interaction-dependent state and conveys nothing extra.
**Scaling on enter/exit.** A 0.98 scale beside the opacity fades reads as jitter at 12px text, so only opacity animates.
**Naming manual and automatic attempts differently.** `ConnectionController.emitState` deduplicates repeated `connecting` states across backoff attempts, so the shell cannot observe attempt boundaries: a shell-held manual-retry flag either flips the label mid-hold or sticks across later automatic attempts. Distinguishing the copy correctly requires the connection layer to expose the attempt origin, which this change does not need — both attempt kinds read the same label.
## Consequences
`ConnectionIndicator`'s `reconnectLabel` prop and its size-reservation spans are removed from the pre-stable API; the sole consumer (`ui-settings-general`) is updated in the same change. `settings-root.client.spec.tsx` pins the 800ms hold, the single attempt label held steady through the hold, and the visibility-based confirmation window; `atoms.client.spec.tsx` pins the exit-duration unmount; `lifecycle-chrome.e2e.ts` and its ARIA golden replay the recovery flow in a real browser. Both packages' READMEs restate the interaction.
@@ -0,0 +1,29 @@
# Agent Note: 连接指示器状态与交互细化
Status: implemented
[English](2026-09-10-connection-indicator-refinements.md) | 中文
## Problem
侧边栏连接药丸把操作提示藏在悬停切换里:断连与重试状态在悬停或聚焦时把文案替换为**立即重连**,因此每个状态都要为最宽的 label 预留空间以避免控件变形。一次不到一秒就恢复的重试会让连接中药丸闪现闪没,状态切换和消失也没有任何过渡、十分突兀。
## Decision
**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutline14`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。
**外壳拥有尝试节奏。** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) 让连接中药丸至少可见 `CONNECTING_MIN_VISIBLE_MS`(800ms),亚秒级重试不再闪动;无论手动还是自动,每次尝试都显示同一个文案`重新连接中`(`connection.connecting`)。2 秒恢复确认(`RECOVERY_CONFIRMATION_MS`)从恢复药丸实际可见时起算,驻留推迟其出现也不会缩短确认时长。两个时长是各自持有方的内置展示常量,不是配置。
## Alternatives considered
**给宽度变化加动画。** FLIP 式的像素测量过渡(记住旧宽度、钉住、过渡到新测量值)需要一个 layout effect 和命令式样式写入;纯淡入淡出已足够平静,无需这些。
**仅在悬停时切换为重试图形。** 常驻显示重试图形无需任何指针交互就说明了操作,与静态文案一致;悬停交叉渐变引入依赖交互的状态却不传达更多信息。
**进出场缩放。** 0.98 的缩放叠加在透明度淡入淡出上,在 12px 文字上读起来像抖动,因此只保留透明度。
**手动与自动尝试使用不同命名。** `ConnectionController.emitState` 会去重退避尝试之间重复的 `connecting` 状态,外壳观察不到尝试边界:外壳自持的手动重试标志要么在驻留中途翻转文案,要么在后续自动尝试中一直滞留。要正确区分文案需要连接层暴露尝试来源,而本次变更并不需要——两种尝试显示同一文案。
## Consequences
`ConnectionIndicator` 的 `reconnectLabel` prop 及其占位 span 从 pre-stable API 中移除;唯一消费者(`ui-settings-general`)在同一变更中更新。`settings-root.client.spec.tsx` 固定 800ms 驻留、驻留期间保持不变的单一尝试文案,以及按可见时刻起算的确认窗口;`atoms.client.spec.tsx` 固定退出时长后的卸载;`lifecycle-chrome.e2e.ts` 及其 ARIA golden 在真实浏览器中回放恢复流程。两个包的 README 重述了该交互。
+8 -15
View File
@@ -428,22 +428,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await recoveryPage.context().setOffline(false)
await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(true)
const connecting = recoveryPage.getByRole('button', {
name: 'Reconnecting automatically, reconnect now', exact: true,
name: 'Reconnecting, reconnect now', exact: true,
})
await connecting.waitFor({ timeout: 10_000 })
expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
const connectingGeometry = await connectionIndicatorGeometry(connecting)
expect(await connectionIndicatorTextAlignment(connecting)).toBe('left')
// Animated dots must remain hidden with their state label during hover.
await connecting.evaluate((element) => {
for (const animation of element.getAnimations({ subtree: true })) {
if (!(animation instanceof CSSAnimation)) continue
animation.pause()
animation.currentTime = 1_250
}
})
// Hover keeps the state label; the pill never swaps copy or resizes.
await connecting.hover()
expect(await connecting.innerText()).toBe('Reconnect now')
expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry)
await recoveryPage.mouse.move(0, 0)
@@ -461,7 +454,6 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const indicator = connecting
expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry)
expect(await connectionIndicatorTextAlignment(indicator)).toBe('left')
await indicator.hover()
const snapshot = await captureStableAria(recoveryPage, '[class*="footArea"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(CONNECTION_ERROR_EXPECTED, snapshot, MODE)
const style = await indicator.evaluate((element) => {
@@ -498,11 +490,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await connecting.waitFor()
await recoveryPage.clock.fastForward(500)
await expect.poll(() => sockets.length).toBe(11)
const idleBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
await indicator.hover()
expect(await indicator.innerText()).toBe('Reconnect now')
expect(await indicator.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
const hoverBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
expect(hoverBackground).toBe(idleBackground)
await recoveryPage.mouse.down()
await expect.poll(() => indicator.evaluate(element => getComputedStyle(element).backgroundColor))
.not.toBe(hoverBackground)
@@ -513,7 +503,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const recovered = recoveryPage.getByRole('status')
await recovered.waitFor({ timeout: 10_000 })
expect(await recovered.innerText()).toBe('Connected')
expect(await connectionIndicatorGeometry(recovered)).toEqual(connectingGeometry)
// The pill sizes to its current label; chrome height and icon box stay fixed.
const recoveredGeometry = await connectionIndicatorGeometry(recovered)
expect(recoveredGeometry.outer[3]).toBe(connectingGeometry.outer[3])
expect(recoveredGeometry.icon).toEqual(connectingGeometry.icon)
expect(await connectionIndicatorTextAlignment(recovered)).toBe('left')
await recoveryPage.clock.fastForward(2_000)
await recovered.waitFor({ state: 'detached', timeout: 5_000 })
+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: 0a113b27ec185e4e0a9d4edf6b8d8503d1630369
config-catalog.zh.md: 6ce5456a5198be262b57f67fc5f7c9878fc429c6
config-catalog.md: 97d6dbdf8710f4f978d8afaaf13c0c7cfd145a20
config-catalog.zh.md: b58121cb33979c69c2538cbbd6aa49347c98648a
+1 -1
View File
@@ -2612,7 +2612,7 @@ export interface Config {
}
```
Source: [`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts)
Source: [`packages/core/system-prompt/src/index.ts:246`](../packages/core/system-prompt/src/index.ts)
<a id="deepseek-aidsh-terminal-bash"></a>
+1 -1
View File
@@ -2614,7 +2614,7 @@ export interface Config {
}
```
来源:[`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts)
来源:[`packages/core/system-prompt/src/index.ts:246`](../packages/core/system-prompt/src/index.ts)
<a id="deepseek-aidsh-terminal-bash"></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/subsystems/system-prompt.md
system-prompt.md: 516e3347c06882bfd3ff42638d77a2acb3aca753
system-prompt.zh.md: 611ea0e9bdd81585efd7106012edd3d17e7bdd8e
system-prompt.md: 898520e8fc88a9fafd8429723fc48bad3b4ba210
system-prompt.zh.md: 82e557a309a48f546d7c7fa5a8c1097152b4d145
+3 -1
View File
@@ -56,9 +56,11 @@ interface PromptSection {
/**
* Static text or a provider evaluated at each assembly with that assembly's
* {@link AssembleContext}. The text may reference `{{variable}}`s — they are
* interpolated later, by {@link renderPrompt}.
* interpolated later, by {@link renderPrompt}, unless `interpolate` is false.
*/
readonly text: string | ((context: AssembleContext) => string)
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
readonly interpolate?: boolean
/**
* Treat this contribution as the complete system prompt. Assembly still
* runs the cooperative waterfall so tools, contexts, and variables can be
+3 -1
View File
@@ -56,9 +56,11 @@ interface PromptSection {
/**
* Static text or a provider evaluated at each assembly with that assembly's
* {@link AssembleContext}. The text may reference `{{variable}}`s — they are
* interpolated later, by {@link renderPrompt}.
* interpolated later, by {@link renderPrompt}, unless `interpolate` is false.
*/
readonly text: string | ((context: AssembleContext) => string)
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
readonly interpolate?: boolean
/**
* Treat this contribution as the complete system prompt. Assembly still
* runs the cooperative waterfall so tools, contexts, and variables can be
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 1d3e6c6d0774ed78bb0a1952ba57e5f607f24b47
README.zh.md: e7f0419939076b1276586cfba28129b7df1ba355
README.md: d22fa2eed35457c0599fd41473102b1aebe4bd2e
README.zh.md: b53cf76dc8b8a253da83d1e94b6f9f1b5abf19cc
+1 -3
View File
@@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel
### Controls and icons
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `FileTypeIcon` renders the traditional 28px Excel, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Office mappings include XLSM/Numbers as Excel, KEY as slides, and RTF/ODT/Pages as documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIcon` remains the smaller leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `FileTypeIcon` renders the traditional 28px Excel, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Office mappings include XLSM/Numbers as Excel, KEY as slides, and RTF/ODT/Pages as documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIcon` remains the smaller leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, a connecting spinner beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
### Rendering agent output
@@ -98,8 +98,6 @@ The package enforces one separation: presentational React atoms with zero Cordis
| [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | Read and diff cards |
| [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | Search and web-retrieval cards |
| [`src/icons/`](src/icons/) | `ic_ds_*` glyph components and brand marks |
| [`src/code-file-icon-artwork.ts`](src/code-file-icon-artwork.ts) | Embedded inner SVG markup for the 48 detailed code-file categories |
| [`src/code-file-icon-artwork.manifest.json`](src/code-file-icon-artwork.manifest.json) | Design-export digests, included categories, and intentionally excluded artwork |
| [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | Floating-panel and overlay geometry hooks |
### Streaming markdown
+1 -3
View File
@@ -67,7 +67,7 @@ kind: "package-library"
### 控件与图标
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`FileTypeIcon` 渲染传统的 28px Excel、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。办公文件映射包含 XLSM/Numbers 的表格图标、KEY 的幻灯片图标,以及 RTF/ODT/Pages 的文档图标。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIcon` 仍是可点击产物链接较小的前置分类图形——地球、文件夹、代码、图片、文档或纸张——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`FileTypeIcon` 渲染传统的 28px Excel、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。办公文件映射包含 XLSM/Numbers 的表格图标、KEY 的幻灯片图标,以及 RTF/ODT/Pages 的文档图标。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIcon` 仍是可点击产物链接较小的前置分类图形——地球、文件夹、代码、图片、文档或纸张——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、spinner 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
### 渲染 agent 输出
@@ -98,8 +98,6 @@ kind: "package-library"
| [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | 读取与差异卡片 |
| [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | 搜索与网页检索卡片 |
| [`src/icons/`](src/icons/) | `ic_ds_*` 字形组件与品牌标记 |
| [`src/code-file-icon-artwork.ts`](src/code-file-icon-artwork.ts) | 48 个细分代码文件类别的内嵌内层 SVG markup |
| [`src/code-file-icon-artwork.manifest.json`](src/code-file-icon-artwork.manifest.json) | 设计导出摘要、已纳入类别与有意排除的图稿 |
| [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | 浮动面板与浮层几何钩子 |
### 流式 Markdown
@@ -4,25 +4,49 @@
grid-template-columns: 14px max-content;
align-items: center;
column-gap: 4px;
height: 32px;
padding: 0 10px;
height: 28px;
padding: 0 8px;
box-sizing: border-box;
border: none;
border-radius: 8px;
border: 1px solid transparent;
border-radius: 13px;
font-family: inherit;
font-size: 12px;
font-weight: 500;
line-height: 18px;
white-space: nowrap;
transition: background-color 160ms ease-out, color 160ms ease-out;
transition:
background-color 160ms ease-out,
color 160ms ease-out,
border-color 160ms ease-out,
opacity 150ms ease-out;
animation: indicator-enter 150ms ease-out;
}
.leaving {
opacity: 0;
}
@keyframes indicator-enter {
from {
opacity: 0;
}
}
.warning {
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-label);
border-color: color-mix(in srgb, var(--dsw-alias-state-warn-label) 20%, transparent);
cursor: pointer;
}
.warning:hover {
background: color-mix(
in srgb,
var(--dsw-alias-state-warn-tertiary),
var(--dsw-alias-state-warn-primary) 6%
);
}
.warning:active {
background: color-mix(
in srgb,
@@ -39,6 +63,7 @@
.success {
background: var(--dsw-alias-state-success-tertiary);
color: var(--dsw-alias-state-success-primary);
border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent);
}
.icon {
@@ -49,35 +74,20 @@
}
.label {
display: grid;
text-align: left;
}
.stateLabel,
.hoverLabel,
.sizeLabel {
grid-area: 1 / 1;
.spinner {
animation: spinner-rotate 0.9s linear infinite;
}
.sizeLabel {
visibility: hidden;
}
.warning:is(:hover, :focus-visible) .stateLabel {
visibility: hidden;
}
.hoverLabel {
visibility: hidden;
}
.warning:is(:hover, :focus-visible) .hoverLabel {
visibility: visible;
@keyframes spinner-rotate {
to { transform: rotate(360deg); }
}
.dots {
display: inline-block;
width: 1.5em;
width: 1em;
text-align: left;
}
@@ -100,8 +110,14 @@
}
@media (prefers-reduced-motion: reduce) {
.indicator,
.secondDot,
.thirdDot {
.thirdDot,
.spinner {
animation: none;
}
.indicator {
transition: none;
}
}
@@ -1,4 +1,5 @@
import { IconCheckOutline16, IconWarningOutline16 } from './icons/index.tsx'
import { useEffect, useState } from 'react'
import { IconCheckOutline16, IconLoadingOutline16, IconRefreshOutline14 } from './icons/index.tsx'
import css from './ConnectionIndicator.module.css'
/** Visual state rendered by {@link ConnectionIndicator}. */
@@ -7,11 +8,16 @@ export type ConnectionIndicatorState =
| 'connecting'
| 'recovered'
/** Exit-transition length; keep equal to the `.leaving` transition duration in the stylesheet. */
const EXIT_MS = 150
/**
* Render an inline connection-recovery control.
* Render an inline connection-recovery control. The outage and retry-attempt
* states are one button whose static label already names the retry action;
* clicking it requests an immediate reconnect. The indicator animates in on
* appearance and fades out for {@link EXIT_MS} before unmounting.
* @param props.state - visible outage, retry-attempt, or recovered state.
* @param props.disconnectedLabel - localized outage text.
* @param props.reconnectLabel - localized action text shown on hover or focus.
* @param props.disconnectedLabel - localized outage text naming the retry action.
* @param props.connectingLabel - localized retry text followed by the attempt dots.
* @param props.recoveredLabel - localized recovery confirmation.
* @param props.reconnectActionLabel - accessible label for the outage action.
@@ -22,7 +28,6 @@ export type ConnectionIndicatorState =
export function ConnectionIndicator({
state,
disconnectedLabel,
reconnectLabel,
connectingLabel,
recoveredLabel,
reconnectActionLabel,
@@ -31,63 +36,66 @@ export function ConnectionIndicator({
}: {
state: ConnectionIndicatorState | undefined
disconnectedLabel: string
reconnectLabel: string
connectingLabel: string
recoveredLabel: string
reconnectActionLabel: string
restartActionLabel: string
onReconnect: () => void
}) {
if (state === undefined) return null
const sizeLabels = (
<>
<span className={css.sizeLabel} aria-hidden="true">{disconnectedLabel}</span>
<span className={css.sizeLabel} aria-hidden="true">{reconnectLabel}</span>
<span className={css.sizeLabel} aria-hidden="true">
{connectingLabel}<span className={css.dots}>...</span>
</span>
<span className={css.sizeLabel} aria-hidden="true">{recoveredLabel}</span>
</>
)
if (state === 'recovered') {
const [rendered, setRendered] = useState(state)
const leaving = state === undefined && rendered !== undefined
useEffect(() => {
if (state !== undefined) {
setRendered(state)
return
}
if (rendered === undefined) return
const timeout = window.setTimeout(() => { setRendered(undefined) }, EXIT_MS)
return () => { window.clearTimeout(timeout) }
}, [state, rendered])
if (rendered === undefined) return null
const leavingClass = leaving ? ` ${css.leaving}` : ''
if (rendered === 'recovered') {
return (
<div className={`${css.indicator} ${css.success}`} role="status" aria-label={recoveredLabel}>
<div
className={`${css.indicator} ${css.success}${leavingClass}`}
role="status"
aria-label={recoveredLabel}
>
<span className={css.icon} aria-hidden="true"><IconCheckOutline16 size={14} /></span>
<span className={css.label}>
{sizeLabels}
<span className={css.stateLabel}>{recoveredLabel}</span>
</span>
<span className={css.label}>{recoveredLabel}</span>
</div>
)
}
const connecting = state === 'connecting'
const connecting = rendered === 'connecting'
return (
<button
type="button"
className={`${css.indicator} ${css.warning}`}
data-phase={state}
className={`${css.indicator} ${css.warning}${leavingClass}`}
data-phase={rendered}
aria-label={connecting ? restartActionLabel : reconnectActionLabel}
onClick={onReconnect}
>
<span className={css.icon} aria-hidden="true"><IconWarningOutline16 size={14} /></span>
<span className={css.icon} aria-hidden="true">
{connecting
? <IconLoadingOutline16 size={14} className={css.spinner} />
: <IconRefreshOutline14 size={14} />}
</span>
<span className={css.label}>
{sizeLabels}
<span className={css.stateLabel}>
{connecting
? (
<>
{connectingLabel}
<span className={css.dots} aria-hidden="true">
<span>.</span>
<span className={css.secondDot}>.</span>
<span className={css.thirdDot}>.</span>
</span>
</>
)
: disconnectedLabel}
</span>
<span className={css.hoverLabel}>{reconnectLabel}</span>
{connecting
? (
<>
{connectingLabel}
<span className={css.dots} aria-hidden="true">
<span>.</span>
<span className={css.secondDot}>.</span>
<span className={css.thirdDot}>.</span>
</span>
</>
)
: disconnectedLabel}
</span>
</button>
)
@@ -462,8 +462,7 @@ describe('ConnectionIndicator', () => {
it('renders outage, attempt progress, and recovered states without a native tooltip', () => {
const reconnect = vi.fn()
const labels = {
disconnectedLabel: 'Disconnected',
reconnectLabel: 'Reconnect',
disconnectedLabel: 'Disconnected, retry',
connectingLabel: 'Connecting',
recoveredLabel: 'Connected',
reconnectActionLabel: 'Disconnected, reconnect now',
@@ -476,8 +475,7 @@ describe('ConnectionIndicator', () => {
expect(container.firstChild).toBeNull()
rerender(<ConnectionIndicator state="disconnected" {...labels} />)
const indicator = screen.getByRole('button', { name: 'Disconnected, reconnect now' })
expect(indicator.textContent).toContain('Disconnected')
expect(indicator.textContent).toContain('Reconnect')
expect(indicator.textContent).toContain('Disconnected, retry')
expect(indicator.hasAttribute('title')).toBe(false)
expect(indicator.querySelector('svg')).toBeTruthy()
fireEvent.click(indicator)
@@ -491,4 +489,27 @@ describe('ConnectionIndicator', () => {
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
})
it('fades out for the exit duration before unmounting', () => {
vi.useFakeTimers()
try {
const labels = {
disconnectedLabel: 'Disconnected, retry',
connectingLabel: 'Connecting',
recoveredLabel: 'Connected',
reconnectActionLabel: 'Disconnected, reconnect now',
restartActionLabel: 'Connecting, restart now',
onReconnect: vi.fn(),
}
const { container, rerender } = render(
<ConnectionIndicator state="disconnected" {...labels} />,
)
rerender(<ConnectionIndicator state={undefined} {...labels} />)
expect(screen.getByRole('button', { name: 'Disconnected, reconnect now' })).toBeTruthy()
act(() => { vi.advanceTimersByTime(150) })
expect(container.firstChild).toBeNull()
} finally {
vi.useRealTimers()
}
})
})
@@ -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-settings-general/README.md
README.md: 845d9c48dce2264d478f0ac854ef85a480828a14
README.zh.md: 814082f94156e44b45e015e67acb7a281ba2e7e9
README.md: af68c42ad111f74e68037436e18a5be57195a64b
README.zh.md: f929df66a13cde7beb63721922f2a3f4aa90ba43
@@ -25,7 +25,7 @@ Use this package to give the dsh web client a Settings panel, connection-recover
<a id="use-this-package"></a>
## Use this package
Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension. Automatic recovery shows **Reconnecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, left-aligned text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time.
Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension; its permanent retry glyph marks the retry action, which the Chinese outage copy also names (连接异常,刷新重试). Every recovery attempt shows a spinner beside **Reconnecting** with one to three dots advancing every 500ms, and an attempt stays visible for at least 800ms so brief retries do not flicker. Selecting either yellow state starts an immediate retry; press feedback stays within the warning palette. Recovery changes the region to pale-green **Connected** for two seconds from the moment the green pill becomes visible. The pill fades in on appearance, fades out over 150ms on removal, and sizes to its current label. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time.
### The General section
@@ -55,7 +55,7 @@ The navigation is a projection of the `settings.section` ledger; nav labels may
### Connection recovery
The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the two-second recovered-state timer.
The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the 800ms minimum-visible hold for the connecting state and the two-second recovered-state timer, which starts when the recovered pill becomes visible after the hold.
### Document availability
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;自动恢复期间显示**自动重连中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。所有可见状态的文字都左对齐,且图标、文字起点、高度和宽度保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。
用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;其常驻重试图形与中文文案「连接异常,刷新重试」都指明重试动作。每次恢复尝试都显示 spinner 加**重新连接中**,其后一至三个点每 500ms 前进一次,且每次尝试至少可见 800ms,短暂重试不会闪动。选中任一黄色状态都会立即发起重试;按压反馈留在黄色色阶内。恢复后该区域变为浅绿色的**连接成功**,从绿色药丸可见起驻留 2 秒再消失。药丸出现时淡入、移除时以 150ms 淡出,宽度随当前文案自适应。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。
### 「通用」分区
@@ -55,7 +55,7 @@ kind: "package-reference"
### 连接恢复
外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;2 秒恢复状态计时器归外壳所有。
外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;连接中状态的 800ms 最短可见驻留与 2 秒恢复确认计时器归外壳所有;恢复计时从驻留结束、恢复药丸实际可见时开始。
### 文档可用性
@@ -23,6 +23,9 @@ import css from './SettingsRoot.module.css'
const RECOVERY_CONFIRMATION_MS = 2_000
/** Minimum visible time for the connecting pill; shorter attempts read as flicker. */
const CONNECTING_MIN_VISIBLE_MS = 800
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
function navIcon(id: string) {
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
@@ -113,6 +116,8 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
const [showRecovery, setShowRecovery] = useState(false)
const [holdConnecting, setHoldConnecting] = useState(false)
const connectingShownAt = useRef<number | undefined>(undefined)
const triggerButton = useRef<HTMLButtonElement | null>(null)
const wasOpen = useRef(open)
const close = useCallback(() => {
@@ -157,8 +162,32 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
}
if (previous !== 'disconnected' && previous !== 'connecting') return
setShowRecovery(true)
}, [connectionState])
// The confirmation window starts when the recovered pill becomes visible,
// which the connecting minimum-visible hold can delay past the transition.
useLayoutEffect(() => {
if (!showRecovery || holdConnecting) return
const timeout = window.setTimeout(() => { setShowRecovery(false) }, RECOVERY_CONFIRMATION_MS)
return () => { window.clearTimeout(timeout) }
}, [showRecovery, holdConnecting])
useLayoutEffect(() => {
if (connectionState === 'connecting') {
connectingShownAt.current = Date.now()
return
}
const shownAt = connectingShownAt.current
if (shownAt === undefined) return
connectingShownAt.current = undefined
const remaining = CONNECTING_MIN_VISIBLE_MS - (Date.now() - shownAt)
if (remaining <= 0) return
setHoldConnecting(true)
const timeout = window.setTimeout(() => { setHoldConnecting(false) }, remaining)
return () => {
window.clearTimeout(timeout)
setHoldConnecting(false)
}
}, [connectionState])
const completeOnboardingStep = useCallback((id: string) => {
@@ -169,10 +198,10 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
}, [])
let connectionIndicator: ConnectionIndicatorState | undefined
if (connectionState === 'disconnected') {
connectionIndicator = 'disconnected'
} else if (connectionState === 'connecting') {
if (connectionState === 'connecting' || holdConnecting) {
connectionIndicator = 'connecting'
} else if (connectionState === 'disconnected') {
connectionIndicator = 'disconnected'
} else if (showRecovery) {
connectionIndicator = 'recovered'
}
@@ -194,7 +223,6 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
<ConnectionIndicator
state={wide ? connectionIndicator : undefined}
disconnectedLabel={t('connection.error')}
reconnectLabel={t('connection.retry')}
connectingLabel={t('connection.connecting')}
recoveredLabel={t('connection.connected')}
reconnectActionLabel={t('connection.reconnect')}
@@ -8,12 +8,11 @@ export const zh = {
'openDocument': '打开配置文件',
'openDocument.error': '无法打开配置文件',
'general.nav': '通用设置',
'connection.error': '连接异常',
'connection.retry': '立即重连',
'connection.connecting': '自动重连中',
'connection.error': '连接异常,刷新重试',
'connection.connecting': '重新连接中',
'connection.connected': '连接成功',
'connection.reconnect': '连接异常,点击立即重连',
'connection.restart': '连接中断,正在自动重试,点击立即重连',
'connection.restart': '连接中断,正在重试,点击立即重连',
} satisfies Record<string, string>
/** The settings namespace key union. */
@@ -28,9 +27,8 @@ export const en = {
'openDocument.error': 'Could not open configuration file',
'general.nav': 'General',
'connection.error': 'Disconnected',
'connection.retry': 'Reconnect now',
'connection.connecting': 'Reconnecting',
'connection.connected': 'Connected',
'connection.reconnect': 'Disconnected, reconnect now',
'connection.restart': 'Reconnecting automatically, reconnect now',
'connection.restart': 'Reconnecting, reconnect now',
} satisfies Record<SettingsKey, string>
@@ -118,8 +118,8 @@ describe('ui-settings-general apply', () => {
settings.mutate.mockResolvedValueOnce(ok(english))
const t = c.ctx.locale.bind(NS)
expect(t('title')).toBe('设置')
expect(t('connection.error')).toBe('连接异常')
expect(t('connection.connecting')).toBe('自动重连中')
expect(t('connection.error')).toBe('连接异常,刷新重试')
expect(t('connection.connecting')).toBe('重新连接中')
expect(t('connection.connected')).toBe('连接成功')
c.ctx.locale.setLocale('en')
expect(t('close')).toBe('Close')
@@ -162,14 +162,57 @@ describe('SettingsRoot trigger', () => {
expect(mounted.reconnect).toHaveBeenCalledOnce()
mounted.setConnectionState('connecting')
expect(screen.getByRole('button', { name: 'Reconnecting automatically, reconnect now' }).textContent)
expect(screen.getByRole('button', { name: 'Reconnecting, reconnect now' }).textContent)
.toContain('Reconnecting...')
// An attempt that resolves instantly still shows the connecting pill for
// its 800ms minimum before the confirmation replaces it.
mounted.setConnectionState('connected')
expect(screen.queryByRole('status')).toBeNull()
act(() => { vi.advanceTimersByTime(800) })
expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
// The confirmation window is measured from visibility, not the transition.
act(() => { vi.advanceTimersByTime(1_999) })
expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
// The confirmation window closes at 2s, then the pill fades for 150ms.
act(() => { vi.advanceTimersByTime(1) })
act(() => { vi.advanceTimersByTime(150) })
expect(screen.queryByRole('status')).toBeNull()
})
it('keeps the attempt label steady through the hold and confirms for the full window', () => {
vi.useFakeTimers()
const mounted = mount({ dictionary: zh })
mounted.setConnectionState('connecting')
const attempt = screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' })
expect(attempt.textContent).toContain('重新连接中')
fireEvent.click(attempt)
expect(mounted.reconnect).toHaveBeenCalledOnce()
expect(attempt.textContent).toContain('重新连接中')
// An attempt that resolves mid-hold keeps its label until the hold ends.
act(() => { vi.advanceTimersByTime(100) })
mounted.setConnectionState('connected')
expect(screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' }).textContent)
.toContain('重新连接中')
act(() => { vi.advanceTimersByTime(700) })
expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy()
// The full two-second confirmation follows the delayed appearance.
act(() => { vi.advanceTimersByTime(1_999) })
expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy()
act(() => { vi.advanceTimersByTime(1) })
act(() => { vi.advanceTimersByTime(150) })
expect(screen.queryByRole('status')).toBeNull()
})
it('skips the hold when the attempt already stayed visible long enough', () => {
vi.useFakeTimers()
const mounted = mount()
mounted.setConnectionState('connecting')
act(() => { vi.advanceTimersByTime(800) })
mounted.setConnectionState('connected')
expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy()
act(() => { vi.advanceTimersByTime(2_000) })
act(() => { vi.advanceTimersByTime(150) })
expect(screen.queryByRole('status')).toBeNull()
})
+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/core/system-prompt/README.md
README.md: 7e4826c4fb51873db02fd80eca632c3d1dd8d550
README.zh.md: 30f2a43908513f5fe1e7169bff7ee95c9461ea5d
README.md: a76ae5c66f0801cb85cd8cf22d0c3ebbc68e65b8
README.zh.md: 98c6e733399c954c5bbc56b905aefcc6b080381d
+4 -2
View File
@@ -63,6 +63,8 @@ ctx.systemPrompt.section({
})
```
Set `interpolate: false` on a section to preserve its text literally, including `{{…}}` groups in generated tool documentation. Other sections interpolate variables by default.
### Contribute a prompt variable
Variables are referenced from section text as `{{name}}` and resolved at each assembly; scoped variables shadow a same-named global for that agent. The loop supplies `model` and `cwd`; any plugin can register the facts it owns.
@@ -102,7 +104,7 @@ The package is a registry plus a cooperative assembly pipeline. One `assemble()`
### Assembly and rendering
Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); a waterfall listener that mutates the list owns the determinism of what it emits.
Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references unless a section sets `interpolate: false`, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); a waterfall listener that mutates the list owns the determinism of what it emits.
### Scoping
@@ -170,7 +172,7 @@ Prefix-stable while the visible schema set, rendering, and order are unchanged.
These limits define when prompt assembly needs special care. They are current package constraints, not a task backlog.
- **Deployment-authored prompt text is config/composition only** — this plugin owns the global persona prefix and suffix defaults, creator plugins may register agent-scoped shadows, and other sections come from the plugin that owns the fact; there is no end-user prompt-editing API.
- **No escape syntax for literal `{{…}}` braces** — every complete group is interpolated against registered variables; an escape is deferred until a real prompt needs one.
- **No inline escape syntax in interpolated text** — use `interpolate: false` when a whole section must preserve literal braces.
- **`toolOrder` misconfiguration surfaces at prompt assembly (the first turn), not at boot** — only shape violations throw at config load.
+4 -2
View File
@@ -63,6 +63,8 @@ ctx.systemPrompt.section({
})
```
在段上设置 `interpolate: false` 可原样保留文本,包括生成的工具文档中的 `{{…}}` 组。其他段默认执行变量插值。
### 贡献提示词变量
变量在段文本中以 `{{name}}` 引用,并在每次组装时解析;带作用域变量会为该 agent 遮蔽同名全局变量。循环提供 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实。
@@ -102,7 +104,7 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd)
### 组装与渲染
组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall 分发前规范化收集到的工具(注册顺序只是插件加载产物);修改列表的 waterfall 监听器对其输出的确定性负责。
组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 对未设置 `interpolate: false` 的段插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall 分发前规范化收集到的工具(注册顺序只是插件加载产物);修改列表的 waterfall 监听器对其输出的确定性负责。
### 作用域
@@ -170,7 +172,7 @@ schema token 在每次请求中重复。限制工具会为该 agent 移除其全
这些限制说明提示词组装何时需要特别留意。它们是当前包约束,不是待办事项清单。
- **部署方编写的提示词文本只来自配置/组合**:此插件拥有全局 persona 前缀与后缀默认值;创建方插件可以注册 agent 作用域的遮蔽项;其他段来自拥有相应事实的插件。不存在终端用户提示词编辑 API。
- **没有表示字面量 `{{…}}` 花括号的转义语法**:每个完整组都会按已注册变量插值;只有实际提示词需要转义时才会实现。
- **插值文本不支持行内转义语法**:整段需要保留字面花括号时,使用 `interpolate: false`。
- **`toolOrder` 配置错误在提示词组装(首轮)时出现,而不是启动时**:只有形状违规会在配置加载时抛出。
+9 -3
View File
@@ -61,9 +61,11 @@ export interface PromptSection {
/**
* Static text or a provider evaluated at each assembly with that assembly's
* {@link AssembleContext}. The text may reference `{{variable}}`s — they are
* interpolated later, by {@link renderPrompt}.
* interpolated later, by {@link renderPrompt}, unless `interpolate` is false.
*/
readonly text: string | ((context: AssembleContext) => string)
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
readonly interpolate?: boolean
/**
* Treat this contribution as the complete system prompt. Assembly still
* runs the cooperative waterfall so tools, contexts, and variables can be
@@ -89,6 +91,8 @@ export interface AssembledSection {
name: string
/** The resolved (but not yet interpolated) section text. */
text: string
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
interpolate?: boolean
}
/** One resolved dynamic context contribution. */
@@ -264,7 +268,8 @@ export interface Config {
/**
* Interpolate strict `{{variable}}` references, drop empty sections, and join
* the rest with blank lines. Malformed, unknown, or undefined references throw;
* the rest with blank lines. Sections with `interpolate: false` retain literal
* text. Malformed, unknown, or undefined references in other sections throw;
* a lone `{{` without any later `}}` is literal prose, and substituted values
* are not scanned again.
* @param assembly - the assembly whose sections and variables to render.
@@ -272,7 +277,7 @@ export interface Config {
*/
export function renderPrompt(assembly: PromptAssembly): string {
return assembly.sections
.map(section => interpolate(section, assembly.variables, 'section'))
.map(section => section.interpolate === false ? section.text : interpolate(section, assembly.variables, 'section'))
.filter(text => text.length > 0)
.join('\n\n')
}
@@ -597,6 +602,7 @@ export class SystemPrompt extends Service {
const assembled = {
name: section.name,
text: typeof section.text === 'function' ? section.text(context) : section.text,
...section.interpolate !== undefined ? { interpolate: section.interpolate } : {},
}
if (section.complete === true) completeSection = { ...assembled }
return assembled
@@ -579,6 +579,25 @@ describe('SystemPrompt', () => {
expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou run on deepseek-v4 in /work.`)
})
it.each([
[false, false],
[false, true],
[true, false],
[true, true],
])('preserves literal section text with complete=%s and dynamic=%s', async (complete, dynamic) => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt, { includeHarnessIdentity: false, personaPrefix: '{{model}}' })
ctx.systemPrompt.variable('model', () => 'actual-model')
const text = '{{item}} {{model}} {{ model }} {{nested{{item}}}}'
ctx.systemPrompt.section({ name: 'literal', order: 1, text: dynamic ? () => text : text, interpolate: false, complete })
expect(renderPrompt(await ctx.systemPrompt.assemble()))
.toBe(complete ? text : `actual-model\n\n${text}`)
} finally {
await ctx.fiber.dispose()
}
})
it('lets a waterfall listener add or override variables before render', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
+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/core/tools/README.md
README.md: 15a301e5f68d6830fe28d02e6bdd6557126289bc
README.zh.md: ca675c25da0162b7143bcaf1d9bec3064d4629c5
README.md: a2ceb1e859509b434a34ae6441205cec4874b2e5
README.zh.md: f5523816a055dfec5d923d7019548dcbddca14b6
+1 -1
View File
@@ -170,7 +170,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat
#### What the model sees
PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. The TypeScript instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this PTC mode API; under `ptc` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for.
PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. The TypeScript instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000 and disables prompt-variable interpolation, preserving literal `{{…}}` text in tool descriptions and schemas for both runtime languages. `both` exposes normal schemas and this PTC mode API; under `ptc` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for.
##### TypeScript PTC mode SDK instructions with bash
+1 -1
View File
@@ -170,7 +170,7 @@ ctx.tools.register(defineTool({
#### 模型看到什么
PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 PTC mode API;在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。
PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000,并关闭提示词变量插值,使两种运行时语言都原样保留工具描述和 schema 中的 `{{…}}` 文本。`both` 会同时公开普通 schema 与此 PTC mode API;在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。
##### 带 bash 的 TypeScript PTC mode SDK 说明
+4 -3
View File
@@ -13,7 +13,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
// augmentation. The seam stays optional at runtime — see `serviceAsk`.
@@ -852,7 +852,7 @@ export class ToolRuntime extends Service {
* `both` renders empty: native calls do execute there, so the rule is false.
* @returns the section registration.
*/
private collapseSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } {
private collapseSection(): PromptSection {
return {
name: 'tools:ptc-only',
order: this.ctx.systemPrompt.getSectionOrder('PTC_ONLY'),
@@ -872,10 +872,11 @@ export class ToolRuntime extends Service {
* dropped from the rendered prompt.
* @returns the section registration.
*/
private sdkSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } {
private sdkSection(): PromptSection {
return {
name: 'tools:sdk',
order: this.ctx.systemPrompt.getSectionOrder('TOOLS_SDK'),
interpolate: false,
// Regenerate from the calling scope's visible tools in stable order.
text: (context) => {
const mode = this.modeFor(context.scope)
+20
View File
@@ -0,0 +1,20 @@
/** Tool documentation containing literal template syntax for recorded PTC replay. */
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'literal-sdk'
export const inject = ['tools']
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'template_echo',
description: 'Echo template text such as {{item}}, {{model}}, or {{ item }} without substitution.',
parameters: { text: { type: 'string', required: true } },
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
execute: args => Promise.resolve(args.text),
}))
}
+33 -1
View File
@@ -4,7 +4,7 @@ import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ToolRuntime, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
@@ -118,6 +118,38 @@ async function runCode(
}
describe('mode-aware wire contribution', () => {
it.each([
{ mode: 'ptc', language: 'typescript' },
{ mode: 'both', language: 'typescript' },
{ mode: 'ptc', language: 'python' },
{ mode: 'both', language: 'python' },
] as const)('preserves literal braces in the $language SDK under $mode', async ({ mode, language }) => {
const { ctx, systemPrompt } = await setup({ mode, runtime: { language } })
try {
systemPrompt.variable('model', () => 'actual-model')
const description = 'Expand {{item}} with {{model}} or {{ model }}.'
ctx.tools.register(defineTool({
name: 'template',
description,
parameters: { value: { type: 'string', description, enum: ['{{item}}', '{{model}}'], required: true } },
output: {
schema: { type: 'string', enum: ['{{item}}', '{{model}}'] },
render: (_args, value) => [{ type: 'text', text: value }],
},
execute: args => Promise.resolve(args.value),
}))
const assembly = await systemPrompt.assemble()
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')
expect(sdk).toBeDefined()
const prompt = renderPrompt(assembly)
expect(prompt).toContain(description)
expect(prompt).toContain(sdk!.text)
expect(prompt).not.toContain('actual-model')
} finally {
await ctx.fiber.dispose()
}
})
it("mode 'native' contributes every schema, no run_code, no SDK section — and needs no runtime", async () => {
const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false })
registerEcho(ctx)
@@ -3718,7 +3718,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AssembledSection',
declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}',
declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n interpolate?: boolean;\n}',
},
{
name: 'AssistantMessage',
@@ -4850,7 +4850,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PromptSection',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly interpolate?: boolean;\n readonly complete?: boolean;\n}',
},
{
name: 'PromptSectionOrderName',
@@ -1,5 +1,9 @@
# Keyless PTC mode combines the runtime/registry changes with the
# DeepSeek-to-replay swap in one profile patch.
- insert:
- id: literal-sdk
name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts'
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
+4
View File
@@ -1,6 +1,10 @@
# PTC mode adds `ctx.codeRuntime` and changes the registry to one wire tool,
# `run_code`, plus its generated TypeScript SDK prompt. The demo and snapshot
# recorder apply this profile patch; replay applies its sibling patch.
- insert:
- id: literal-sdk
name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts'
- id: agent-default-model
name: '@deepseek-ai/dsh-agent-default-model'
config:
@@ -191,6 +191,10 @@ interface ToolArgsMap {
/** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */
prompt: string;
} & Record<string, JsonValue>;
/** Echo template text such as {{item}}, {{model}}, or {{ item }} without substitution. */
template_echo: {
text: string;
} & Record<string, JsonValue>;
/** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
todo_write: {
/** The COMPLETE task list, replacing any previous list. */
@@ -466,6 +470,7 @@ interface ToolOutputMap {
runId: string;
output: JsonValue[];
};
template_echo: string;
todo_write: {
todos: ({
content: string;
@@ -1,5 +1,9 @@
# Keyless replay counterpart of ptc-workspace-context.cordis.yml. It adds
# PTC mode to the default filesystem suite and swaps in replay.
- insert:
- id: literal-sdk
name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts'
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
@@ -1,5 +1,9 @@
# PTC mode agent-instructions snapshot recording overlay. The default filesystem
# tools trigger nested instruction discovery after a read.
- insert:
- id: literal-sdk
name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts'
- id: agent-default-model
name: '@deepseek-ai/dsh-agent-default-model'
config:
@@ -1,4 +1,4 @@
- button "Settings":
- img
- text: Settings
- button "Reconnecting automatically, reconnect now": Reconnect now
- button "Reconnecting, reconnect now": Reconnecting