Merge remote-tracking branch 'origin/master' into worktree-fetest

This commit is contained in:
imccyu
2026-07-30 11:17:03 +08:00
81 changed files with 1283 additions and 254 deletions
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-bridges.md
2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe
2026-06-30-hook-bridges.zh.md: 11ed3a5d177661271b30f1a58d034caa577b5348
2026-06-30-hook-bridges.zh.md: 66855c3c4f36877aa627173de8e73250546e9621
@@ -15,7 +15,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](
`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见[事后复盘 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`
- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart``UserPromptSubmit``PreToolUse``PostToolUse``Stop``SubagentStart``SubagentStop`。拥有 CC 形态的每事件 stdin payload(基础字段 `session_id`/`transcript_path`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。`transcript_path` 是持久化定位器结果或 `''`stdin 带有**尾部换行**。
- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse``PostToolUse``SessionStart``UserPromptSubmit``Stop`。使用始终正则的匹配模式、Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`
- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse``PostToolUse``SessionStart``UserPromptSubmit``Stop`使用始终正则解释的 matcher,输出 Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`
### Outcome → Decision 映射
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md
2026-06-30-hook-protocol-lib.md: 33ec23dd4fa6aa8b4966bbe6c0ca5697ec83056c
2026-06-30-hook-protocol-lib.zh.md: 8e8c89a4ecca3bea98fb26bc55974765f27f6a11
2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857
2026-06-30-hook-protocol-lib.zh.md: 062160931f52576e65557b6e0d385ccaac54aceb
@@ -15,7 +15,7 @@ This Agent Note introduces `@deepseek-ai/dsh-hook-protocol`, a **library** (not
A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns four primitive families and the `hook/*` session events; each bridge plugin (`dsh-hooks-claude`, `dsh-hooks-codex`) owns what genuinely differs.
**Shared (here):**
- **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop).
- **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, validates the remaining runnable groups, and treats an invalid regex there as a whole-config load failure, with a stable dialect/pattern/event diagnostic; no hook listeners are registered. Runtime matching still contains an invalid regex as a non-match, so a direct library caller never throws into the loop.
- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`).
- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md)).
- **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order.
@@ -29,4 +29,4 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo
## Consequences
Each bridge parses config, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's real load path. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands.
Each bridge parses config atomically, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode and diagnostic, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's load path and pin the exact warning. Keyless ACP snapshots boot both bridges through the real Loader/app path with a valid blocking group before an invalid matcher, then prove the request reaches the replay model and persists no `hook/*` rows, so partial registration cannot hide behind a hand-mounted context. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands.
@@ -15,7 +15,7 @@ Status: implemented
`packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude``dsh-hooks-codex`)拥有真正不同的部分。
**共享(本库):**
- **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配多选),其视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常
- **Matcher** — `matcherDiagnostic(pattern, mode)``matchesMatcher(pattern, query, mode)`。两种方言唯一差异收敛 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` 始终使用未锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,校验其余可运行 group;其中任何无效正则都会导致整份配置加载失败,并给出包含方言/pattern/事件的稳定诊断,不会注册任何钩子监听器。运行时匹配仍会将无效正则隔离为不匹配,因此直接调用本库绝不向 agent loop(智能体循环)抛异常。
- **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash``stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。
- **解码** — `parseHookOutput(exit, stdout, stderr)`exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdoutexit `2` → blocking error`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。
- **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。
@@ -29,4 +29,4 @@ Status: implemented
## 后果
每个桥接插件解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。
每个桥接插件以原子方式解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式与诊断、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的加载路径并锁定精确警告。无密钥 ACP 快照通过真实 Loader/app 路径启动两个桥接插件,在非法 matcher 之前放置一个合法的拦截 group,然后证明请求仍到达 replay 模型且没有持久化任何 `hook/*` 行,从而避免手工挂载 Context 掩盖部分注册`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。
+11 -8
View File
@@ -268,7 +268,6 @@ flowchart TD
pkg_client_modules --> pkg_invariants
pkg_client_runtime --> pkg_invariants
pkg_client_ui_primitives --> pkg_invariants
pkg_client_ui_question --> pkg_invariants
pkg_client_ui_slots --> pkg_invariants
pkg_client_web --> pkg_invariants
pkg_client_web_react --> pkg_invariants
@@ -304,10 +303,6 @@ flowchart TD
pkg_client_ui_settings --> pkg_client_ui_primitives
pkg_client_ui_settings --> pkg_client_ui_slots
pkg_client_ui_settings --> pkg_invariants
pkg_client_ui_sidebar --> pkg_client_runtime
pkg_client_ui_sidebar --> pkg_client_ui_primitives
pkg_client_ui_sidebar --> pkg_client_ui_slots
pkg_client_ui_sidebar --> pkg_invariants
pkg_client_ui_trajectory --> pkg_client_ui_primitives
pkg_client_ui_trajectory --> pkg_invariants
pkg_client_ui_workspace --> pkg_client_runtime
@@ -345,12 +340,19 @@ flowchart TD
pkg_system_prompt --> pkg_scope
pkg_web --> pkg_invariants
pkg_web --> pkg_llm
pkg_client_ui_question --> pkg_client_locale
pkg_client_ui_question --> pkg_invariants
pkg_client_ui_settings_general --> pkg_client_locale
pkg_client_ui_settings_general --> pkg_client_runtime
pkg_client_ui_settings_general --> pkg_client_ui_primitives
pkg_client_ui_settings_general --> pkg_client_ui_settings
pkg_client_ui_settings_general --> pkg_client_ui_slots
pkg_client_ui_settings_general --> pkg_invariants
pkg_client_ui_sidebar --> pkg_client_locale
pkg_client_ui_sidebar --> pkg_client_runtime
pkg_client_ui_sidebar --> pkg_client_ui_primitives
pkg_client_ui_sidebar --> pkg_client_ui_slots
pkg_client_ui_sidebar --> pkg_invariants
pkg_client_ui_slash --> pkg_client_locale
pkg_client_ui_slash --> pkg_client_runtime
pkg_client_ui_slash --> pkg_client_ui_primitives
@@ -618,6 +620,7 @@ flowchart TD
pkg_client_ui_goal --> pkg_goal
pkg_client_ui_goal --> pkg_invariants
pkg_client_ui_model --> pkg_client_connection
pkg_client_ui_model --> pkg_client_locale
pkg_client_ui_model --> pkg_client_runtime
pkg_client_ui_model --> pkg_client_ui_command
pkg_client_ui_model --> pkg_client_ui_conversation
@@ -1001,7 +1004,6 @@ flowchart TD
| [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) |
| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
@@ -1021,7 +1023,6 @@ flowchart TD
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
@@ -1036,7 +1037,9 @@ flowchart TD
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
@@ -1101,7 +1104,7 @@ flowchart TD
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
+8 -2
View File
@@ -212,10 +212,16 @@ const SCENARIOS: Scenario[] = [
headerClass: 'advanced',
configPath: ADVANCED_CONFIG,
},
// Prompt-submit blocks are authored keylessly. Admission rejects before a
// turn opens, so only the ACP stop reason is observable and no log is harvested.
// Prompt-submit blocks are authored keylessly with malformed matcher fields,
// which these matcherless events must ignore. Admission rejects before a turn
// opens, so only the ACP stop reason is observable and no log is harvested.
{ name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false },
{ name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false },
// Each invalid matcher follows a runnable prompt blocker. Reaching the replay
// model without any hook audit rows proves config loading is atomic through
// the real Loader/app path, rather than retaining the earlier valid group.
{ name: 'hook-cc-invalid-matcher', hasModelTurn: true, recorded: false },
{ name: 'hook-codex-invalid-matcher', hasModelTurn: true, recorded: false },
// The mid-turn seams fire during a real model turn, so each is recorded with its hook active
// (the model's reaction to a deny/block/force-continue is part of the captured transcript).
// SessionStart/SubagentStart are excluded because detached injection races log
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." }
]
}
@@ -0,0 +1,18 @@
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5a36df87-da8e-480d-8e0f-61cd2b93bbb8"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}}
{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}}
{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}}
{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}}
{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}}
{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}}
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"7452a358-8038-4583-9ceb-66564f665bfb"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"}
{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -0,0 +1,19 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "echo 'must not run' >&2; exit 2" }
]
}
],
"PreToolUse": [
{
"matcher": "[",
"hooks": [
{ "type": "command", "command": "exit 2" }
]
}
]
}
}
@@ -2,6 +2,7 @@
"hooks": {
"UserPromptSubmit": [
{
"matcher": "[",
"hooks": [
{ "type": "command", "command": "echo 'blocked by policy hook' >&2; exit 2" }
]
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." }
]
}
@@ -0,0 +1,18 @@
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"56715824-b0da-4a73-8d6c-0caa590995e6"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}}
{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}}
{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}}
{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}}
{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}}
{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}}
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2d9d88d1-b684-491f-9d5f-73721b7fd5ed"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"}
{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -0,0 +1,19 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "echo 'must not run' >&2; exit 2" }
]
}
],
"PreToolUse": [
{
"matcher": "[",
"hooks": [
{ "type": "command", "command": "exit 2" }
]
}
]
}
}
@@ -2,6 +2,7 @@
"hooks": {
"UserPromptSubmit": [
{
"matcher": "[",
"hooks": [
{ "type": "command", "command": "echo 'blocked by codex policy hook' >&2; exit 2" }
]
+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/locale/README.md
README.md: 9015af2b44a33771b06863ace139fe97695df616
README.zh.md: 12205e21bb75a4433902b8e85c1cf7bdb0147bbf
README.md: c2adbcabc77def740094288da4643032873aa5b8
README.zh.md: c6ecb31e21d7513a4e7d579b17588107ccd7ea59
+3 -3
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key).
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)``TranslateNS<ns>`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience).
## Model Experience
@@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred.
- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount.
- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs.
- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.
+3 -3
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
locale 插件:LocaleService 包含浏览器 locale 偏好(`zh``en`,以 `dsh.locale` 为键持久化;提供 gettersetter,并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。
locale 插件:LocaleService——浏览器 locale 偏好(`zh``en`,以 `dsh.locale` 持久化;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})``LocaleNamespaceMap` 校验,`bind(ns)``TranslateNS<ns>`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate``TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。
## 模型体验
@@ -14,5 +14,5 @@ locale 插件:LocaleService 包含浏览器 locale 偏好(`zh``en`,以
## 已知限制与暂缓事项
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的界面区域会保留已渲染文本,直到重新挂载
- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移
- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新
@@ -5,23 +5,22 @@
* settings surface.
*/
import { useState } from 'react'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from './settings-contract.ts'
import type { createLanguageRowStore } from './settings-store.ts'
import css from './LanguageRow.module.css'
/** Injected business face: namespace-bound translate + the preference write. */
/** Injected business face: the preference write (t rides the standard locale seat). */
export interface LanguageRowInjected {
/** Translate a `settings.locale` dictionary key to the active-locale text. */
t: (key: string) => string
/** Switch the active locale (a registered locale id). */
setLocale: (id: string) => void
}
/** Full component props: runtime share + store share + injected face. */
/** Full component props: runtime share + store share + locale seat + injected face. */
export type LanguageRowComponentProps =
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>>
& PropsLocale<'settings.locale'> & LanguageRowInjected
/**
* Render the Language row.
+153 -37
View File
@@ -4,11 +4,21 @@
* preference row into the settings General section — the locale feature owns
* its own settings surface.
*/
/* oxlint-disable typescript/no-redundant-type-constituents --
* `keyof LocaleNamespaceMap & string` is the declare-merge key pattern (see
* ui-slots): in THIS unit the map holds only this package's own merges, but
* consumers merge more namespaces in and the intersection keeps them
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
import type { Context } from 'cordis'
import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import {
deferRegistration,
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
import { en, zh, type CommonKey } from '../locales/index.ts'
import {
en as settingsEn, zh as settingsZh, type SettingsLocaleKey,
} from '../locales/settings.ts'
import type { LanguageRowInjected } from './LanguageRow.tsx'
import { LanguageRow } from './LanguageRow.tsx'
import { createLanguageRowStore } from './settings-store.ts'
@@ -16,9 +26,21 @@ import { createLanguageRowStore } from './settings-store.ts'
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
export type { CommonKey } from '../locales/index.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
// The translate currency lives in ui-slots (the render machinery synthesizes
// the seat); re-exported here so dictionary owners import one package.
// TranslateNS<'model'> is the namespace-addressed developer-facing form.
export type { Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */
common: CommonKey
/** This feature's own settings-row copy (the Language row). */
'settings.locale': SettingsLocaleKey
}
}
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
@@ -50,7 +72,10 @@ declare module 'cordis' {
}
interface Events {
/**
* Locale state changed (active locale switched or registry updated).
* The active locale switched. Dictionary registrations do NOT emit this
* event (listeners may re-register slots in response, and boot registers
* one namespace per package); continuous render refresh rides the
* LocaleFace revision instead.
* @param snapshot - Current immutable locale snapshot.
* @mode emit
*/
@@ -77,16 +102,20 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([
])
/**
* Dictionary registry plus locale preference. Lookup chain per key: active
* locale -> zh fallback -> the key itself (missing text stays visible, fail
* loud in the UI rather than blank). Reads go through {@link getLocale};
* writes only through {@link setLocale}; continuous sync only through the
* `locale/change` event.
* Dictionary registry plus locale preference. Lookup chain per key: the
* entry's namespace in the active locale -> that namespace's zh fallback ->
* the shared common namespace (active, then zh) -> the key itself (missing
* text stays visible, fail loud in the UI rather than blank). Reads go
* through {@link getLocale}; writes only through {@link setLocale};
* continuous sync through the `locale/change` event, or through the
* LocaleFace getSnapshot/subscribe pair the render machinery consumes
* (installed via `ctx.slots.installLocale`).
*/
export class LocaleService {
private dicts = new Map<string, Map<string, LocaleDict>>()
private bound = new Map<string, Translate>()
private snapshot: LocaleSnapshot
private listeners = new Set<() => void>()
private readonly ctx: Context
/**
@@ -105,6 +134,27 @@ export class LocaleService {
return this.snapshot
}
/**
* LocaleFace getSnapshot: the current snapshot (carries `revision`; stable
* reference between changes, uSES-safe).
* @returns the current snapshot.
*/
getSnapshot(): LocaleSnapshot {
return this.snapshot
}
/**
* LocaleFace subscribe: notified on every snapshot change (locale switch
* or dictionary registration — registrations bump the revision so already
* rendered outlets pick up late-arriving dictionaries).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
}
/**
* Switch the active locale — the only preference write entry. Persists the
* id and emits `locale/change`.
@@ -114,44 +164,80 @@ export class LocaleService {
const match = this.snapshot.locales.find(l => l.id === id)
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
if (this.snapshot.active === match.id) return
this.snapshot = Object.freeze({
active: match.id,
locales: this.snapshot.locales,
revision: this.snapshot.revision + 1,
})
persistPreference(match.id)
this.ctx.emit('locale/change', this.snapshot)
this.publish(match.id, true)
}
/**
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
* throws (single occupant; a namespace's texts have one owner).
* Register a declared namespace's dictionaries, all locales in one call —
* the typed form: each dictionary is checked against the namespace's
* {@link LocaleNamespaceMap} key union (a missing or extra key is a
* compile error), and every shipped locale is required (bilingual balance
* enforced at the seam). Duplicate (ns, locale) throws (single occupant; a
* namespace's texts have one owner). Registration bumps the revision so
* mounted outlets pick up late-arriving dictionaries.
* @param ns - a namespace merged into LocaleNamespaceMap.
* @param dicts - complete dictionaries keyed by locale id.
* @returns disposer removing every locale registered by this call (idempotent).
*/
register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void
/**
* Single-locale untyped form for namespaces outside the merge table
* (dynamic composition, tests).
* @param ns - namespace.
* @param locale - locale tag (zh/en to start).
* @param locale - locale tag.
* @param dict - dictionary.
* @returns disposer (idempotent).
*/
register(ns: string, locale: string, dict: LocaleDict): () => void {
register(ns: string, locale: string, dict: LocaleDict): () => void
register(ns: string, localeOrDicts: string | Record<string, LocaleDict>, dict?: LocaleDict): () => void {
const pairs: [string, LocaleDict][] = typeof localeOrDicts === 'string'
// Overload guarantees dict on the single-locale arm.
? [[localeOrDicts, dict as LocaleDict]]
: Object.entries(localeOrDicts)
let locales = this.dicts.get(ns)
if (!locales) {
locales = new Map()
this.dicts.set(ns, locales)
}
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
locales.set(locale, dict)
for (const [locale] of pairs) {
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
}
for (const [locale, entries] of pairs) locales.set(locale, entries)
this.publish(this.snapshot.active, false)
return () => {
const owner = this.dicts.get(ns)
if (owner?.get(locale) === dict) owner.delete(locale)
/* v8 ignore next -- defensive: a namespace's locales map is created on
* first register and never removed, so the disposer always finds it. */
if (!owner) return
let removed = false
for (const [locale, entries] of pairs) {
if (owner.get(locale) === entries) {
owner.delete(locale)
removed = true
}
}
if (removed) this.publish(this.snapshot.active, false)
}
}
/**
* Bind a namespace to a translate function. The returned reference is
* stable per namespace (repeat binds return the same function), so it can
* ride inject surfaces without breaking memoization.
* @param ns - namespace.
* @returns the translate function (reads the active locale at call time).
* Bind a declared namespace to a translate function typed to its
* dictionary key union (plus the shared common vocabulary) — the same key
* domain the framework-injected `t` seat carries. The returned reference
* is stable per namespace (repeat binds return the same function), so it
* can ride inject surfaces without breaking memoization.
* @param ns - a namespace merged into LocaleNamespaceMap.
* @returns the typed translate function (reads the active locale at call time).
*/
bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>
/**
* Untyped form for namespaces outside the merge table (dynamic
* composition, tests).
* @param ns - namespace.
* @returns the translate function.
*/
bind(ns: string): Translate
bind(ns: string): Translate {
let t = this.bound.get(ns)
if (!t) {
@@ -163,14 +249,43 @@ export class LocaleService {
}
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
const locales = this.dicts.get(ns)
const template = locales?.get(this.snapshot.active)?.[key]
?? locales?.get(FALLBACK_LOCALE)?.[key]
const template = this.lookup(ns, key)
?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key) : undefined)
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
private lookup(ns: string, key: string): string | undefined {
const locales = this.dicts.get(ns)
return locales?.get(this.snapshot.active)?.[key] ?? locales?.get(FALLBACK_LOCALE)?.[key]
}
/**
* Advance the snapshot revision and notify LocaleFace subscribers (render
* refresh). Only an active-locale switch additionally emits
* `locale/change` — dictionary registrations stay off the event so
* registration-heavy boot cannot storm event listeners (which may
* re-register slots in response).
*/
private publish(active: LocaleId, localeChanged: boolean): void {
this.snapshot = Object.freeze({
active,
locales: this.snapshot.locales,
revision: this.snapshot.revision + 1,
})
if (localeChanged) this.ctx.emit('locale/change', this.snapshot)
for (const fn of [...this.listeners]) {
try {
fn()
} catch (error) {
// One throwing subscriber must not strand the rest on a stale
// revision (outlets would keep the previous language).
console.error('locale subscriber crashed:', error)
}
}
}
}
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
@@ -208,11 +323,12 @@ export const inject = ['slots']
*/
export function apply(ctx: ClientContext): void {
const locale = new LocaleService(ctx)
locale.register(COMMON_NS, 'zh', zh)
locale.register(COMMON_NS, 'en', en)
locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' })
locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' })
locale.register(COMMON_NS, { zh, en })
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
ctx.provide('locale', locale)
// The service IS the LocaleFace (bind + getSnapshot/subscribe): install it
// so the render machinery can synthesize the `t` standard seat.
ctx.slots.installLocale(locale)
const store = createLanguageRowStore()
let bound: BoundActions<typeof store> | undefined
@@ -230,7 +346,6 @@ export function apply(ctx: ClientContext): void {
// first render (the store's revision guard drops stale duplicates).
sync(locale.getLocale())
return {
t: locale.bind(SETTINGS_NS),
setLocale: (id) => { locale.setLocale(id) },
}
}
@@ -241,6 +356,7 @@ export function apply(ctx: ClientContext): void {
id: 'language',
order: 0,
store,
locale: SETTINGS_NS,
inject: injected,
}, LanguageRow))
return () => { deferred.dispose() }
+29 -2
View File
@@ -1,2 +1,29 @@
/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const en: Record<string, string> = {}
import type { CommonKey } from './zh.ts'
/** en base dictionary for the common namespace, checked complete against the zh key set. */
export const en = {
'ok': 'OK',
'cancel': 'Cancel',
'close': 'Close',
'copy': 'Copy',
'copied': 'Copied',
'retry': 'Retry',
'loading': 'Loading…',
'load.failed': 'Failed to load',
'submit': 'Submit',
'submitting': 'Submitting…',
'next': 'Next',
'previous': 'Previous',
'skip': 'Skip',
'delete': 'Delete',
'edit': 'Edit',
'save': 'Save',
'search': 'Search',
'more': 'More',
'collapse': 'Collapse',
'expand': 'Expand',
'back': 'Back',
'unknown': 'Unknown',
'none': 'None',
'truncated': 'Truncated',
} satisfies Record<CommonKey, string>
@@ -0,0 +1,8 @@
/**
* The common-namespace dictionary pair. zh is the source of truth for the
* key set (Chinese-first repo convention); en is checked complete against it
* — a missing or extra en key is a compile error.
*/
export { zh } from './zh.ts'
export { en } from './en.ts'
export type { CommonKey } from './zh.ts'
@@ -0,0 +1,14 @@
/** `settings.locale` namespace dictionaries (the Language row's copy). */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'language.title': '语言',
} satisfies Record<string, string>
/** The settings.locale namespace key union. */
export type SettingsLocaleKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'language.title': 'Language',
} satisfies Record<SettingsLocaleKey, string>
+30 -2
View File
@@ -1,2 +1,30 @@
/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const zh: Record<string, string> = {}
/** zh base dictionary for the common namespace: cross-feature standard words. */
export const zh = {
'ok': '确定',
'cancel': '取消',
'close': '关闭',
'copy': '复制',
'copied': '复制成功',
'retry': '重试',
'loading': '加载中…',
'load.failed': '加载失败',
'submit': '提交',
'submitting': '正在提交…',
'next': '下一步',
'previous': '上一步',
'skip': '跳过',
'delete': '删除',
'edit': '编辑',
'save': '保存',
'search': '搜索',
'more': '更多',
'collapse': '收起',
'expand': '展开',
'back': '返回',
'unknown': '未知',
'none': '无',
'truncated': '已截断',
} satisfies Record<string, string>
/** The common vocabulary key union (zh is the key-set source of truth). */
export type CommonKey = keyof typeof zh
+5 -3
View File
@@ -69,16 +69,18 @@ describe('locale apply', () => {
// An event ahead of any inject hits the unbound-actions arm.
locale.setLocale('en')
const { instance, face } = faceOf(b.slots)
const { entry, instance, face } = faceOf(b.slots)
// The inject-time re-sync sealed the init window: the mirror is current.
expect(instance.getSnapshot().active).toBe('en')
expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en'])
expect(face.t('language.title')).toBe('Language')
// Copy rides the standard locale seat: the entry declares the namespace.
expect(entry.locale).toBe(SETTINGS_NS)
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language')
face.setLocale('zh')
expect(locale.getLocale().active).toBe('zh')
expect(instance.getSnapshot().active).toBe('zh')
expect(face.t('language.title')).toBe('语言')
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
@@ -29,6 +29,24 @@ describe('LocaleService', () => {
expect(t('missing.key')).toBe('missing.key')
})
it('falls through to the common vocabulary after the namespace misses (production keys)', () => {
const { svc } = make()
// The shipped common pair is registered by apply; the bench registers it
// directly to pin the production chain: ns -> common -> zh -> key.
svc.register('common', 'zh', { retry: '重试' })
svc.register('common', 'en', { retry: 'Retry' })
svc.register('ns', 'zh', { own: '自有' })
const t = svc.bind('ns')
expect(t('retry')).toBe('重试')
svc.setLocale('en')
expect(t('retry')).toBe('Retry')
expect(t('own')).toBe('自有')
// common itself must not recurse: a miss inside common echoes the key.
// (Wide-string ns hits the untyped bind overload — the typed one rejects
// unknown keys at compile time, which is the point of the seam.)
expect(svc.bind('common' as string)('nope')).toBe('nope')
})
it('interpolates {name} params and leaves unknown placeholders intact', () => {
const { svc } = make()
svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
@@ -56,6 +74,47 @@ describe('LocaleService', () => {
expect(t('k')).toBe('v2')
})
it('serves the LocaleFace: snapshot revision moves on switch and registration, subscribers fire, unsubscribe stops them', () => {
const { svc } = make()
const seen: number[] = []
const off = svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
expect(svc.getSnapshot()).toBe(svc.getLocale())
const r0 = svc.getSnapshot().revision
svc.register('ns', 'zh', { k: 'v' })
expect(svc.getSnapshot().revision).toBe(r0 + 1)
svc.setLocale('en')
expect(seen).toEqual([r0 + 1, r0 + 2])
off()
svc.setLocale('zh')
expect(seen).toHaveLength(2)
})
it('isolates a throwing subscriber: the rest still see the new revision', () => {
const { svc } = make()
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const seen: number[] = []
svc.subscribe(() => { throw new Error('boom') })
svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
svc.setLocale('en')
expect(seen).toEqual([1])
expect(spy).toHaveBeenCalledOnce()
} finally {
spy.mockRestore()
}
})
it('register disposer republishes (mounted outlets drop the dead dictionary)', () => {
const { svc } = make()
const dispose = svc.register('ns', 'zh', { k: 'v' })
const before = svc.getSnapshot().revision
dispose()
expect(svc.getSnapshot().revision).toBe(before + 1)
// Second run hits the idempotent arm: nothing removed, no republish.
dispose()
expect(svc.getSnapshot().revision).toBe(before + 1)
})
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
const { svc, events } = make()
svc.setLocale('en')
+28 -1
View File
@@ -18,7 +18,7 @@ import { Service } from 'cordis'
import type { Context } from 'cordis'
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
import type {
OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -70,6 +70,8 @@ interface ErasedRegisterOptions {
select?: (owner: never) => unknown
/** Chain-slot explicit ordering override (ascending; registration order otherwise). */
priority?: number
/** Declared dictionary namespace (the renderer synthesizes the `t` seat from it). */
locale?: string
registrant?: string
}
@@ -82,6 +84,7 @@ export class SlotsService extends Service {
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
private _renderer: SlotRenderer | undefined
private _locale: LocaleFace | undefined
private _host: SlotRendererHost | undefined
/**
@@ -127,6 +130,23 @@ export class SlotsService extends Service {
}, 'slots.install()')
}
/**
* Install the locale face backing the `t` standard seat (the locale
* plugin's product; same boot-once discipline as the renderer install).
* Runs through the caller's ctx.effect, so the installing fiber's unload
* uninstalls the face.
* @param face - namespace binder + revision observable.
*/
installLocale(face: LocaleFace): void {
if (this._locale !== undefined) throw new Error('locale face already installed (installLocale() is boot-once)')
this.ctx.effect(() => {
this._locale = face
return () => {
if (this._locale === face) this._locale = undefined
}
}, 'slots.installLocale()')
}
/**
* The single ctx-level render entry: the shell renders 'root'; every other
* key renders inside components through the props renderSlot face. All
@@ -246,6 +266,12 @@ export class SlotsService extends Service {
if (workspaces === undefined) {
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
}
// `locale` is a live getter: the face installs (and, under HMR, swaps)
// on the locale plugin's own fiber lifetime, while this host object is
// built once — a captured value would strand renders on a dead face. The
// alias is required: `this` inside the getter is the host literal.
// oxlint-disable-next-line typescript/no-this-alias
const service = this
this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key),
@@ -259,6 +285,7 @@ export class SlotsService extends Service {
provideInfo: sessions.currentProvideInfo,
},
workspaces: { list: workspaces.list },
get locale() { return service._locale },
}
return this._host
}
+3
View File
@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-command"
],
@@ -36,6 +37,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-command": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
@@ -49,6 +51,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -18,6 +18,7 @@ import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelSelectInjected } from './slots.ts'
import css from './ModelSelect.module.css'
@@ -34,10 +35,13 @@ interface EffortChoice {
/**
* Render the composer model seat.
* @param props - owner share (locked) + injected face (shared directory store/verbs).
* @param props - owner share (locked) + injected face (shared directory
* store/verbs) + the standard locale seat.
* @returns the trigger and, while open, the two-level menu.
*/
export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) {
export function ModelSelect(
{ locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
) {
const state = useSyncExternalStore(
fn => directory.subscribe(fn),
() => directory.getSnapshot(),
@@ -70,13 +74,13 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
const effortLabel = reasoning === undefined
? undefined
: effectiveEffort === undefined
? 'Provider default'
? t('effort.providerDefault')
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
? []
: [
...reasoning.defaultEffort === undefined
? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }]
? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }]
: [],
...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
key: `effort:${effort.id}`,
@@ -84,7 +88,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
label: effort.name,
...effort.description === undefined ? {} : { description: effort.description },
})),
], [reasoning])
], [reasoning, t])
const busy = state.status === 'selecting'
// Mount-time load resolves the trigger label; every open refreshes.
@@ -165,7 +169,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
})
}
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型'
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback')
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
itemRefs.current = []
let itemIndex = 0
@@ -180,7 +184,9 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`}
aria-label={effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
@@ -204,19 +210,19 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
id={`${id}-menu`}
className={css.menu}
role="menu"
aria-label="模型与推理等级"
aria-label={t('menu.aria')}
aria-busy={state.status === 'loading' || busy}
>
{pane === 'root' && (
<>
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
<span className={css.cellLabel}>Model</span>
<span className={css.cellLabel}>{t('menu.model')}</span>
<span className={css.cellValue}>{modelLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
{reasoning !== undefined && (
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
<span className={css.cellLabel}>Effort</span>
<span className={css.cellLabel}>{t('menu.effort')}</span>
<span className={css.cellValue}>{effortLabel}</span>
<IconChevronRightOutline14 className={css.cellChevron} />
</button>
@@ -227,18 +233,18 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
{pane === 'model' && (
<>
{state.status === 'loading' && (
<div className={css.status}></div>
<div className={css.status}>{t('status.loading')}</div>
)}
{state.error !== null && (
<div className={css.error}>
<span>{state.error}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
<span>{t('error.action', { message: state.error })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
</div>
)}
{state.failures.map(failure => (
<div className={css.warning} key={failure.id}>
<span>{failure.name} {failure.message}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
</div>
))}
<div className={clsx(css.groups, 'scrollable')}>
@@ -267,7 +273,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
<span className={css.description}>{model.description}</span>
)}
{model.unlisted === true && (
<span className={css.unlisted}> · </span>
<span className={css.unlisted}>{t('option.currentUnlisted')}</span>
)}
</span>
<span className={css.check}>
@@ -281,7 +287,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
})}
</div>
{state.status === 'ready' && choices.length === 0 && (
<div className={css.empty}></div>
<div className={css.empty}>{t('empty.models')}</div>
)}
</>
)}
@@ -290,12 +296,12 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
<>
{state.error !== null && (
<div className={css.error}>
<span>{state.error}</span>
<button type="button" className={css.retry} onClick={() => { load() }}></button>
<span>{t('error.action', { message: state.error })}</span>
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.reload')}</button>
</div>
)}
{effortChoices.length === 0
? <div className={css.empty}></div>
? <div className={css.empty}>{t('empty.efforts')}</div>
: effortChoices.map(level => (
<button
ref={itemRef()}
+39 -10
View File
@@ -14,15 +14,27 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
// Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelDirectoryState } from './directory.ts'
import { ModelService } from './service.ts'
import type { ModelSelectInjected } from './slots.ts'
import { ModelSelect } from './ModelSelect.tsx'
import { en, zh, type ModelKey } from './locales.ts'
export { ModelDirectory } from './directory.ts'
export type { ModelDirectoryState } from './directory.ts'
export { ModelService } from './service.ts'
export type { ModelSelectInjected } from './slots.ts'
export type { ModelKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The model selection surfaces' copy (/model popup + composer seat). */
model: ModelKey
}
}
/** One selectable row's id: an opaque row key (resolved by lookup, never parsed). */
function rowId(providerId: string, modelId: string): string {
@@ -30,7 +42,7 @@ function rowId(providerId: string, modelId: string): string {
}
/** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
function optionsOf(directory: SessionModels): SelectOption[] {
function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOption[] {
const rows: SelectOption[] = []
for (const group of directory.groups) {
for (const model of group.models) {
@@ -38,7 +50,7 @@ function optionsOf(directory: SessionModels): SelectOption[] {
id: rowId(group.id, model.id),
label: model.name,
detail: model.unlisted === true
? `${group.name} · 未列入目录`
? t('option.unlisted', { group: group.name })
: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
...(directory.current.provider === group.id && directory.current.model === model.id
? { active: true } : {}),
@@ -46,7 +58,11 @@ function optionsOf(directory: SessionModels): SelectOption[] {
}
}
for (const failure of directory.failures) {
rows.push({ id: `failure/${failure.id}`, label: failure.name, detail: `目录加载失败:${failure.message}` })
rows.push({
id: `failure/${failure.id}`,
label: failure.name,
detail: t('option.loadError', { message: failure.message }),
})
}
return rows
}
@@ -76,28 +92,40 @@ function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefin
return undefined
}
/** Required services: the contribution registry, the seat's slot registry, and the service's own faces. */
export const inject = ['command', 'connection', 'sessions', 'slots']
/** Dictionary namespace owned by this plugin. */
const NS = 'model'
/** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */
export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
/**
* Client plugin body: mount ModelService, then register the /model popup
* contribution and the composer model seat over it.
* Client plugin body: mount ModelService, register the `model` dictionaries,
* then register the /model popup contribution and the composer model seat
* over the service.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.plugin(ModelService)
// Entry 1: the /model popupSelect over the shared directory.
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model: dictionaries')
// Non-slot faces (the command description, the popup option builder) read
// through the bound translate; the seat component reads the standard seat.
const t = ctx.locale.bind(NS)
// Entry 1: the /model popupSelect over the shared directory. The command
// description is registry-held text: it reads t() once at registration and
// refreshes only on re-registration, not on locale change.
ctx.inject(['command', 'models'], (scope: ClientContext) => {
const command = scope.get('command') as CommandServiceContract
const models = scope.models
scope.effect(() => command.register({
name: 'model',
description: 'Select the model for this conversation',
description: t('command.description'),
available: () => true,
ui: {
kind: 'popupSelect',
options: async session => optionsOf(await models.directoryFor(session.sessionId).load()),
options: async session => optionsOf(await models.directoryFor(session.sessionId).load(), t),
onSelect: async (option, session) => {
const directory = models.directoryFor(session.sessionId)
const target = targetOf(directory.store.getSnapshot(), option.id)
@@ -117,6 +145,7 @@ export function apply(ctx: ClientContext): void {
const models = scope.models
scope.effect(() => scope.slots.register({
name: 'conversation.input.model',
locale: NS,
inject: (sessionId): ModelSelectInjected => {
const directory = models.directoryFor(sessionId)
return {
@@ -0,0 +1,46 @@
/** `model` namespace dictionaries. */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'command.description': '选择本会话使用的模型',
'option.unlisted': '{group} · 未列入目录',
'option.loadError': '目录加载失败:{message}',
'trigger.fallback': '选择模型',
'trigger.aria': '选择模型,当前 {model}',
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
'menu.aria': '模型与推理等级',
'menu.model': '模型',
'menu.effort': '推理等级',
'effort.providerDefault': '服务商默认',
'status.loading': '正在刷新模型列表…',
'error.action': '模型操作失败:{message}',
'action.reload': '重新加载',
'warning.groupLoad': '{name} 加载失败:{message}',
'option.currentUnlisted': '当前模型 · 未列入目录',
'empty.models': '没有可用的模型。',
'empty.efforts': '当前模型未提供推理等级。',
} satisfies Record<string, string>
/** The model namespace key union. */
export type ModelKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'command.description': 'Select the model for this conversation',
'option.unlisted': '{group} · Not in catalog',
'option.loadError': 'Catalog failed to load: {message}',
'trigger.fallback': 'Select model',
'trigger.aria': 'Select model, current {model}',
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
'menu.aria': 'Model and reasoning effort',
'menu.model': 'Model',
'menu.effort': 'Effort',
'effort.providerDefault': 'Provider default',
'status.loading': 'Refreshing model list…',
'error.action': 'Model operation failed: {message}',
'action.reload': 'Reload',
'warning.groupLoad': '{name} failed to load: {message}',
'option.currentUnlisted': 'Current model · Not in catalog',
'empty.models': 'No models available.',
'empty.efforts': 'This model provides no reasoning effort levels.',
} satisfies Record<ModelKey, string>
@@ -12,6 +12,7 @@ import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
import type { ModelSelectInjected } from '../src/client/slots.ts'
@@ -79,14 +80,18 @@ async function bench() {
return () => { contribution = undefined }
},
})
const seats = new Map<string, { inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined }>()
const seats = new Map<string, {
inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined
locale: string | undefined
}>()
ctx.provide('slots', {
register(options: { name: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
seats.set(options.name, { inject: options.inject })
register(options: { name: string; locale?: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
seats.set(options.name, { inject: options.inject, locale: options.locale })
return () => { seats.delete(options.name) }
},
})
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
const scopes = new Map<SessionId, Context>()
ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) })
const fiber = ctx.plugin({ inject: [...inject], apply })
@@ -114,6 +119,8 @@ describe('ui-model dual entry', () => {
expect(b.contribution().name).toBe('model')
expect(b.contribution().ui.kind).toBe('popupSelect')
expect(b.seat().inject).toBeTypeOf('function')
// Copy rides the standard locale seat.
expect(b.seat().locale).toBe('model')
})
it('popup options mark the host current active with the provider group in the detail', async () => {
@@ -3,8 +3,22 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComponentProps } from 'react'
import type { ModelDirectoryState } from '../src/client/directory.ts'
import { ModelSelect } from '../src/client/ModelSelect.tsx'
import { zh } from '../src/client/locales.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
// The seat's key domain is model common; the stub mirrors the real lookup
// chain: package dictionary, then common vocabulary, then the key.
const t: ComponentProps<typeof ModelSelect>['t'] = (key, params) => {
const template = (zh as Record<string, string>)[key]
?? (commonZh as Record<string, string>)[key]
?? key
return params === undefined
? template
: template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
}
const reasoning = {
efforts: [
@@ -44,13 +58,14 @@ describe('ModelSelect reasoning effort', () => {
directory={directory}
load={vi.fn()}
select={select}
t={t}
/>)
const trigger = screen.getByRole('button', {
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
})
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['Off', 'High', 'MaxLargest budget'])
@@ -83,13 +98,14 @@ describe('ModelSelect reasoning effort', () => {
directory={directory}
load={vi.fn()}
select={vi.fn().mockResolvedValue(true)}
t={t}
/>)
fireEvent.click(screen.getByRole('button', {
name: '选择模型,当前 Model,推理等级 Provider default',
name: '选择模型,当前 Model,推理等级 服务商默认',
}))
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
.toEqual(['Provider default', 'Standard'])
.toEqual(['服务商默认', 'Standard'])
})
})
+3
View File
@@ -14,6 +14,9 @@
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
+3
View File
@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
@@ -44,11 +45,13 @@
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
@@ -4,7 +4,10 @@ import {
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
import {
PendingQuestion,
type QuestionAnswer, type QuestionComposerProps,
} from './contract/slots.ts'
import css from './QuestionComposer.module.css'
interface DraftAnswer {
@@ -52,17 +55,20 @@ export function QuestionComposer(props: QuestionComposerProps) {
// Domain-face mint rides the carrier's stable identity (never minted in a
// select/render dispatch — per-dispatch minting would churn memo identity).
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
return <QuestionFlow key={question.key} pending={question} />
return <QuestionFlow key={question.key} pending={question} t={props.t} />
}
function QuestionFlow({ pending }: { pending: PendingQuestion }) {
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
const questions = pending.questions
const [index, setIndex] = useState(0)
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
const [error, setError] = useState<string | null>(null)
// Validation feedback is stored as a dictionary KEY and translated at
// render, so already-shown feedback follows a locale switch; runtime
// failure messages (finished strings from the wire) pass through verbatim.
const [error, setError] = useState<{ key: 'error.incomplete' | 'error.unanswered' } | { text: string } | null>(null)
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// oxlint-disable-next-line typescript/no-non-null-assertion
const question = questions[index]!
@@ -75,7 +81,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
setError(null)
void pending.cancel().catch((cause: unknown) => {
setBusy(null)
setError(cause instanceof Error ? cause.message : String(cause))
setError({ text: cause instanceof Error ? cause.message : String(cause) })
})
}
@@ -111,7 +117,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
const missing = values.findIndex(item => !completed(item))
if (missing >= 0) {
setIndex(missing)
setError('请先完成这道问题。')
setError({ key: 'error.incomplete' })
return
}
const answer: QuestionAnswer = {
@@ -130,13 +136,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
setError(null)
void pending.answer(answer).catch((cause: unknown) => {
setBusy(null)
setError(cause instanceof Error ? cause.message : String(cause))
setError({ text: cause instanceof Error ? cause.message : String(cause) })
})
}
const continueFlow = (): void => {
if (!answered(draft)) {
setError('请选择一个选项或填写自定义答案。')
setError({ key: 'error.unanswered' })
return
}
if (index < questions.length - 1) {
@@ -174,28 +180,30 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
<span>{question.multiSelect === true
? parseQuestionTitle(question.question)
: question.question}</span>
{question.multiSelect === true && <span className={css.multiSelectHint}></span>}
{question.multiSelect === true && (
<span className={css.multiSelectHint}>{t('title.multi')}</span>
)}
</h2>
</div>
<div className={css.headerActions}>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label="上一题"
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="下一题"
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label="放弃整组问题"
title="放弃整组问题"
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
title={t('nav.cancel')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
@@ -230,7 +238,9 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
{display.recommended && <span className={css.badge}></span>}
{display.recommended && (
<span className={css.badge}>{t('option.recommended')}</span>
)}
{option.description !== undefined && (
<span className={css.description}>{option.description}</span>
)}
@@ -255,7 +265,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
aria-expanded={draft.customOpen}
>
<span className={css.number}><IconEditOutline16 /></span>
<span></span>
<span>{t('option.custom')}</span>
</button>
)}
{draft.customOpen && (
@@ -265,7 +275,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder="输入你的答案"
placeholder={t('custom.placeholder')}
onChange={(event) => {
const value = event.target.value
updateDraft(current => ({
@@ -285,18 +295,18 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
</div>
<footer className={css.footer}>
<div className={css.feedback} role="status">{error}</div>
<div className={css.feedback} role="status">{error === null ? null : 'key' in error ? t(error.key) : error.text}</div>
<div className={css.footerActions}>
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
{t('action.skip')}
</Button>
<Button
variant="primary" size="sm"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'
? '正在提交…'
: index === questions.length - 1 ? '提交' : '下一题'}
? t('submitting')
: index === questions.length - 1 ? t('submit') : t('action.next')}
</Button>
</div>
</footer>
@@ -6,7 +6,7 @@
* cancelled error encoding, receipt checks — lives HERE, with the package
* that consumes it.
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
// entry) into every program that sees this contract, so PropsRuntime resolves.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -70,8 +70,9 @@ export class PendingQuestion {
/**
* Full component props: the framework runtime share (chain currency +
* session/global standard kit) plus the chain `matched` share — the entry's
* selector result, already narrowed to the question carrier. No injected
* share: the carrier plus the domain face above carry the whole behavior
* surface.
* selector result, already narrowed to the question carrier — plus the
* standard locale seat; the carrier plus the domain face above carry the
* whole behavior surface.
*/
export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }
export type QuestionComposerProps =
PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>
+29 -10
View File
@@ -1,18 +1,32 @@
/**
* Web question plugin, browser half: QuestionComposer registered as a
* selector-routed entry of the conversation-declared composer chain. Pure
* consumer — the selector narrows the owner's currency to the question
* carrier (matched prop), and the whole behavior surface rides the carrier
* (domain encoding in contract/slots.ts PendingQuestion); no inject face, no
* service dependency beyond slots. Export discipline: packages/client/AGENTS.md.
* selector-routed entry of the conversation-declared composer chain, plus the
* `question` dictionaries. The selector narrows the owner's currency to the
* question carrier (matched prop), and the whole behavior surface rides the
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { QuestionWait } from './contract/slots.ts'
import { QuestionComposer } from './QuestionComposer.tsx'
import { en, zh, type QuestionKey } from './locales.ts'
export { PendingQuestion } from './contract/slots.ts'
export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './contract/slots.ts'
export type { QuestionKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The question composer's copy. */
question: QuestionKey
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'question'
/**
* Required services (cordis fiber inject). 'conversation' is an ordering
@@ -20,7 +34,7 @@ export type { QuestionAnswer, QuestionComposerProps, QuestionWait } from './cont
* declared by ui-conversation's apply, and register() into an undeclared
* slot throws — service waiting orders this apply after the declaring one.
*/
export const inject = ['slots', 'conversation']
export const inject = ['slots', 'conversation', 'locale']
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
@@ -28,14 +42,19 @@ function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | nu
}
/**
* Client plugin body: register the question composer into the composer chain.
* Zero business face — data and verbs both live on the matched carrier.
* Client plugin body: register the `question` dictionaries and the question
* composer into the composer chain. Zero business face — data and verbs live
* on the matched carrier; t rides the standard locale seat.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const slots = ctx.slots
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-question: dictionaries')
ctx.effect(
() => slots.register({ name: 'conversation.composer', select: selectQuestion }, QuestionComposer),
() => ctx.slots.register(
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
QuestionComposer,
),
'ui-question: composer chain registration',
)
}
@@ -0,0 +1,34 @@
/** `question` namespace dictionaries. */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'error.incomplete': '请先完成这道问题。',
'error.unanswered': '请选择一个选项或填写自定义答案。',
'title.multi': '可多选',
'nav.prev': '上一题',
'nav.next': '下一题',
'nav.cancel': '放弃整组问题',
'option.recommended': '推荐',
'option.custom': '其他,请填写自定义答案',
'custom.placeholder': '输入你的答案',
'action.skip': '跳过本题',
'action.next': '下一题',
} satisfies Record<string, string>
/** The question namespace key union. */
export type QuestionKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'error.incomplete': 'Please complete this question first.',
'error.unanswered': 'Please select an option or enter a custom answer.',
'title.multi': 'Multi-select',
'nav.prev': 'Previous question',
'nav.next': 'Next question',
'nav.cancel': 'Dismiss all questions',
'option.recommended': 'Recommended',
'option.custom': 'Other — enter a custom answer',
'custom.placeholder': 'Type your answer',
'action.skip': 'Skip this question',
'action.next': 'Next',
} satisfies Record<QuestionKey, string>
@@ -9,6 +9,7 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
import { apply, inject } from '../src/client/index.ts'
@@ -24,12 +25,13 @@ async function bench() {
// 'conversation' inject is an ordering edge (the declaring plugin provides
// it after declaring the chain); the bench declares the chain itself.
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
return { ctx, slots }
}
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slots', 'conversation'])
expect(inject).toEqual(['slots', 'conversation', 'locale'])
})
it('fails loud when no live entry has declared the composer slot', async () => {
@@ -38,6 +40,7 @@ describe('apply', () => {
// Satisfy the ordering inject without declaring the chain: apply must
// then hit the undeclared-slot throw, not sit waiting on the service.
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleService(ctx))
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.composer" is not declared/)
})
@@ -47,8 +50,10 @@ describe('apply', () => {
await ctx.plugin({ inject: [...inject], apply }).await()
const entry = slots.entries('conversation.composer')[0]!
expect(entry.component).toBe(QuestionComposer)
// The whole behavior surface rides the matched carrier: no business face.
// The whole behavior surface rides the matched carrier: no business face;
// copy rides the standard locale seat.
expect(entry.inject).toBeUndefined()
expect(entry.locale).toBe('question')
// The selector narrows the chain currency: question wait in → that wait; none → null.
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
const question = { kind: 'question' }
@@ -8,10 +8,12 @@ import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { PendingQuestion } from '../src/client/contract/slots.ts'
import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
import {
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
} from '../src/client/QuestionComposer.tsx'
import { zh } from '../src/client/locales.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
afterEach(cleanup)
@@ -28,6 +30,11 @@ const kit = {
useProjection: (() => undefined) as never,
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
// The seat's key domain is question common; the stub mirrors the real
// lookup chain: package dictionary, then common vocabulary, then the key.
t: (key => (zh as Record<string, string>)[key]
?? (commonZh as Record<string, string>)[key]
?? key) as QuestionComposerProps['t'],
}
const QUESTIONS = [
@@ -14,6 +14,9 @@
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
+4 -1
View File
@@ -25,7 +25,8 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-layout"
"@deepseek-ai/dsh-client-ui-layout",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
},
@@ -38,6 +39,7 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
@@ -46,6 +48,7 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
@@ -32,6 +32,7 @@ export function SidebarRoot({
width,
startSession,
toggleSidebar,
t,
renderSlot,
}: SidebarRootComponentProps) {
// Wide content stays mounted while the collapse animates (fading via
@@ -67,7 +68,7 @@ export function SidebarRoot({
<button
type="button"
className={clsx(css.brand, css.wide)}
aria-label="New session"
aria-label={t('session.new.label')}
onClick={() => { startSession() }}
>
<BrandWordmark />
@@ -75,11 +76,11 @@ export function SidebarRoot({
)}
{/* Rail resting state is the whale mark; hovering swaps in the panel
icon (the expand affordance, figma sidebar-hover flow). */}
<Tooltip label="Open sidebar" disabled={wide}>
<Tooltip label={t('toggle.open')} disabled={wide}>
<button
type="button"
className={clsx(css.iconButton, css.toggle)}
aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
aria-label={collapsed ? t('toggle.open') : t('toggle.collapse')}
onClick={() => { toggleSidebar() }}
>
{!wide && <FishLogo className={css.railFish} size={24} />}
@@ -89,15 +90,15 @@ export function SidebarRoot({
</Tooltip>
</div>
<Tooltip label="New session" disabled={wide}>
<Tooltip label={t('session.new.label')} disabled={wide}>
<button
type="button"
className={css.newSession}
aria-label="New session"
aria-label={t('session.new.label')}
onClick={() => { startSession() }}
>
<IconNewChatOutline16 size={wide ? 14 : 18} />
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
</button>
</Tooltip>
@@ -6,7 +6,7 @@
* `sidebar.workspaces` registrant's (ui-workspace), and the foot is the
* `sidebar.settings` registrant's (ui-settings).
*/
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
// program that sees this contract, so PropsRuntime<'sidebar'> resolves.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
@@ -68,7 +68,9 @@ export type SidebarRootInjected = {
/**
* Full component props: layout owner state/actions plus the declared holes'
* render shares and this package's injected callbacks. No store is registered.
* render shares, this package's injected callbacks, and the standard locale
* seat. No store is registered.
*/
export type SidebarRootComponentProps =
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> & SidebarRootInjected
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'>
& SidebarRootInjected & PropsLocale<'sidebar'>
+18 -1
View File
@@ -1,17 +1,33 @@
/** Registers the sidebar shell into the layout-owned slot. */
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
import { en, zh, type SidebarKey } from './locales.ts'
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts'
export type { SidebarKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Sidebar shell controls copy. */
sidebar: SidebarKey
}
}
/** Dictionary namespace owned by this plugin (shell controls copy). */
const NS = 'sidebar'
/** Services required by the sidebar plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
/** Registers the sidebar shell and its service callbacks.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries')
const injectProps = (): SidebarRootInjected => ({
// The shell's New Session button rides the runtime's shared action
// (recent-Workspace targeting; explicit Workspace wins for scoped actions).
@@ -21,6 +37,7 @@ export function apply(ctx: ClientContext): void {
ctx.effect(
() => ctx.slots.register({
name: 'sidebar',
locale: NS,
// The shell owns geometry; ui-workspace registers the whole browsing
// region (header, search, session list, workspace dialogs), ui-settings
// registers the foot trigger + settings panel.
@@ -0,0 +1,20 @@
/** `sidebar` namespace dictionaries: shell controls (brand row, New Session, fold toggle). */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'session.new': '新会话',
'session.new.label': '新建会话',
'toggle.open': '打开侧边栏',
'toggle.collapse': '收起侧边栏',
} satisfies Record<string, string>
/** The sidebar namespace key union. */
export type SidebarKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'session.new': 'New Session',
'session.new.label': 'New session',
'toggle.open': 'Open sidebar',
'toggle.collapse': 'Collapse sidebar',
} satisfies Record<SidebarKey, string>
@@ -2,6 +2,7 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -14,6 +15,7 @@ async function bench(declare = true) {
ctx.provide('layout', layout)
ctx.provide('sessions', sessions as never)
ctx.provide('workspaces', workspaces as never)
ctx.provide('locale', new LocaleService(ctx))
const slots = ctx.get('slots') as SlotsService
if (declare) {
slots.register(
@@ -26,7 +28,7 @@ async function bench(declare = true) {
describe('ui-sidebar apply', () => {
it('declares only the services it uses', () => {
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale'])
})
it('registers the shell and declares the browsing-region hole', async () => {
@@ -34,6 +36,8 @@ describe('ui-sidebar apply', () => {
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('sidebar')).toHaveLength(1)
expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
// Copy rides the standard locale seat, not the inject face.
expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
// Both arms delegate to the runtime's shared New Session action.
@@ -3,6 +3,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
import { en } from '../src/client/locales.ts'
// English-dictionary translate stub: the shell renders the same copy the
// assertions below query by accessible name.
const t: SidebarRootComponentProps['t'] = key => (en as Record<string, string>)[key] ?? key
afterEach(() => {
cleanup()
@@ -23,7 +28,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
<SidebarRoot
collapsed={current.collapsed} width={current.width}
useSessions={neverHook} useWorkspaces={neverHook}
startSession={startSession} toggleSidebar={toggleSidebar}
startSession={startSession} toggleSidebar={toggleSidebar} t={t}
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
if (key === 'sidebar.settings') {
settingsOwner = owner
@@ -11,6 +11,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, waitFor } from '@testing-library/react'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
afterEach(cleanup)
@@ -18,6 +19,12 @@ afterEach(cleanup)
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { toggleSidebar: vi.fn() })
// English locale pins the snapshots to the copy they were recorded with;
// the installed face backs the entry's standard `t` seat.
const locale = new LocaleService(runtime.ctx)
locale.setLocale('en')
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } })
await runtime.mount({ inject: [...inject], apply })
return runtime
+3
View File
@@ -26,6 +26,9 @@
{
"path": "../ui-layout"
},
{
"path": "../locale"
},
{
"path": "../../support/invariants"
}
+88 -8
View File
@@ -24,6 +24,67 @@ export * from './deferred.ts'
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
export interface SlotMap {}
/**
* Locale namespace table. Dictionary owners extend via declaration merging
* (exactly like {@link SlotMap}, and declared in this entry module for the
* same lexical-merge reason): the key is the namespace string, the value is
* the union of its dictionary keys. Register sites declare one of these
* namespaces (`locale:`), which puts the typed `t` standard seat on the
* component props.
*/
export interface LocaleNamespaceMap {}
/**
* Translate a dictionary key with optional `{name}` template params.
* `K` narrows the accepted keys to the owning namespace's dictionary union
* (plus the shared common vocabulary where composed).
*/
export type Translate<K extends string = string> =
(key: K, params?: Record<string, unknown>) => string
/**
* The shared `common` vocabulary keys as merged by the locale plugin;
* resolves to `never` in programs without the merge (this package's tests),
* keeping the union collapse harmless.
*/
export type CommonKeyOf = LocaleNamespaceMap extends { common: infer C } ? C & string : never
/**
* Key domain of a namespace-bound translate: the namespace's own dictionary
* union plus the shared common vocabulary (the lookup chain consults common
* after the namespace misses).
*/
export type LocaleKeysOf<N extends keyof LocaleNamespaceMap & string> =
(LocaleNamespaceMap[N] & string) | CommonKeyOf
/**
* Namespace-addressed translate — the developer-facing alias over
* {@link Translate}: `TranslateNS<'model'>` is the translate function of the
* `model` namespace (key domain = its dictionary union plus the shared
* common vocabulary), the exact type of the framework-injected `t` seat and
* of the locale service's typed `bind`.
*/
export type TranslateNS<N extends keyof LocaleNamespaceMap & string> = Translate<LocaleKeysOf<N>>
/**
* Dictionary shape for a declared namespace: exactly the keys the namespace
* merged into {@link LocaleNamespaceMap} — a missing or extra key at a typed
* registration site is a compile error.
*/
export type LocaleDictOf<N extends keyof LocaleNamespaceMap & string> =
Record<LocaleNamespaceMap[N] & string, string>
/**
* Locale share of the composed component props: the framework-injected `t`
* seat, present exactly on entries whose registration declares `locale:`.
*/
export type PropsLocale<N> = N extends keyof LocaleNamespaceMap & string
? {
/** Translate a dictionary key of the declared namespace (or the shared common vocabulary). */
t: TranslateNS<N>
}
: object
/** Slot cardinality: single occupant, ordered list, key-dispatched, or selector-routed chain. */
export type SlotKind = 'single' | 'list' | 'keyed' | 'chain'
@@ -244,10 +305,11 @@ export type InjectFace<I extends object> =
I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
/**
* The four-share component props intersection: runtime share (SlotMap) +
* The composed component props intersection: runtime share (SlotMap) +
* child-render share (children declaration) + store share (declared handle) +
* the registrant's injected business face (its hooks compartment bound, see
* {@link InjectFace}). Each share derives from its single source of truth;
* {@link InjectFace}) + the locale `t` seat (declared namespace, see
* {@link PropsLocale}). Each share derives from its single source of truth;
* components reference this composition, never re-type it.
*/
export type ComposedProps<
@@ -256,7 +318,8 @@ export type ComposedProps<
H,
I extends object,
M = never,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
N = undefined,
> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M> & PropsLocale<N>
/**
* Inject factory parameter list, derived from the registration's declaration:
@@ -303,13 +366,20 @@ type RendersCheck<C, D> =
: unknown
/** Common register options share (see {@link SlotCore.register} for semantics). */
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never> = {
type BaseOptions<K extends keyof SlotMap & string, D extends ChildrenDecl, H, M = never, N = undefined> = {
/** Target slot key (the entry contributes INTO this slot). */
name: K
/** Child-slot declaration + render authorization + runtime spec, in one table. */
children?: D
/** Store seat: a shared handle (apply-constructed) or an exclusive factory (framework-called per entry x scope). */
store?: H
/**
* Dictionary namespace of this entry's copy. Declaring it puts the
* framework-synthesized `t` seat (typed to the namespace's dictionary
* union) on the component props; rendering requires an installed locale
* face — fails loud otherwise.
*/
locale?: N
/** Registrant identity label for diagnostics (the runtime Service wrapper stamps the caller's fiber name). */
registrant?: string
} & KindOptions<SlotMap[K], M>
@@ -330,6 +400,8 @@ export interface StoredEntry {
children?: Readonly<Record<string, SlotSpec<SlotEntryDef>>> | undefined
/** Declared store seat (instance resolution and lifecycle live with the host machinery). */
store?: StoreDecl | undefined
/** Declared dictionary namespace (the render machinery synthesizes the `t` seat from it). */
locale?: string | undefined
/** Diagnostics label of who registered. */
registrant?: string | undefined
}
@@ -350,6 +422,7 @@ interface ErasedOptions {
priority?: number | undefined
children?: Record<string, SlotSpec<SlotEntryDef>> | undefined
store?: StoreDecl | undefined
locale?: string | undefined
/* oxlint-disable-next-line typescript/no-explicit-any --
* implementation-signature position only (both public overloads type inject
* exactly); `never[]` would fail overload-to-implementation compatibility
@@ -427,16 +500,20 @@ export class SlotCore {
* @returns disposer removing the registration and its declarations
* (idempotent; stale disposers after a cascade are no-ops).
*/
/* jscpd:ignore-start -- the two register overloads are deliberately
* parallel declarations differing only in the inject share; folding them
* would lose the per-overload inference of I. */
register<
K extends keyof SlotMap & string,
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
M = never,
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H, M> & { inject?: undefined },
options: BaseOptions<K, D, H, M, N> & { inject?: undefined },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>>>
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, object, NoInfer<M>, NoInfer<N>>>
& RendersCheck<C, D>,
): () => void
/**
@@ -455,13 +532,15 @@ export class SlotCore {
const D extends ChildrenDecl = Record<never, never>,
H extends StoreDecl | undefined = undefined,
M = never,
N extends (keyof LocaleNamespaceMap & string) | undefined = undefined,
C extends SlotComponent<never> = SlotComponent<never>,
>(
options: BaseOptions<K, D, H, M> & { inject: (...args: InjectParams<K, H>) => I },
options: BaseOptions<K, D, H, M, N> & { inject: (...args: InjectParams<K, H>) => I },
component: C
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>>>
& SlotComponent<ComposedProps<K, keyof NoInfer<D> & keyof SlotMap & string, HandleOf<NoInfer<H>>, I, NoInfer<M>, NoInfer<N>>>
& RendersCheck<C, D>,
): () => void
/* jscpd:ignore-end */
register(options: ErasedOptions, component: unknown): () => void {
const rec = this.records.get(options.name)
if (!rec?.spec) {
@@ -523,6 +602,7 @@ export class SlotCore {
...(options.inject !== undefined ? { inject: options.inject } : {}),
...(options.children !== undefined ? { children: options.children } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.locale !== undefined ? { locale: options.locale } : {}),
...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
}
const next = [...rec.entries, entry]
+32 -1
View File
@@ -1,6 +1,31 @@
/** React-free contracts between the slot host and an installed renderer. */
import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts'
/**
* The locale face the render machinery consumes: namespace binding plus an
* observable revision (getSnapshot/subscribe pair — the same HostObservable
* currency as every other standard-kit source). The revision moves on every
* active-locale or registry change; the renderer re-derives each entry's `t`
* from (namespace, revision), so a locale switch hands out NEW function
* references and memoized components re-render naturally. Implemented by the
* locale plugin, installed through the runtime SlotsService (installLocale).
* Install before the first render that needs the seat: outlets bind their
* revision subscription at mount, and a face appearing later has no channel
* to notify already-mounted outlets (the locale plugin is immediately-tier
* infrastructure, so normal compositions install during boot).
*/
export interface LocaleFace extends HostObservable<{ revision: number }> {
/**
* Bind a namespace to a translate function reading the active locale at
* call time. Identity may be stable per namespace — freshness of rendered
* text is carried by the renderer's (ns, revision) seat derivation, not by
* this binding.
* @param ns - dictionary namespace.
* @returns the namespace-bound translate function.
*/
bind(ns: string): Translate
}
/** Minimal observable surface for host-provided standard-kit data sources. */
export interface HostObservable<T> {
@@ -128,6 +153,12 @@ export interface SlotRendererHost {
/** Workspace list source backing the useWorkspaces standard hook. */
list: HostObservable<unknown>
}
/**
* Installed locale face backing the `t` standard seat (absent until the
* locale plugin installs one; rendering an entry that declared `locale:`
* without it is an assembly failure).
*/
locale?: LocaleFace | undefined
}
/** The install seam: runtime owns install()/renderSlot(); web-react implements rendering. */
@@ -9,26 +9,26 @@ import clsx from 'clsx'
import {
IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ThemePreference } from './index.ts'
import type { ThemeKey } from './locales.ts'
import type {} from './settings-contract.ts'
import type { createAppearanceRowStore } from './settings-store.ts'
import css from './AppearanceRow.module.css'
/** Injected business face: namespace-bound translate + the preference write. */
/** Injected business face: the preference write (t rides the standard locale seat). */
export interface AppearanceRowInjected {
/** Translate a `settings.theme` dictionary key to the active-locale text. */
t: (key: string) => string
/** Switch the theme preference. */
setTheme: (id: ThemePreference) => void
}
/** Full component props: runtime share + store share + injected face. */
/** Full component props: runtime share + store share + locale seat + injected face. */
export type AppearanceRowComponentProps =
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>> & AppearanceRowInjected
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createAppearanceRowStore>>
& PropsLocale<'settings.theme'> & AppearanceRowInjected
/** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */
const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [
const CUBES: readonly { id: ThemePreference; labelKey: ThemeKey; Icon: typeof IconLightOutline16 }[] = [
{ id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 },
{ id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 },
{ id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 },
+11 -18
View File
@@ -14,13 +14,22 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { AppearanceRowInjected } from './AppearanceRow.tsx'
import { AppearanceRow } from './AppearanceRow.tsx'
import { createAppearanceRowStore } from './settings-store.ts'
import { en, zh, type ThemeKey } from './locales.ts'
export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
export type { AppearanceRowState } from './settings-store.ts'
export type { ThemeKey } from './locales.ts'
/** Namespace owning this feature's settings-row copy. */
export const SETTINGS_NS = 'settings.theme'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The Appearance settings row's copy. */
'settings.theme': ThemeKey
}
}
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
export type ThemeTokens = Record<string, string>
@@ -228,23 +237,7 @@ export function apply(ctx: ClientContext): void {
const theme = new ThemeService(ctx)
ctx.provide('theme', theme)
ctx.effect(() => {
const disposers = [
ctx.locale.register(SETTINGS_NS, 'zh', {
'appearance.title': '外观',
'appearance.light': '浅色',
'appearance.dark': '深色',
'appearance.system': '跟随系统',
}),
ctx.locale.register(SETTINGS_NS, 'en', {
'appearance.title': 'Appearance',
'appearance.light': 'Light',
'appearance.dark': 'Dark',
'appearance.system': 'System',
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-theme: settings row dictionaries')
ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries')
const store = createAppearanceRowStore()
let bound: BoundActions<typeof store> | undefined
@@ -258,7 +251,6 @@ export function apply(ctx: ClientContext): void {
// first render (the store's revision guard drops stale duplicates).
sync(theme.getTheme())
return {
t: ctx.locale.bind(SETTINGS_NS),
setTheme: (id) => { theme.setTheme(id) },
}
}
@@ -269,6 +261,7 @@ export function apply(ctx: ClientContext): void {
id: 'appearance',
order: 10,
store,
locale: SETTINGS_NS,
inject: injected,
}, AppearanceRow))
return () => { deferred.dispose() }
@@ -0,0 +1,20 @@
/** `settings.theme` namespace dictionaries (the Appearance row's copy). */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'appearance.title': '外观',
'appearance.light': '浅色',
'appearance.dark': '深色',
'appearance.system': '跟随系统',
} satisfies Record<string, string>
/** The settings.theme namespace key union. */
export type ThemeKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'appearance.title': 'Appearance',
'appearance.light': 'Light',
'appearance.dark': 'Dark',
'appearance.system': 'System',
} satisfies Record<ThemeKey, string>
+2 -1
View File
@@ -73,7 +73,8 @@ describe('ui-theme apply', () => {
const { instance, face } = faceOf(b.slots)
// The inject-time re-sync sealed the init window: the mirror is current.
expect(instance.getSnapshot().preference).toBe('dark')
expect(face.t('appearance.dark')).toBe('深色')
// Copy rides the standard locale seat: the entry declares the namespace.
expect(b.slots.entries(SLOT).find(e => e.component === AppearanceRow)!.locale).toBe(SETTINGS_NS)
face.setTheme('system')
expect(theme.getTheme().preference).toBe('system')
+85 -2
View File
@@ -5,8 +5,9 @@
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost,
type SlotScope, type StoredEntry, type Translate,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
@@ -159,6 +160,74 @@ function cachedSessionMaybeInject(
return props
}
/**
* Locale `t` seat bindings, cached per (face, namespace, revision). The
* revision is part of the cache key ON PURPOSE: a locale switch mints a NEW
* function reference per namespace, so `React.memo` components taking `t`
* re-render through ordinary shallow comparison — freshness rides identity,
* no extra invalidation channel. Within one revision the reference is stable
* (memoized children do not churn on unrelated re-renders).
*/
const localeSeatCache = new WeakMap<LocaleFace, Map<string, { revision: number; t: Translate }>>()
function localeSeat(face: LocaleFace, ns: string): Translate {
let perNs = localeSeatCache.get(face)
if (!perNs) {
perNs = new Map()
localeSeatCache.set(face, perNs)
}
const revision = face.getSnapshot().revision
const cached = perNs.get(ns)
if (cached && cached.revision === revision) return cached.t
const bound = face.bind(ns)
// Fresh wrapper per revision: bind() itself may return a stable reference.
const t: Translate = (key, params) => bound(key, params)
perNs.set(ns, { revision, t })
return t
}
const noopSubscribe = (): (() => void) => () => {}
const zeroRevision = (): number => 0
/**
* Per-face subscribe/getSnapshot closure pair. Cached by face identity: the
* face is one global source shared by every outlet, and uSES resubscribes
* whenever the subscribe reference changes — fresh closures per render would
* churn one unsubscribe/resubscribe pair per outlet per render.
*/
const localeSubscriptionCache = new WeakMap<LocaleFace, {
subscribe: (fn: () => void) => () => void
getRevision: () => number
}>()
function localeSubscription(face: LocaleFace): { subscribe: (fn: () => void) => () => void; getRevision: () => number } {
let cached = localeSubscriptionCache.get(face)
if (!cached) {
cached = {
subscribe: fn => face.subscribe(fn),
getRevision: () => face.getSnapshot().revision,
}
localeSubscriptionCache.set(face, cached)
}
return cached
}
/**
* Subscribe an outlet to the installed locale face's revision (0 while none
* is installed — exactly one uSES call either way, keeping hook order
* stable). Every outlet re-renders on a locale switch; entry bodies then
* re-derive their `t` seat at the new revision. The face must be installed
* before the first render that needs it — a face appearing later has no
* notification channel to already-mounted outlets.
*/
function useLocaleRevision(face: LocaleFace | undefined): number {
const subscription = face !== undefined ? localeSubscription(face) : undefined
return useSyncExternalStore(
subscription?.subscribe ?? noopSubscribe,
subscription?.getRevision ?? zeroRevision,
)
}
/**
* Entry-identity React keys for chain boundaries. A chain outlet renders ONE
* elected entry through an error boundary; without a key, a boundary that
@@ -242,6 +311,16 @@ function standardKit(
// reader, bound per provide bundle (cached by info identity).
kit['useProjection'] = projectionHook(info)
}
if (entry.locale !== undefined) {
const face = host.locale
// Loud assembly failure: locale is immediately-tier infrastructure; a
// declared namespace with no installed face is a miswired composition.
if (face === undefined) {
throw new SlotAssemblyError(
`entry declares locale namespace '${entry.locale}' but no locale face is installed (locale plugin missing from the composition?)`)
}
kit['t'] = localeSeat(face, entry.locale)
}
const store = scope === 'session-maybe' && info?.sessionId === undefined
? undefined
: host.storeOf(entry, info?.sessionId)
@@ -329,6 +408,9 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
fn => host.subscribe(slotKey, fn),
() => host.getVersion(slotKey),
)
// Locale revision tick: a locale switch re-renders every outlet, and entry
// bodies re-derive their `t` seat at the new revision (fresh identity).
useLocaleRevision(host.locale)
const sessionInfo = useSessionMaybeProvideInfo()
const spec = host.specOf(slotKey)
// Undeclared (or no-longer-declared) keys render empty: a declaring entry's
@@ -435,6 +517,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) {
fn => host.subscribe('root', fn),
() => host.getVersion('root'),
)
useLocaleRevision(host.locale)
const entry = host.entriesOf('root')[0]
if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
return (
+1 -1
View File
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/hooks/README.md
README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a
README.zh.md: 41024a8bd268550aa07401fd8b21c74db0914796
README.zh.md: 741300a9a390a8f254c01733e5326be84541a78d
+1 -1
View File
@@ -10,4 +10,4 @@ hooks 子系统让用户可以像使用 Claude Code 和 Codex 一样,在 agent
| `hooks-claude/` | Claude Code `hooks.json`settings 的桥接 | 插件 |
| `hooks-codex/` | Codex `hooks.json` 的桥接 | 插件 |
Codex 有意重新实现 Claude Code 协议的一个*子集*`hooks.json` 结构相同、5 个事件而非 CC 的众多事件、仅命令、仅正则表达式 matcher、没有 env/替换),因此 `hook-protocol` 负责真正相同的原语,每个桥接只负责不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。
Codex 有意重新实现 Claude Code 协议的一个*子集*`hooks.json` 结构相同、5 个事件而非 CC 的众多事件、仅命令、仅使用正则的 matcher、没有 env/替换),因此 `hook-protocol` 负责真正相同的原语,每个桥接只负责不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md
README.md: 10cfcdcbf819f318f2ccaf412ae04bba60812397
README.zh.md: 9862d4f332e0e82fb6479fcadf9376403fb9bef8
README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805
README.zh.md: 15a537b67677a401ab434a3e73af1973030780c0
+2 -3
View File
@@ -10,7 +10,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) |
|---|---|---|
| Matcher test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) |
| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic |
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
@@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
## Primitives
- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws).
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. The runtime predicate still contains an invalid pattern as a non-match, so a direct library caller cannot throw into the agent loop.
- **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge.
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
@@ -42,4 +42,3 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **`HookOutput.updatedInput` is parsed but not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts.
- **An invalid matcher regex matches nothing, silently** — `matchesMatcher` never throws; surfacing the error needs a diagnostic-returning variant or parse-time validation (`TODO(matcher-diagnostics)`).
+3 -4
View File
@@ -10,7 +10,7 @@ Claude CodeCodex hook 协议格式(wire format)的**共享核心**。它
| 关注点 | 此处(`dsh-hook-protocol` | 桥接(`dsh-hooks-claude` / `-codex` |
|---|---|---|
| Matcher 测试 | `matchesMatcher(pattern, query, mode)`:根据 `mode` 使用字面匹配或正则匹配 | 选择自身 `mode``claude` = 字面或正则,`codex` = 始终使用正则) |
| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于隔离的运行时匹配 | 选择自身 `mode``claude` = 字面或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 |
| 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.bash` 提供 stdin payload + env,再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** |
| 解码输出 | `parseHookOutput(exit, stdout, stderr)` → 中性 `HookOutput` | 将中性 `HookOutput` 映射到 seam 特定的类型化 Decision |
| 合并 N 个 hook | `mergeHookOutputs(outputs)` → 最严格的 `MergedHookOutcome` | (无) |
@@ -19,7 +19,7 @@ Claude CodeCodex hook 协议格式(wire format)的**共享核心**。它
## 原语
- **`matchesMatcher(matcher, query, mode)`**:缺失、`''``'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。无效正则不匹配任何内容(绝不抛出异常
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''``'*'` 时匹配全部;`claude` mode 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` mode 始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带字段,再用 `matcherDiagnostic` 拒绝事件实际使用的无效正则,并在注册任何钩子之前给出稳定诊断。运行时谓词仍会将无效 pattern 隔离为不匹配,因此直接调用本库不会向 agent loop(智能体循环)抛异常。
- **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin(当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env``dsh-bash` 受信任插件接口),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec)。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** 解码退出状态与结构化 stdout。退出码为 2 时,会以 stderr 内容阻止执行;其他失败不阻塞。匹配的 hook 特定权限决策会覆盖遗留顶层决策;事件判别字段不匹配或缺失只会抑制事件特定字段。顶层字段仍与事件无关,成功但非 JSON 的输出会留给桥接处理。
- **`mergeHookOutputs(outputs)`**:折叠在一个点上匹配的每个 hook 结果:权限优先级为 **deny > ask > allow**,从首个 `continue:false` 起,halt 状态保持不变,阻塞原因用 `\n\n` 连接,`additionalContext``systemMessages` 按顺序累积。
@@ -29,7 +29,7 @@ Claude CodeCodex hook 协议格式(wire format)的**共享核心**。它
通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md)`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。
Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse``PostToolUse``Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Noteagent 决策记录)
Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse``PostToolUse``Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。
## 模型体验
@@ -42,4 +42,3 @@ Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(
## 已知限制与暂缓事项
- **`HookOutput.updatedInput` 会被解析但不会应用**:输入改写是已暂缓的一致性设计问题(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md));当 hook 设置它时,桥接会记录 + 警告。完整契约见 `src/types.ts`
- **无效 matcher 正则会静默地不匹配任何内容**:`matchesMatcher` 绝不抛出异常;显示该错误需要返回诊断的变体或解析时验证(`TODO(matcher-diagnostics)`)。
+1 -1
View File
@@ -13,7 +13,7 @@ export type {
MatcherGroup,
MatcherMode,
} from './types.ts'
export { matchesMatcher } from './matcher.ts'
export { matcherDiagnostic, matchesMatcher } from './matcher.ts'
export { parseHookOutput } from './codec.ts'
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
export type { RunHookOptions, RunHookResult } from './runner.ts'
+31 -11
View File
@@ -2,7 +2,8 @@
* Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/
* pipe patterns as literal alternatives and other patterns as regex; Codex
* treats every non-empty pattern as an unanchored regex. Missing, empty, and
* `*` match all; invalid regexes silently match nothing.
* `*` match all. Runtime matching contains invalid regexes as non-matches;
* config parsers use {@link matcherDiagnostic} to reject them with a diagnostic.
* @module @deepseek-ai/dsh-hook-protocol/matcher
*/
@@ -16,10 +17,37 @@ function isMatchAll(matcher: string | undefined): boolean {
/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */
const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */
function compileRegex(pattern: string): RegExp | undefined {
try {
return new RegExp(pattern)
} catch (_syntaxError) {
// RegExp construction is the try's only operation, so malformed pattern
// syntax is the only expected failure.
return undefined
}
}
/**
* Validate one matcher before a bridge accepts its config group.
* @param matcher - configured pattern; match-all sentinels are valid.
* @param mode - dialect deciding whether a word-and-pipe pattern is literal.
* @returns `undefined` for a valid matcher, otherwise a stable diagnostic.
*/
export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined {
if (isMatchAll(matcher)) return undefined
const pattern = matcher as string
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) return undefined
return compileRegex(pattern) === undefined
? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`
: undefined
}
/**
* Whether `matcher` selects `query` under the given dialect. Claude literal
* patterns exact-match pipe-separated alternatives; all other patterns are
* unanchored regexes. Invalid regexes return `false` rather than throwing.
* unanchored regexes. Invalid regexes return `false` rather than throwing;
* bridge config parsers surface them through {@link matcherDiagnostic} before use.
* @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels.
* @param query - the candidate value (a tool name, a session source, …).
* @param mode - the dialect deciding literal-vs-regex interpretation of the pattern.
@@ -33,13 +61,5 @@ export function matchesMatcher(matcher: string | undefined, query: string, mode:
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) {
return pattern.split('|').includes(query)
}
try {
return new RegExp(pattern).test(query)
} catch {
// Invalid regex: a broken matcher selects nothing rather than throwing into
// the agent loop. This is silent — callers get `false`, indistinguishable
// from a genuine non-match, so a typo'd pattern quietly disables the matcher.
// Surfacing it needs a diagnostic-returning variant (TODO(matcher-diagnostics)).
return false
}
return compileRegex(pattern)?.test(query) ?? false
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
describe('matchesMatcher — match-all sentinels (both dialects)', () => {
for (const mode of ['claude', 'codex'] as const) {
@@ -56,3 +56,19 @@ describe('matchesMatcher — invalid regex is a non-match (never throws)', () =>
expect(matchesMatcher('[', 'x', 'codex')).toBe(false)
})
})
describe('matcherDiagnostic — parse-time diagnostics', () => {
it('accepts match-all sentinels, Claude literals, and valid regexes', () => {
expect(matcherDiagnostic(undefined, 'claude')).toBeUndefined()
expect(matcherDiagnostic('', 'codex')).toBeUndefined()
expect(matcherDiagnostic('*', 'codex')).toBeUndefined()
expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined()
expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined()
expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined()
})
it('returns a stable diagnostic for invalid regexes in either dialect', () => {
expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("')
expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["')
})
})
+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/hooks/hooks-claude/README.md
README.md: 24259c24ea35cd450f8ea27ca2cca423ed4406bd
README.zh.md: 0a7afc20eba02d59d293124936cb81aeba6d3f0f
README.md: 61c2d152dacdbec31bca015b94b9f2ac6d24c3aa
README.zh.md: 38509ab6e6f72bb62a6bed064257603f728812cb
+2 -2
View File
@@ -28,7 +28,7 @@ In a `cordis.yml`:
projectDir: .
```
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — including an invalid regex matcher on an event that consumes matchers, reported with its pattern and event — and the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
@@ -86,7 +86,7 @@ A blocked prompt sends no request and invalidates nothing. Denial, feedback, and
## Known Limitations and Deferred Work
- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is parsed but never dispatched. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events).
- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is ignored before group parsing, so an unsupported event cannot invalidate or register hooks. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events).
- **`SessionStart` is partial:** JSON `additionalContext` is consumed, but plain stdout context, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills`, and `CLAUDE_ENV_FILE` are unsupported. The hook runs detached, so context can miss the first request (`TODO(session-start-gating)`), and the payload omits current optional fields such as `model`, `agent_type`, and `session_title`.
- **`UserPromptSubmit` is partial:** blocking and JSON `additionalContext` work, but plain stdout context, `sessionTitle`, and `suppressOriginalPrompt` are unsupported. Unless overridden, the bridge also uses its 600-second default instead of Claude Code's event-specific 30-second command timeout.
- **`PreToolUse` is partial:** `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
+2 -2
View File
@@ -28,7 +28,7 @@ const config: Config = {
projectDir: .
```
配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理:桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent(智能体)停止)。只运行 shell 形式 `type: 'command'` hook`http``mcp_tool``prompt``agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`10 分钟,即 CC 默认值)。
配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理,其中包括实际消费 matcher 的事件所带的无效 matcher 正则(会报告其 pattern 与事件):桥接记录警告且不注册任何内容,而不是使启动崩溃(路径拼写错误不应使 agent(智能体)停止)。只运行 shell 形式 `type: 'command'` hook`http``mcp_tool``prompt``agent` hook 会被解析并跳过,同时记录警告。没有每 hook `timeout` 的 hook 会使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`10 分钟,即 CC 默认值)。
hook **本身**会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd``session/new.cwd`)作为 hook 进程工作目录,因此 hook 的 `pwd`/相对路径/marker 作用于用户项目树,而非服务器启动目录。
@@ -86,7 +86,7 @@ hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记
## 已知限制与暂缓事项
- **不支持的 hook 事件(Claude Code 当前 30 项中的 23 项):** `Setup``InstructionsLoaded``UserPromptExpansion``MessageDisplay``PermissionRequest``PostToolUseFailure``PostToolBatch``PermissionDenied``Notification``TaskCreated``TaskCompleted``StopFailure``TeammateIdle``ConfigChange``CwdChanged``FileChanged``WorktreeCreate``WorktreeRemove``PreCompact``PostCompact``SessionEnd``Elicitation``ElicitationResult`。这些事件的配置会被解析,但绝不分派。比较基线是 Claude Code [官方 hook 事件参考](https://code.claude.com/docs/en/hooks#hook-events)。
- **不支持的 hook 事件(Claude Code 当前 30 项中的 23 项):** `Setup``InstructionsLoaded``UserPromptExpansion``MessageDisplay``PermissionRequest``PostToolUseFailure``PostToolBatch``PermissionDenied``Notification``TaskCreated``TaskCompleted``StopFailure``TeammateIdle``ConfigChange``CwdChanged``FileChanged``WorktreeCreate``WorktreeRemove``PreCompact``PostCompact``SessionEnd``Elicitation``ElicitationResult`。这些事件的配置会在配置组解析前被忽略,因此不支持的事件既不会使配置失效,也不会注册 hook。比较基线是 Claude Code [官方 hook 事件参考](https://code.claude.com/docs/en/hooks#hook-events)。
- **`SessionStart` 只支持部分功能:** 会消费 JSON `additionalContext`,但不支持纯 stdout 上下文、`initialUserMessage``sessionTitle``watchPaths``reloadSkills``CLAUDE_ENV_FILE`。hook 脱离运行,因此上下文可能错过第一个请求(`TODO(session-start-gating)`),payload 会省略 `model``agent_type``session_title` 等当前可选字段。
- **`UserPromptSubmit` 只支持部分功能:** 支持阻塞与 JSON `additionalContext`,但不支持纯 stdout 上下文、`sessionTitle``suppressOriginalPrompt`。除非被覆盖,否则桥接还会使用自身 600 秒默认值,而非 Claude Code 的事件特定 30 秒 command 超时。
- **`PreToolUse` 只支持部分功能:** `deny``ask` 决策可用;`allow` 不会预审批,不支持 `defer``additionalContext` 会被忽略,`updatedInput` 会被记录 + 警告但不应用(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md))。
+24 -5
View File
@@ -6,7 +6,17 @@
* @module @deepseek-ai/dsh-hooks-claude/config
*/
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
const CLAUDE_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PostToolUse',
'Stop',
'SubagentStart',
'SubagentStop',
] as const
/** A parsed CC config: event name → its matcher groups (command hooks only). */
export type ClaudeHookConfig = Record<string, MatcherGroup[]>
@@ -53,8 +63,11 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
/**
* Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are
* ignored rather than failing boot; non-command hooks are returned in `skipped`, and substitutions
* are applied to every surviving command.
* ignored rather than failing boot; unsupported events are ignored before their groups are parsed,
* non-command hooks are returned in `skipped`, and substitutions are applied to every surviving
* command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no
* matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a
* `SyntaxError`, allowing the bridge to reject the complete config before listener registration.
*
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
* event map.
@@ -70,7 +83,8 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa
const hooksMap = root ? asObject(root.hooks) ?? root : undefined
if (!hooksMap) return { config, skipped }
for (const [event, rawGroups] of Object.entries(hooksMap)) {
for (const event of CLAUDE_EVENTS) {
const rawGroups = hooksMap[event]
if (!Array.isArray(rawGroups)) continue
const groups: MatcherGroup[] = []
for (const rawGroup of rawGroups) {
@@ -92,8 +106,13 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa
})
}
if (commands.length === 0) continue
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
? undefined
: typeof group.matcher === 'string' ? group.matcher : undefined
const diagnostic = matcherDiagnostic(matcher, 'claude')
if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
groups.push({
...typeof group.matcher === 'string' ? { matcher: group.matcher } : {},
...matcher !== undefined ? { matcher } : {},
hooks: commands,
})
}
@@ -45,17 +45,22 @@ function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): stri
return dir
}
async function harness(configDir: string, adapter: MockAdapter): Promise<Context> {
return (await harnessWithFiber(configDir, adapter)).ctx
async function harness(configDir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
return (await harnessWithFiber(configDir, adapter, beforeHooks)).ctx
}
/** {@link harness}, also exposing the bridge's fiber for tests that dispose it. */
async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promise<{ ctx: Context; hooks: Fiber }> {
async function harnessWithFiber(
configDir: string,
adapter: MockAdapter,
beforeHooks?: (ctx: Context) => void,
): Promise<{ ctx: Context; hooks: Fiber }> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
beforeHooks?.(ctx)
const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
return { ctx, hooks }
@@ -85,13 +90,14 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
describe('hooks-claude bridge — UserPromptSubmit', () => {
it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => {
// The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr.
// UserPromptSubmit ignores its malformed matcher field, then exit 2 blocks
// with the reason on stderr.
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
const block = join(dir, 'block.sh')
writeFileSync(block, '#!/usr/bin/env bash\necho "prompt denied by policy" >&2\nexit 2\n')
chmodSync(block, 0o755)
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: block }] }] } }))
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: block }] }] } }))
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(dir, adapter)
@@ -361,6 +367,42 @@ describe('hooks-claude bridge — load resilience', () => {
expect(adapter.requests).toHaveLength(1)
})
it('an invalid regex matcher is reported and registers no hooks', async () => {
const dir = writeConfig({
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 2' }] }],
})
const adapter = new MockAdapter([textResponse('fine')])
const warn = vi.fn()
const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
const agent = ctx.agentLoop.create(SessionId('invalid-claude-matcher'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false)
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
'invalid claude regex matcher "(" on event "PreToolUse"',
))
})
it('an invalid matcher on an unsupported event does not disable supported hooks', async () => {
const dir = writeConfig({
Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'exit 0' }] }],
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
})
const adapter = new MockAdapter([textResponse('should not run')])
const warn = vi.fn()
const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
const agent = ctx.agentLoop.create(SessionId('unsupported-claude-matcher'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('invalid claude regex matcher'))
})
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it
// would veto the prompt (0 model requests) and log a hook/invoked. Build the
@@ -63,4 +63,33 @@ describe('parseClaudeConfig', () => {
const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 's.sh' }] }] })
expect('matcher' in config.Stop![0]!).toBe(false)
})
it('rejects an invalid regex matcher with its event name', () => {
expect(() => parseClaudeConfig({
PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }],
})).toThrow('invalid claude regex matcher "(" on event "PreToolUse"')
})
it('discards matcher fields on events without matcher subjects before validation', () => {
const { config } = parseClaudeConfig({
UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }],
Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }],
})
expect(config).toEqual({
UserPromptSubmit: [{ hooks: [{ command: 'prompt.sh' }] }],
Stop: [{ hooks: [{ command: 'stop.sh' }] }],
})
})
it('ignores invalid matchers on unsupported events without dropping supported hooks', () => {
const { config } = parseClaudeConfig({
Setup: [{ matcher: '(', hooks: [{ type: 'command', command: 'ignored.sh' }] }],
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'kept.sh' }] }],
})
expect(config).toEqual({
PreToolUse: [{ matcher: 'Bash', hooks: [{ command: 'kept.sh' }] }],
})
})
})
+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/hooks/hooks-codex/README.md
README.md: fd57762c6fb91e0ea47ec57c30bf9850bc488a33
README.zh.md: 58b387e22f0e56770e4ea779f184c6c82a38ff98
README.md: e906810ed58c3d0204c618c32787af06c91cfb78
README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334
+1 -1
View File
@@ -34,7 +34,7 @@ In a `cordis.yml`:
model: deepseek-v4
```
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing); an invalid regex matcher on an event that consumes matchers is one such failure and reports its pattern and event. Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
+2 -2
View File
@@ -7,7 +7,7 @@
该桥接实现 Codex 当前 hook 协议的一个明确子集:
- **10 个 hook 点中的 5 个:** `PreToolUse``PostToolUse``SessionStart``UserPromptSubmit``Stop`
- **使用正则 matcher**(没有字面快速路径;matcher 始终是未锚定正则)。
- **使用正则 matcher**(没有字面快速路径;matcher 始终是未锚定正则)。
- **snake_case stdin payload**,携带 `turn_id``model` 额外字段,写入时**不带**尾随换行符。
- **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。
- **没有工具前审批或改写路径**:hook 可以阻塞,但桥接不会预审批或替换工具输入。
@@ -34,7 +34,7 @@ const config: Config = {
model: deepseek-v4
```
配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理(记录 + 不注册任何内容)。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout``timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理(记录 + 不注册任何内容);实际消费 matcher 的事件所带的无效 matcher 正则属于此类失败,并报告其 pattern 与事件。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout``timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
hook 本身会在 agent(智能体)的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd` 作为 hook 进程工作目录,因此 hook 作用于用户项目树,而非服务器启动目录。
+11 -3
View File
@@ -5,7 +5,7 @@
* @module @deepseek-ai/dsh-hooks-codex/config
*/
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
/** The five Codex hook points this bridge supports. */
export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const
@@ -33,7 +33,10 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
/**
* Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather
* than failing boot; unsupported or asynchronous hooks are returned in `skipped`.
* than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on
* UserPromptSubmit and Stop are discarded because those events have no matcher subject. A
* matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge
* to reject the complete config before listener registration.
* @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
* @returns the runnable per-event groups plus the skipped hooks with their reasons.
*/
@@ -69,7 +72,12 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} })
}
if (commands.length === 0) continue
groups.push({ ...typeof group.matcher === 'string' ? { matcher: group.matcher } : {}, hooks: commands })
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
? undefined
: typeof group.matcher === 'string' ? group.matcher : undefined
const diagnostic = matcherDiagnostic(matcher, 'codex')
if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands })
}
if (groups.length > 0) config[event] = groups
}
@@ -39,12 +39,13 @@ function writeHooks(dir: string, hooks: unknown): void {
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks }))
}
async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
beforeHooks?.(ctx)
await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
@@ -88,11 +89,11 @@ describe('hooks-codex bridge', () => {
it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => {
const dir = configDir()
// Block once with a marker; until the loop guard lands, an always-blocking
// hook would never let this test finish.
// Stop ignores its malformed matcher field. Block once with a marker;
// until the loop guard lands, an always-blocking hook would never finish.
const marker = join(dir, 'fired')
const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`)
writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] })
writeHooks(dir, { Stop: [{ matcher: '[', hooks: [{ type: 'command', command: cont }] }] })
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
const ctx = await harness(dir, adapter)
@@ -151,6 +152,26 @@ describe('hooks-codex bridge', () => {
expect(adapter.requests).toHaveLength(1)
})
it('an invalid regex matcher is reported and registers no hooks', async () => {
const dir = configDir()
writeHooks(dir, {
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }],
})
const adapter = new MockAdapter([textResponse('ok')])
const warn = vi.fn()
const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
const agent = ctx.agentLoop.create(SessionId('invalid-codex-matcher'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false)
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
'invalid codex regex matcher "[" on event "PreToolUse"',
))
})
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
const dir = configDir()
// A leaked listener would let this blocking hook veto the prompt and log an invocation; a
@@ -65,4 +65,22 @@ describe('parseCodexConfig', () => {
const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] })
expect(config.PreToolUse![0]!.matcher).toBe('^Bash$')
})
it('rejects an invalid regex matcher with its event name', () => {
expect(() => parseCodexConfig({
PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 's.sh' }] }],
})).toThrow('invalid codex regex matcher "[" on event "PreToolUse"')
})
it('discards matcher fields on events without matcher subjects before validation', () => {
const { config } = parseCodexConfig({
UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }],
Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }],
})
expect(config).toEqual({
UserPromptSubmit: [{ hooks: [{ command: 'prompt.sh' }] }],
Stop: [{ hooks: [{ command: 'stop.sh' }] }],
})
})
})
+9
View File
@@ -1172,6 +1172,9 @@ importers:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
@@ -1366,6 +1369,9 @@ importers:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -1452,6 +1458,9 @@ importers:
specifier: ^2.0.0
version: 2.1.1
devDependencies:
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime