From 3f71f91d5b71d73f2f9532e9e7592c43d26d0cdb Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:43:51 -0700 Subject: [PATCH 01/31] fix(hooks): reject invalid matcher regexes --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 4 +- .../2026-06-30-hook-protocol-lib.zh.md | 4 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 5 +-- packages/hooks/hook-protocol/README.zh.md | 5 +-- packages/hooks/hook-protocol/src/index.ts | 2 +- packages/hooks/hook-protocol/src/matcher.ts | 40 ++++++++++++++----- .../hooks/hook-protocol/tests/matcher.spec.ts | 18 ++++++++- packages/hooks/hooks-claude/README.i18n.yaml | 4 +- packages/hooks/hooks-claude/README.md | 2 +- packages/hooks/hooks-claude/README.zh.md | 2 +- packages/hooks/hooks-claude/src/config.ts | 10 +++-- .../hooks/hooks-claude/tests/bridge.spec.ts | 30 ++++++++++++-- .../hooks/hooks-claude/tests/config.spec.ts | 6 +++ packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/README.zh.md | 2 +- packages/hooks/hooks-codex/src/config.ts | 11 +++-- .../hooks/hooks-codex/tests/bridge.spec.ts | 23 ++++++++++- .../hooks/hooks-codex/tests/config.spec.ts | 6 +++ 21 files changed, 143 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 260ea57905..cbbfcc96c9 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: fd3fbe6d0332210a4bf4fe49fecf4bb7b656ec78 +2026-06-30-hook-protocol-lib.zh.md: dacd7f341901c5003d6542fac70b46ccb49d5968 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 33ec23dd4f..fd3fbe6d03 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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 validates runnable matcher groups while parsing and treats an invalid regex 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 real load path and pin invalid-config containment. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 8e8c89a4ec..dacd7f3419 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -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_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件在解析时校验可运行的 matcher group,将无效正则视为整份配置加载失败,输出稳定的方言/模式/事件诊断,且不注册任何钩子监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 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 解析 stdout;exit `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% 覆盖率;桥接插件测试验证库的真实加载路径,并锁定无效配置的隔离行为。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index f74f1bb3a8..3869ef696c 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: f6fd30c968f68faa46d7ea07188cb22ee5ef3afe +README.md: 92b5e146c7da3531246da627884143991c76932b +README.zh.md: c49b7e75848f9bc736492b66d1126aa93287ace4 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 10cfcdcbf8..92b5e146c7 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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 use `matcherDiagnostic` to reject an invalid 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)`). diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index f6fd30c968..c49b7e7584 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 | 关注点 | 此处(`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 Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。无效正则不匹配任何内容(绝不抛出异常)。 +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。桥接解析器使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝无效正则,并输出稳定诊断。运行时谓词仍将无效 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` 按顺序累积。 @@ -42,4 +42,3 @@ Hook 溯源记录必须位于开启轮次内。轮次中点(`PreToolUse`/`Po ## 已知限制与暂缓事项 - **`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)`)。 diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index e342665057..d67746f824 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -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' diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 036954a59c..ca3a867418 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -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,35 @@ 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 { + 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 +59,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 } diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index 37e2acb137..a1f794aa28 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -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 "["') + }) +}) diff --git a/packages/hooks/hooks-claude/README.i18n.yaml b/packages/hooks/hooks-claude/README.i18n.yaml index c4d7c1bdc9..6aa1c25d44 100644 --- a/packages/hooks/hooks-claude/README.i18n.yaml +++ b/packages/hooks/hooks-claude/README.i18n.yaml @@ -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: 9f58b782190721de08750e5bd4eac9e5effd5c6a +README.md: 8bdce8555b4b1919bdeebf02cbf35f7c60a3e1ff +README.zh.md: 4cceffb364b80b61686561a89b0b2a0161d16566 diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 24259c24ea..8bdce8555b 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -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, 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. diff --git a/packages/hooks/hooks-claude/README.zh.md b/packages/hooks/hooks-claude/README.zh.md index 9f58b78219..4cceffb364 100644 --- a/packages/hooks/hooks-claude/README.zh.md +++ b/packages/hooks/hooks-claude/README.zh.md @@ -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 正则(报告其 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 作用于 user 项目树,而非服务器启动目录。 diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 3797d4e56f..2f66a10a01 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -6,7 +6,7 @@ * @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' /** A parsed CC config: event name → its matcher groups (command hooks only). */ export type ClaudeHookConfig = Record @@ -54,7 +54,8 @@ 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. + * are applied to every surviving command. A runnable group with an invalid regex matcher 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. @@ -92,8 +93,11 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa }) } if (commands.length === 0) continue + const matcher = 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, }) } diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 5b55261b14..c2d19f351f 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -44,17 +44,22 @@ function writeConfig(hooks: unknown, scripts: Record = {}): stri return dir } -async function harness(configDir: string, adapter: MockAdapter): Promise { - return (await harnessWithFiber(configDir, adapter)).ctx +async function harness(configDir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise { + 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 } @@ -360,6 +365,25 @@ 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({ 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('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 diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts index f635ef0fd9..5be9947b8b 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -63,4 +63,10 @@ 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"') + }) }) diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index bb102c814b..ba274e13e3 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -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: 367d6acd0fec486cb0f9fb50023ad2ed4cca7217 +README.md: 62a9599b17a816205f91cee8ba6ce7eab1852681 +README.zh.md: 813c8ca1def904c3f6b79472ecc785ff316606d6 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index fd57762c6f..62a9599b17 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -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 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. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 367d6acd0f..813c8ca1de 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -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 正则属于此类失败,并报告其 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 作用于 user 项目树,而非服务器启动目录。 diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index e602ddb20c..97e1f23bd8 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -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,9 @@ function asObject(value: unknown): Record | 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`. A runnable group + * with an invalid regex matcher 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 +71,10 @@ 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 = 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 } diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 97e64cb7b0..95243d9dee 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -38,12 +38,13 @@ function writeHooks(dir: string, hooks: unknown): void { writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks })) } -async function harness(dir: string, adapter: MockAdapter): Promise { +async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise { 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 @@ -150,6 +151,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' }] }], + Stop: [{ 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({ 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 "Stop"', + )) + }) + 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 diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index 09bce12a43..a3a5bea827 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -65,4 +65,10 @@ 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({ + Stop: [{ matcher: '[', hooks: [{ type: 'command', command: 's.sh' }] }], + })).toThrow('invalid codex regex matcher "[" on event "Stop"') + }) }) From 5e4c2ffae741f5d16654e5fc0bbfc5c5d7aa4330 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:58:08 -0700 Subject: [PATCH 02/31] test(hooks): snapshot invalid matcher loading --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 ++-- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 5 +++++ .../hook-cc-invalid-matcher/input.json | 7 +++++++ .../hook-cc-invalid-matcher/session.jsonl | 18 ++++++++++++++++++ .../stdout.expected.jsonl | 4 ++++ .../workspace/hooks.json | 19 +++++++++++++++++++ .../hook-codex-invalid-matcher/input.json | 7 +++++++ .../hook-codex-invalid-matcher/session.jsonl | 18 ++++++++++++++++++ .../stdout.expected.jsonl | 4 ++++ .../workspace/codex-hooks.json | 19 +++++++++++++++++++ 12 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/input.json create mode 100644 examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/workspace/hooks.json create mode 100644 examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/input.json create mode 100644 examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/workspace/codex-hooks.json diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index cbbfcc96c9..6f5db5b86a 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: fd3fbe6d0332210a4bf4fe49fecf4bb7b656ec78 -2026-06-30-hook-protocol-lib.zh.md: dacd7f341901c5003d6542fac70b46ccb49d5968 +2026-06-30-hook-protocol-lib.md: 07bcd23e5ef944e37237586a402b3cfb8d293a62 +2026-06-30-hook-protocol-lib.zh.md: 00573004efdb1dca2d60404fc4e9ae2dd916923a diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index fd3fbe6d03..07bcd23e5e 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -29,4 +29,4 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo ## Consequences -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 real load path and pin invalid-config containment. `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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index dacd7f3419..00573004ef 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -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)落地。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 9987679607..49c954151b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -207,6 +207,11 @@ const SCENARIOS: Scenario[] = [ // 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 diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/input.json b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl new file mode 100644 index 0000000000..6c4e1d2a49 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","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"}},"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,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"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"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl @@ -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"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/workspace/hooks.json new file mode 100644 index 0000000000..ddb3eb4659 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/workspace/hooks.json @@ -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" } + ] + } + ] + } +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/input.json b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl new file mode 100644 index 0000000000..6c4e1d2a49 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","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"}},"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,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"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"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl @@ -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"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/workspace/codex-hooks.json new file mode 100644 index 0000000000..ddb3eb4659 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/workspace/codex-hooks.json @@ -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" } + ] + } + ] + } +} From 7dc6b058a950345ea16a505bb25256bbc8421a60 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:18:03 -0700 Subject: [PATCH 03/31] fix(hooks): ignore unsupported Claude events --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 ++-- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- packages/hooks/hooks-claude/README.i18n.yaml | 4 ++-- packages/hooks/hooks-claude/README.md | 2 +- packages/hooks/hooks-claude/README.zh.md | 2 +- packages/hooks/hooks-claude/src/config.ts | 20 +++++++++++++++---- .../hooks/hooks-claude/tests/bridge.spec.ts | 17 ++++++++++++++++ .../hooks/hooks-claude/tests/config.spec.ts | 11 ++++++++++ 9 files changed, 52 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 6f5db5b86a..4413405235 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: 07bcd23e5ef944e37237586a402b3cfb8d293a62 -2026-06-30-hook-protocol-lib.zh.md: 00573004efdb1dca2d60404fc4e9ae2dd916923a +2026-06-30-hook-protocol-lib.md: 11986c01f76c7b3cc7eb5ebe9627dc0ded8afd95 +2026-06-30-hook-protocol-lib.zh.md: 6abccabf517b69b642cd5db52281e4e8a8d526c7 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 07bcd23e5e..11986c01f7 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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** — `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 validates runnable matcher groups while parsing and treats an invalid regex 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. +- **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, validates runnable groups for supported events, 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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 00573004ef..6abccabf51 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件在解析时校验可运行的 matcher group,将无效正则视为整份配置加载失败,输出稳定的方言/模式/事件诊断,且不注册任何钩子监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 +- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,仅校验受支持事件中可运行的 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 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 解析 stdout;exit `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 按序累积。 diff --git a/packages/hooks/hooks-claude/README.i18n.yaml b/packages/hooks/hooks-claude/README.i18n.yaml index 6aa1c25d44..515aba4a0b 100644 --- a/packages/hooks/hooks-claude/README.i18n.yaml +++ b/packages/hooks/hooks-claude/README.i18n.yaml @@ -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: 8bdce8555b4b1919bdeebf02cbf35f7c60a3e1ff -README.zh.md: 4cceffb364b80b61686561a89b0b2a0161d16566 +README.md: 413159759dc76478beeb65c8e380df77c0a26e86 +README.zh.md: 43c0b4644891a14e832ef35db7ffe11f11a4e545 diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 8bdce8555b..413159759d 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -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)). diff --git a/packages/hooks/hooks-claude/README.zh.md b/packages/hooks/hooks-claude/README.zh.md index 4cceffb364..43c0b46448 100644 --- a/packages/hooks/hooks-claude/README.zh.md +++ b/packages/hooks/hooks-claude/README.zh.md @@ -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`。这些事件的配置会在 group 解析前忽略,因此不支持的事件既不会使配置失效,也不会注册 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))。 diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 2f66a10a01..aed4cab729 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -8,6 +8,16 @@ 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 @@ -53,9 +63,10 @@ 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. A runnable group with an invalid regex matcher throws a - * `SyntaxError`, allowing the bridge to reject the complete config before listener registration. + * 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. A supported runnable group with an invalid regex matcher 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. @@ -71,7 +82,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) { diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index c2d19f351f..87c5ce3ee2 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -384,6 +384,23 @@ describe('hooks-claude bridge — load resilience', () => { )) }) + 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({ 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 diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts index 5be9947b8b..d9713e998a 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -69,4 +69,15 @@ describe('parseClaudeConfig', () => { PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }], })).toThrow('invalid claude regex matcher "(" on event "PreToolUse"') }) + + 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' }] }], + }) + }) }) From f688cd32aff6b16de611419f0d50002940b9b8d9 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:10:45 -0700 Subject: [PATCH 04/31] fix(workspace-context): escape instruction metadata --- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 2 +- .../2026-06-24-workspace-context.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 22 +++++++++-- .../snapshots/workspace-context/input.json | 2 +- .../workspace-context/replay.override.json | 10 +++++ .../snapshots/workspace-context/session.jsonl | 39 ++++++++++++------- .../workspace-context/README.i18n.yaml | 4 +- packages/context/workspace-context/README.md | 2 +- .../context/workspace-context/README.zh.md | 2 +- .../context/workspace-context/src/render.ts | 21 +++++----- .../tests/workspace-context.spec.ts | 33 +++++++++++++++- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 8 ++++ packages/support/acp-snapshot/src/suite.ts | 7 ++++ .../acp-snapshot/tests/harness.spec.ts | 26 ++++++++++++- .../support/acp-snapshot/tests/suite.spec.ts | 3 ++ 19 files changed, 151 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 8e25425874..073aa9fa4b 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml @@ -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-24-workspace-context.md -2026-06-24-workspace-context.md: f86e227be615c9b54e2a9013d3c7dca75d3975f0 -2026-06-24-workspace-context.zh.md: 154b5260955570e2de3c88d98286c5ea6afaa3b5 +2026-06-24-workspace-context.md: 8baced0143abb38ff34d16a072761ec016a53d6e +2026-06-24-workspace-context.zh.md: 392d57f344b97c1816f691fef75440f815bccb50 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index f86e227be6..8baced0143 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -34,7 +34,7 @@ The injection becomes a durable `user/message` with a typed `workspace-instructi A resumed agent creates a new loop instance and injects a baseline composed from current files before its first request. This permits current baseline content on resume without mutating an earlier history event. A resume and a hot plugin remount both face a log that may already hold a baseline; they are told apart by `agent/session-start`, which a startup or resume emits before the first step while a remount attaches to an already-live session and never sees it. A remount retains the existing baseline only when its typed event remains in the current visible surface, and still rebuilds scope and provider-version tracking from current files. If compaction has shadowed that event, the remount injects a current baseline. A resume always re-composes. -The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. A literal `` inside file content is escaped. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). +The baseline is a user-role `` with `Instructions from: ` sections and explicit authority and precedence language. This familiar model-facing frame avoids a harness-specific XML vocabulary. Project paths are root-relative and the user-global path is `~/.dsh/AGENTS.md` for the default home or `$DSH_HOME/AGENTS.md` for a configured home. The final rendering boundary escapes a literal `` anywhere in instruction content or model-visible path, scope, and budget metadata before byte accounting completes. The package README owns the exact current [prompt shape](../../../../packages/context/workspace-context/README.md#prompt-shape). ### Dynamic Discovery And Refresh diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 154b526095..392d57f344 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -34,7 +34,7 @@ Status: implemented 恢复 agent 会创建新的循环实例,并在其第一次请求前注入由当前文件组合的基线。这样,恢复时可以使用当前基线内容,而无需修改先前的历史事件。恢复与插件热重挂都会面对日志中可能已存在基线的情况;二者通过 `agent/session-start` 区分:启动或恢复会在第一步前发出该事件,而热重挂附着到一个已存活的会话、永远不会看到它。只有当基线的类型化事件仍在当前可见表层中时,热重挂才保留既有基线,同时仍会根据当前文件重建 scope 与提供方版本跟踪。如果压缩(compaction)已遮蔽该事件,热重挂会注入当前基线。恢复则始终重新组合。 -基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。文件内容中的字面量 `` 会被转义。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 +基线是一条 user 角色的 ``,包含 `Instructions from: ` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。最终渲染边界会在完成字节核算前,转义指令内容或模型可见的路径、scope 与预算元数据中出现的字面量 ``。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。 ### 动态发现与刷新 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index a6c91bd5cd..248a5ac88e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { homedir } from 'node:os' import { expect, it } from 'vitest' @@ -45,6 +46,15 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' +async function prepareDelimiterPathWorkspace(cwd: string): Promise { + const dir = join(cwd, 'scope') + await mkdir(dir, { recursive: true }) + await Promise.all([ + writeFile(join(dir, 'AGENTS.md'), 'Delimiter path snapshot instruction.\n'), + writeFile(join(dir, 'task.txt'), 'delimiter path snapshot task\n'), + ]) +} + // FIXME: Migrate backend-oriented scenarios to the headless stream-json suite; // this ACP suite should eventually retain only automation-protocol contracts. @@ -152,11 +162,13 @@ const SCENARIOS: Scenario[] = [ { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false }, // Authored replay: a root AGENTS.md pins the session prefix, then a read in // nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing - // injected user/message. Both AGENTS.md fixtures are symlinks to a sibling + // injected user/message. Both portable AGENTS.md fixtures are symlinks to a sibling // AGENTS.canonical.md, so this scenario also guards that discovery follows a - // symlinked instruction file to its target's content. The scenario-specific - // config keeps home/root discovery hermetic, and the resulting prefix needs - // its own pinned header class. + // symlinked instruction file to its target's content. A second nested path + // containing a literal closing tag is created at runtime: Git cannot check + // that name out on Windows, so this delimiter-injection case is POSIX-only. + // The scenario-specific config keeps home/root discovery hermetic, and the + // resulting prefix needs its own pinned header class. { name: 'workspace-context', hasModelTurn: true, @@ -165,6 +177,8 @@ const SCENARIOS: Scenario[] = [ pinsHeader: true, headerClass: 'workspace-context', configPath: WORKSPACE_CONTEXT_CONFIG, + prepareWorkspace: prepareDelimiterPathWorkspace, + posixOnly: true, }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, // Cancelling a live bash call relies on POSIX process-group termination; diff --git a/examples/acp-agent/tests/snapshots/workspace-context/input.json b/examples/acp-agent/tests/snapshots/workspace-context/input.json index 94fd9dae92..ea1e0cd190 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/input.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Read nested/task.txt with the read tool, then reply DONE." } + { "op": "prompt", "text": "Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE." } ] } diff --git a/examples/acp-agent/tests/snapshots/workspace-context/replay.override.json b/examples/acp-agent/tests/snapshots/workspace-context/replay.override.json index ef70491338..a8ba5d718f 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/replay.override.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/replay.override.json @@ -9,6 +9,16 @@ { "type": "finish", "reason": { "kind": "tool-calls" } } ] }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_workspace_delimiter_read", "name": "read", "argumentsDelta": "{\"file_path\":\"scope/task.txt\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_workspace_delimiter_read", "name": "read", "arguments": "{\"file_path\":\"scope/task.txt\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, { "kind": "chunks", "chunks": [ diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl index da3bbd7ad7..e5194f54b1 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl @@ -1,8 +1,8 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783778297065,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"7bee8c9d-684e-42e2-a906-54479a4360c0"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt with the read","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1784903339799,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"b3f5afcf-3483-4f42-95db-cca54076be3d"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783778297066,"data":{"content":[{"type":"text","text":"Read nested/task.txt, then read scope/task.txt with the read tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"b1792d71-b916-463d-9ef0-b349e37d914d"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1783778297066,"data":{"title":"Read nested/task.txt, then read scope\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nRoot snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","baseline":true,"changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"2e18766c26603608f321508caae00ea8f4434d59"}]},"role":"user","id":"ba197665-164f-48dc-b408-afa76e228ed6"},"surfaceOp":"append"} {"type":"step/start","seq":4,"time":1784903339799,"data":{"turn":1,"step":1}} {"type":"request/header","seq":5,"time":1784903339800,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":6,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -10,17 +10,28 @@ {"type":"assistant/chunk","seq":8,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}}} {"type":"assistant/chunk","seq":9,"time":1783778297070,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":11,"time":1784903339801,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"11b21c20-5425-41ad-8fa0-d8b89cc40f87"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"assistant/message","seq":11,"time":1784903339801,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"fdc0fbd1-b483-49ff-861d-1c0332d13596"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} {"type":"tool/call","seq":12,"time":1784903339802,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}} -{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"7cbf28e2-a9f0-4cca-874c-2987a3507e24"}},"sourceEventSeqs":[12],"surfaceOp":"append"} -{"type":"user/message","seq":14,"time":1784903339813,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"73cb82c7-85c5-4d87-bb6c-cad10b7ef6de"},"surfaceOp":"append"} +{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"9027e8f1-572e-45f2-9c92-c78227adc42a"}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"user/message","seq":14,"time":1784903339813,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"939dbe9f-7df8-48af-b36c-3b546fd5d95e"},"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1784903339813,"data":{"turn":1,"step":1}} {"type":"step/start","seq":16,"time":1784903339820,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1784903339821,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"52b0df24-16f6-4b82-b351-0c4af707da21"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} -{"type":"step/end","seq":23,"time":1784903339821,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":24,"time":1784903339822,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":17,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":18,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_workspace_delimiter_read","name":"read","argumentsDelta":"{\"file_path\":\"scope/task.txt\"}"}}} +{"type":"assistant/chunk","seq":19,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}}}} +{"type":"assistant/chunk","seq":20,"time":1783778297073,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":22,"time":1784903339821,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"a9d0e5a8-e1ae-4b09-933d-882400f5f13a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"} +{"type":"tool/call","seq":23,"time":1785233046380,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}} +{"type":"tool/result","seq":24,"time":1785233046389,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"31c9f547-39d5-4fd8-903a-2b4625fb3b8e"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"user/message","seq":25,"time":1785233046389,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"149d4be0-a33b-4478-be5a-8d1e4f9ec7cc"},"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":1785233046389,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":27,"time":1785233046397,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":28,"time":1785233046398,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":29,"time":1785233046398,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":30,"time":1785233046398,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":31,"time":1785233046398,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":32,"time":1785233046398,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":33,"time":1785233046398,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"c5718cf9-802e-47e9-8e64-3353598ea5ee"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1785233046398,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":35,"time":1785233046398,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/context/workspace-context/README.i18n.yaml b/packages/context/workspace-context/README.i18n.yaml index 8191413d37..b1626592e8 100644 --- a/packages/context/workspace-context/README.i18n.yaml +++ b/packages/context/workspace-context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md -README.md: df75b29dd3e8dbb504aac9e9885c32a809cbf70f -README.zh.md: 8bd926302f09ecdf453c7832b3a15b0e7fcc1b2a +README.md: 2669422ec1fa7a74ba329cd96ee6b7e5e6da7e9d +README.zh.md: c5074f84796e3a2f95a1ba5e849af2f6afd751c9 diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index df75b29dd3..2669422ec1 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -42,7 +42,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when ``` -A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text inside an instruction file is escaped so file content cannot close the plugin-owned frame. +A same-file edit starts with `Updated instructions from: ` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: ` followed by `The previously loaded instructions from this file no longer apply.` Literal `` text anywhere in instruction content or model-visible path, scope, and budget metadata is escaped so repository-controlled text cannot close the plugin-owned frame. The plugin owns the complete `` framing, and every injected `user/message` reaches the model verbatim with no core wrapper. diff --git a/packages/context/workspace-context/README.zh.md b/packages/context/workspace-context/README.zh.md index 8bd926302f..c5074f8479 100644 --- a/packages/context/workspace-context/README.zh.md +++ b/packages/context/workspace-context/README.zh.md @@ -42,7 +42,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when ``` -同一文件的编辑以 `Updated instructions from: ` 开头,并说明使用新内容替代之前加载的内容。候选文件消失或成为同一目录中较早候选文件的重复项时,消息是 `Instructions removed: `,后跟 `The previously loaded instructions from this file no longer apply.`。指令文件中的字面 `` 文本会转义,因此文件内容无法关闭插件拥有的 frame。 +同一文件的编辑以 `Updated instructions from: ` 开头,并说明使用新内容替代之前加载的内容。候选文件消失或成为同一目录中较早候选文件的重复项时,消息是 `Instructions removed: `,后跟 `The previously loaded instructions from this file no longer apply.`。指令内容或模型可见的路径、scope 与预算元数据中出现的字面 `` 文本都会转义,因此仓库控制的文本无法关闭插件拥有的 frame。 该插件拥有完整 `` framing,每个注入的 `user/message` 都会在没有核心包装的情况下逐字达到模型。 diff --git a/packages/context/workspace-context/src/render.ts b/packages/context/workspace-context/src/render.ts index baca6bd84b..9ab311e942 100644 --- a/packages/context/workspace-context/src/render.ts +++ b/packages/context/workspace-context/src/render.ts @@ -59,15 +59,12 @@ function truncateUtf8(value: string, maxBytes: number): string { return truncated } -function escapeInstructionContent(content: string): string { - // TODO(instruction-frame-paths): apply the same delimiter neutralization to - // every interpolated path and scope; repository-controlled names can - // otherwise close the plugin-owned system-reminder frame. - return content.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>') +function escapeInstructionFrameBody(body: string): string { + return body.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>') } function sectionText(file: LoadedInstructionFile): string { - return `Instructions from: ${file.displayPath}\n\n${escapeInstructionContent(file.content)}` + return `Instructions from: ${file.displayPath}\n\n${file.content}` } /** Directory component that identifies the single user-global instruction scope. */ @@ -136,7 +133,7 @@ function additionalSectionText(file: LoadedInstructionFile): string { '', `These instructions apply to work under \`${scope}\`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.`, '', - escapeInstructionContent(file.content), + file.content, ].join('\n') } @@ -153,7 +150,7 @@ function changedSectionText(item: ChangeRenderItem): string { '', 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.', '', - escapeInstructionContent(file.content), + file.content, ].join('\n') } @@ -214,7 +211,7 @@ function buildInstructionText( // producer's content (the pattern a future `meta`-driven renderer would // generalize — see the deferred note in // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md). - return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n') + return [SYSTEM_REMINDER_OPEN, escapeInstructionFrameBody(body.join('\n\n')), SYSTEM_REMINDER_CLOSE].join('\n') } function withTruncatedContent(file: LoadedInstructionFile, includedBytes: number): LoadedInstructionFile { @@ -285,8 +282,10 @@ function renderInstructionContext( originalBytes: byteLength(mostSpecific.content), includedBytes: 0, }] - const compactNotice = markerText(maxBytes, omitted, truncated) - const compactWithHeading = [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n') + const compactNotice = escapeInstructionFrameBody(markerText(maxBytes, omitted, truncated)) + const compactWithHeading = escapeInstructionFrameBody( + [compactNotice, style.section(withTruncatedContent(mostSpecific, 0))].join('\n\n'), + ) if (byteLength(compactWithHeading) <= maxBytes) return { text: compactWithHeading, omitted, truncated } const text = byteLength(compactNotice) <= maxBytes ? compactNotice : truncateUtf8(compactNotice, maxBytes) return { text, omitted, truncated } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 3ff6b9b310..a65196e03c 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -41,7 +41,7 @@ import { type InstructionVersionCache, type PendingInstructionChange, } from '../src/state.ts' -import { candidateScopeKey } from '../src/render.ts' +import { candidateScopeKey, renderInstructionChanges } from '../src/render.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** Per-candidate reconciliation scope key: directory paired with the file name. */ @@ -681,6 +681,37 @@ describe('workspace context rendering', () => { expect(rendered.text).toContain('<\\/system-reminder>') }) + it('neutralizes system-reminder closing delimiters in paths and derived scopes', () => { + const displayPath = 'scope/AGENTS.md' + const file = { absolutePath: `/repo/${displayPath}`, displayPath, content: 'rules' } + const rendered = [ + renderWorkspaceContext([file], { maxBytes: 65536 }).text, + ...(['set', 'replace', 'remove'] as const).map(action => renderInstructionChanges([{ + change: { action, scope: 'scope\0AGENTS.md', path: displayPath }, + file, + }], 65536).text), + ] + + for (const text of rendered) { + expect(text.match(/<\/system-reminder>/g)).toHaveLength(1) + expect(text).toContain('scope<\\/system-reminder>') + } + }) + + it('neutralizes a system-reminder closing delimiter in budget marker paths', () => { + const rendered = renderWorkspaceContext([ + { + absolutePath: '/repo/scope/AGENTS.md', + displayPath: 'scope/AGENTS.md', + content: 'root '.repeat(100), + }, + { absolutePath: '/repo/leaf/AGENTS.md', displayPath: 'leaf/AGENTS.md', content: 'leaf rules' }, + ], { maxBytes: 400 }) + + expect(rendered.text).toContain('omitted scope<\\/system-reminder>/AGENTS.md') + expect(rendered.text.match(/<\/system-reminder>/g)).toHaveLength(1) + }) + it('preserves more specific files under the byte budget and names omitted/truncated paths', () => { const rendered = renderWorkspaceContext([ { absolutePath: '/repo/AGENTS.md', displayPath: 'AGENTS.md', content: 'root '.repeat(100) }, diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index b7f09e888d..0b41c2dd50 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -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/support/acp-snapshot/README.md -README.md: afbb23e2251932d41ac5d5d5b7d966f2750857fe -README.zh.md: 67ebbff395881c811819bb9e3dcfa4faa4d39914 +README.md: 93998c20bed7a2542c23932bd659a64aec63a585 +README.zh.md: a355cf0f35b5e7bec41ab0d9063c932211a7200b diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index afbb23e225..93998c20be 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. A scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Reserve this hook for fixtures Git cannot represent portably, keep ordinary seeds in `workspace/`, and pair it with `posixOnly` when the generated paths are invalid on Windows. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 67ebbff395..a355cf0f35 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。每个 pin 目录将规范化的完整提示词序列存入生成的 `system-prompt.expected.md`,将对应完整工具 schema 序列存入生成的 `tool-schemas.expected.json`;`session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`,用于固定两个 sidecar 序列的长度。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。此 hook 仅用于 Git 无法跨平台表示的 fixture;普通种子应留在 `workspace/` 中,而生成路径在 Windows 上无效时还必须搭配 `posixOnly`。每个 pin 目录将规范化的完整提示词序列存入生成的 `system-prompt.expected.md`,将对应完整工具 schema 序列存入生成的 `tool-schemas.expected.json`;`session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`,用于固定两个 sidecar 序列的长度。 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index d11a4f1b9c..8f562be7e3 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -155,6 +155,13 @@ export interface RunOptions { * start from an empty workspace. */ workspaceDir?: string + /** + * Optional final workspace preparation, run after {@link workspaceDir} is + * copied and before the agent starts. This is for fixtures that cannot be + * represented portably in Git (for example, a POSIX-only filename that is + * invalid on Windows); ordinary seeded files belong in `workspaceDir`. + */ + prepareWorkspace?: (cwd: string) => void | Promise /** * Parent directory for the generated session cwd. Defaults to * `os.tmpdir()`. A scenario that must distinguish its workspace from the @@ -221,6 +228,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) } + await opts.prepareWorkspace?.(cwd) const env: NodeJS.ProcessEnv = { ...opts.env, DSH_SNAPSHOT: opts.mode, diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 9b6c31fa47..d8ab5c2f6e 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -114,6 +114,12 @@ export interface Scenario { * test and the scenario needs an independent project location. */ workspaceParent?: string + /** + * Optional final workspace preparation after the committed fixture is + * copied. Reserve this for paths that Git cannot represent portably; normal + * scenario files belong under the scenario's `workspace/` directory. + */ + prepareWorkspace?: (cwd: string) => void | Promise /** * Whether Windows additionally compares stdout with native separators against * `stdout.expected.windows.jsonl`. The shared canonical stdout expected output is still @@ -849,6 +855,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // replays from its own script. In RECORD they are harvested, not read. ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, + ...scenario.prepareWorkspace !== undefined ? { prepareWorkspace: scenario.prepareWorkspace } : {}, ...scenario.workspaceParent !== undefined ? { workspaceParent: scenario.workspaceParent } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index b908330554..531eedd4b4 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { once } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join, relative, sep } from 'node:path' @@ -468,6 +468,30 @@ describe('runScenario', () => { expect(result.rawStdout).toContain('workspace:seeded.txt') }) + it('prepares the generated workspace after copying committed fixtures', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ echoWorkspace: true }) + const workspaceDir = join(dir, 'workspace') + const { mkdir } = await import('node:fs/promises') + await mkdir(workspaceDir, { recursive: true }) + await writeFile(join(workspaceDir, 'committed.txt'), 'committed') + + const result = await runScenario( + { steps: [...boot, { op: 'prompt', text: 'ls' }] }, + { + agent: AGENT, + mode: 'replay', + fixtureFile, + workspaceDir, + prepareWorkspace: async (cwd) => { + expect(await readFile(join(cwd, 'committed.txt'), 'utf8')).toBe('committed') + await writeFile(join(cwd, 'runtime.txt'), 'runtime') + }, + }, + ) + + expect(result.rawStdout).toContain('workspace:committed.txt,runtime.txt') + }) + it('creates the generated workspace under an explicit parent', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({}) const workspaceParent = await mkdtemp(join(tmpdir(), 'acp-snap-parent-')) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 2160aad618..592c79e24e 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -65,6 +65,9 @@ const REPLAY_SCENARIOS: Scenario[] = [ env: { DSH_PERMISSION_MODE: 'never' }, configPath: AGENT.configPath, workspaceParent: tmpdir(), + prepareWorkspace: (cwd) => { + writeFileSync(join(cwd, 'seed.txt'), 'prepared at runtime') + }, }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, From 276ebd9339a09680ee67cab574385530e1dc281a Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 28 Jul 2026 21:50:39 +0800 Subject: [PATCH 05/31] docs: propose experimental plugin group --- ...xperimental-plugin-package-group.i18n.yaml | 6 ++++ ...07-28-experimental-plugin-package-group.md | 35 +++++++++++++++++++ ...28-experimental-plugin-package-group.zh.md | 35 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md create mode 100644 .agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md diff --git a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml new file mode 100644 index 0000000000..539b2dc0ab --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md +2026-07-28-experimental-plugin-package-group.md: e0a17206bf4ffd424d6dd449023001fd48eb3260 +2026-07-28-experimental-plugin-package-group.zh.md: 1850e94c91908865c833b7dc1583460babbb32f1 diff --git a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md new file mode 100644 index 0000000000..e0a17206bf --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md @@ -0,0 +1,35 @@ +# Agent Note: Experimental plugin package group + +Status: proposed + +English | [中文](2026-07-28-experimental-plugin-package-group.zh.md) + +## Problem + +The [package hierarchy](../../../../packages/README.md) groups plugins by product role, but it cannot distinguish supported plugins from prototypes whose contracts and continued existence remain unsettled. After the first tagged release, contributors still need an obvious place for useful experiments that carry no stability, compatibility, migration, or support warranty. + +## Proposal + +Add `packages/experimental//` as the required home for Cordis plugin packages whose whole public contract is experimental. Package names remain `@deepseek-ai/dsh-`; promotion moves the package into its product-role group without renaming it. + +Experimental packages carry no stability, compatibility, migration, or support promise: they may change APIs, configuration, or data, or disappear, without deprecation or migration. This status does not relax engineering standards; these packages retain the repository's type, test, security, documentation, lifecycle, and snapshot requirements. Non-experimental packages must not take runtime dependencies on them. Examples may use them; any other runtime dependent is itself experimental and belongs under `packages/experimental/`. Tests may use them as development dependencies. + +Examples include the pending `@deepseek-ai/dsh-tui-session-changes` `/diff` viewer and the `/btw` plugin; if accepted, they land in this group. A release never promotes a package implicitly. Promotion requires explicit review of the public contract, limitations, test evidence, and a named owner accepting stable-package obligations. + +## Alternatives considered + +**Keep experiments in product-role groups with README labels.** Labels are easy to miss and cannot enforce dependency boundaries. + +**Treat every package as experimental until the first tagged release.** This provides no durable incubation boundary. + +**Develop experiments elsewhere.** This loses the real plugin graph, examples, snapshots, and lifecycle checks needed to evaluate them. + +## Acceptance criteria + +- `packages/experimental/` has a concise group README defining the package-level status, all four disclaimed promises, and the promotion rule. +- Constraints require every experimental plugin package and every non-example runtime dependent of one to live there. +- Package and user documentation label experimental plugins and avoid stability, compatibility, migration, or support promises. + +## Risks + +The group can become a junk drawer or let “experimental” excuse weak engineering. The repository's [current-owner/current-need rule](../../../../packages/AGENTS.md) and unchanged engineering gates limit that risk. Promotion causes path churn, but the npm name remains stable. diff --git a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md new file mode 100644 index 0000000000..1850e94c91 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 实验性插件包(package)分组 + +Status: proposed + +[English](2026-07-28-experimental-plugin-package-group.md) | 中文 + +## 问题 + +[包层级结构](../../../../packages/README.md)按产品角色对插件分组,但无法区分受支持的插件和契约与去留均未确定的原型。首个带标签的版本发布后,贡献者仍需要一个明确的位置存放有价值的实验性插件;这些插件不提供稳定性、兼容性、迁移或支持保证。 + +## 提案 + +新增 `packages/experimental//`,并要求公开契约整体处于实验阶段的 Cordis 插件包全部放在其中。包名仍为 `@deepseek-ai/dsh-`;提升为稳定插件时,只将包移入对应的产品角色分组,不对其重命名。 + +实验性包不提供稳定性、兼容性、迁移或支持保证:其 API、配置和数据可以变更,包也可以移除,均不提供弃用期或迁移路径。实验性状态不表示降低工程标准;这些包仍须满足仓库对类型、测试、安全、文档、生命周期和快照的要求。非实验性包不得将其列为运行时依赖。示例包可以使用它们;其他任何运行时依赖方本身也必须是实验性包,并位于 `packages/experimental/` 下。测试可以将其用作开发依赖。 + +示例包括尚待完成的 `@deepseek-ai/dsh-tui-session-changes` `/diff` 查看器和 `/btw` 插件;如果获准合入,它们将直接进入该分组。发布不会自动将包提升为稳定状态。提升前必须明确评审其公开契约、限制和测试证据,并指定一名愿意承担稳定包义务的负责人。 + +## 考虑过的替代方案 + +**将实验性插件留在产品角色分组中,并用 README 标注。** 标注容易被忽略,也无法强制执行依赖边界。 + +**首个版本发布前,将所有包都视为实验性。** 这无法提供持久的孵化边界。 + +**在其他位置开发实验性插件。** 这会失去评估它们所需的真实插件图、示例、快照和生命周期检查。 + +## 验收标准 + +- `packages/experimental/` 包含一份简明的分组 README,定义包级状态、明确排除的四类保证以及提升规则。 +- 约束规则要求所有实验性插件包及其所有非示例运行时依赖方位于该目录。 +- 包文档和用户文档标明插件的实验性状态,且不作稳定性、兼容性、迁移或支持承诺。 + +## 风险 + +该分组可能无序积累原型,也可能让「实验性」成为降低工程标准的借口。仓库已有的[「必须有当前负责人和实际需求」规则](../../../../packages/AGENTS.md)以及不变的工程门禁可限制这项风险。提升为稳定插件会导致路径变动,但 npm 包名保持稳定。 From ba31656258d1c1c4e2c5eaf75eb787e1674e665d Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 28 Jul 2026 21:53:18 +0800 Subject: [PATCH 06/31] docs: name prototype sharing purpose --- .../2026-07-28-experimental-plugin-package-group.i18n.yaml | 4 ++-- .../2026-07-28-experimental-plugin-package-group.md | 2 ++ .../2026-07-28-experimental-plugin-package-group.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml index 539b2dc0ab..ca65a8ac5b 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml @@ -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/proposed/architecture/2026-07-28-experimental-plugin-package-group.md -2026-07-28-experimental-plugin-package-group.md: e0a17206bf4ffd424d6dd449023001fd48eb3260 -2026-07-28-experimental-plugin-package-group.zh.md: 1850e94c91908865c833b7dc1583460babbb32f1 +2026-07-28-experimental-plugin-package-group.md: e3c6f350bd8c8341e0da831159044e2f32e914f9 +2026-07-28-experimental-plugin-package-group.zh.md: f92683316e1acbaf01d903e8dda37801e4b3d238 diff --git a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md index e0a17206bf..e3c6f350bd 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md +++ b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md @@ -12,6 +12,8 @@ The [package hierarchy](../../../../packages/README.md) groups plugins by produc Add `packages/experimental//` as the required home for Cordis plugin packages whose whole public contract is experimental. Package names remain `@deepseek-ai/dsh-`; promotion moves the package into its product-role group without renaming it. +The group is also the team's in-repository place to share prototypes: members can discover, run, review, and extend one another's work against the real plugin graph without implying product support. + Experimental packages carry no stability, compatibility, migration, or support promise: they may change APIs, configuration, or data, or disappear, without deprecation or migration. This status does not relax engineering standards; these packages retain the repository's type, test, security, documentation, lifecycle, and snapshot requirements. Non-experimental packages must not take runtime dependencies on them. Examples may use them; any other runtime dependent is itself experimental and belongs under `packages/experimental/`. Tests may use them as development dependencies. Examples include the pending `@deepseek-ai/dsh-tui-session-changes` `/diff` viewer and the `/btw` plugin; if accepted, they land in this group. A release never promotes a package implicitly. Promotion requires explicit review of the public contract, limitations, test evidence, and a named owner accepting stable-package obligations. diff --git a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md index 1850e94c91..f92683316e 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md @@ -12,6 +12,8 @@ Status: proposed 新增 `packages/experimental//`,并要求公开契约整体处于实验阶段的 Cordis 插件包全部放在其中。包名仍为 `@deepseek-ai/dsh-`;提升为稳定插件时,只将包移入对应的产品角色分组,不对其重命名。 +该分组也是团队在仓库内共享原型的位置:成员可以基于真实插件图发现、运行、评审并扩展彼此的原型,但这不代表产品会提供支持。 + 实验性包不提供稳定性、兼容性、迁移或支持保证:其 API、配置和数据可以变更,包也可以移除,均不提供弃用期或迁移路径。实验性状态不表示降低工程标准;这些包仍须满足仓库对类型、测试、安全、文档、生命周期和快照的要求。非实验性包不得将其列为运行时依赖。示例包可以使用它们;其他任何运行时依赖方本身也必须是实验性包,并位于 `packages/experimental/` 下。测试可以将其用作开发依赖。 示例包括尚待完成的 `@deepseek-ai/dsh-tui-session-changes` `/diff` 查看器和 `/btw` 插件;如果获准合入,它们将直接进入该分组。发布不会自动将包提升为稳定状态。提升前必须明确评审其公开契约、限制和测试证据,并指定一名愿意承担稳定包义务的负责人。 From 2f3ac10da046036a36870e4bef1ed04f518580d5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 28 Jul 2026 22:30:12 +0800 Subject: [PATCH 07/31] docs: implement experimental and internal package group --- ...xperimental-plugin-package-group.i18n.yaml | 6 +++ ...07-28-experimental-plugin-package-group.md | 33 +++++++++++++++++ ...28-experimental-plugin-package-group.zh.md | 33 +++++++++++++++++ ...xperimental-plugin-package-group.i18n.yaml | 6 --- ...07-28-experimental-plugin-package-group.md | 37 ------------------- ...28-experimental-plugin-package-group.zh.md | 37 ------------------- packages/README.i18n.yaml | 4 +- packages/README.md | 3 +- packages/README.zh.md | 3 +- packages/experimental/AGENTS.md | 11 ++++++ packages/experimental/README.i18n.yaml | 6 +++ packages/experimental/README.md | 7 ++++ packages/experimental/README.zh.md | 7 ++++ 13 files changed, 109 insertions(+), 84 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md create mode 100644 .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md delete mode 100644 .agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml delete mode 100644 .agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md delete mode 100644 .agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md create mode 100644 packages/experimental/AGENTS.md create mode 100644 packages/experimental/README.i18n.yaml create mode 100644 packages/experimental/README.md create mode 100644 packages/experimental/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml new file mode 100644 index 0000000000..69a3347039 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md +2026-07-28-experimental-plugin-package-group.md: 3bc455eb2b676a1fb6d64117b7e9a7f390a6da83 +2026-07-28-experimental-plugin-package-group.zh.md: f204ecd052de03d0cf347e2c770feb0ea33966c7 diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md new file mode 100644 index 0000000000..3bc455eb2b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md @@ -0,0 +1,33 @@ +# Agent Note: Experimental and internal package group + +Status: implemented + +English | [中文](2026-07-28-experimental-plugin-package-group.zh.md) + +## Problem + +The [package hierarchy](../../../../packages/README.md) groups plugins by product role, but it cannot distinguish release packages from prototypes or internal-only packages. The team needs an obvious shared place for useful work that is not part of the official release. + +## Decision + +The subtree rules in [`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) make `packages/experimental//` the required home for Cordis plugin packages whose whole public contract is experimental or internal-only. Package names remain `@deepseek-ai/dsh-`. + +The group is the team's in-repository place to share engineering and product-manager prototypes: members can discover, run, review, and extend one another's work against the real plugin graph without implying product support. + +Official releases exclude this directory. A package enters a release only after moving to its product-role group; release packages cannot take runtime dependencies on packages here. Examples may use them, while any other runtime dependent also belongs here. Tests may use them as development dependencies. + +Experimental packages carry no stability, compatibility, migration, or support promise: they may change APIs, configuration, or data, or disappear without deprecation or migration. Internal-only packages may define narrower internal contracts but make no public release promise. Neither status relaxes engineering, security, documentation, lifecycle, testing, or snapshot requirements. + +The pending `@deepseek-ai/dsh-tui-session-changes` `/diff` viewer and `/btw` plugin are examples governed by this rule. Promotion into an official release requires explicit review of the public contract, limitations, test evidence, and a named owner accepting stable-package obligations. + +## Alternatives considered + +**Keep experimental and internal-only packages in product-role groups with README labels.** Labels are easy to miss and cannot enforce dependency boundaries. + +**Treat every package as experimental until the first tagged release.** This provides no durable incubation boundary. + +**Develop prototypes and internal packages elsewhere.** This loses the real plugin graph, examples, snapshots, and lifecycle checks needed to evaluate them. + +## Consequences + +The path makes release exclusion and dependency blast radius visible while retaining the real plugin graph for team sharing. It gives up product-role colocation and creates path churn on promotion, while the npm name remains stable. The subtree rules, repository [current-owner/current-need rule](../../../../packages/AGENTS.md), and unchanged engineering gates limit junk-drawer growth. Because official release tooling does not yet exist, contributor policy enforces the exclusion; the directory is its required exclusion boundary when added. diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md new file mode 100644 index 0000000000..f204ecd052 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 实验性与内部专用包(package)分组 + +Status: implemented + +[English](2026-07-28-experimental-plugin-package-group.md) | 中文 + +## 问题 + +[包层级结构](../../../../packages/README.md)按产品角色对插件分组,但无法区分发布包、原型和内部专用包。团队需要一个明确的共享位置,存放不属于官方发布版本的有价值成果。 + +## 决策 + +[`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) 中的子树规则要求所有公开契约整体处于实验状态或仅限内部使用的 Cordis 插件包位于 `packages/experimental//`。包名仍为 `@deepseek-ai/dsh-`。 + +该分组供团队在仓库内共享工程人员和产品经理制作的原型:成员可以基于真实插件图发现、运行、评审并扩展彼此的原型,但这不代表产品会提供支持。 + +官方发布版本不包含此目录。包只有移入对应的产品角色分组后才会纳入发布版本;发布包不得在运行时依赖此处的包。示例可以使用这些包;其他任何运行时依赖方也必须位于此处。测试可以将它们用作开发依赖。 + +实验性包不提供稳定性、兼容性、迁移或支持保证:其 API、配置或数据可以变更,包也可以移除,均不提供弃用期或迁移路径。内部专用包可以定义范围更窄的内部契约,但不作公开发布承诺。无论哪种状态,都不降低仓库对工程、安全、文档、生命周期、测试或快照的要求。 + +尚待完成的 `@deepseek-ai/dsh-tui-session-changes` `/diff` 查看器和 `/btw` 插件都受这项规则约束。将包提升为稳定包并纳入官方发布版本,需要明确评审其公开契约、限制和测试证据,并指定一名愿意承担稳定包义务的负责人。 + +## 考虑过的替代方案 + +**将实验性和内部专用包留在产品角色分组中,并用 README 标注。** 标注容易被忽略,也无法强制执行依赖边界。 + +**首个带标签的版本发布前,将所有包都视为实验性。** 这无法提供持久的孵化边界。 + +**在其他位置开发原型和内部专用包。** 这会失去评估它们所需的真实插件图、示例、快照和生命周期检查。 + +## 后果 + +该路径明确标示不纳入发布版本的包及其依赖影响范围,同时保留供团队共享成果的真实插件图。代价是这些包无法与同产品角色的包共置,提升并纳入发布版本时还会产生路径变动,但 npm 包名保持稳定。子树规则、仓库已有的[「必须有当前负责人和实际需求」规则](../../../../packages/AGENTS.md)以及保持不变的工程门禁,可限制该分组无序膨胀。由于官方发布工具尚不存在,目前由贡献者政策执行这项排除规则;添加发布工具后,必须以该目录为排除边界。 diff --git a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml deleted file mode 100644 index ca65a8ac5b..0000000000 --- a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md -2026-07-28-experimental-plugin-package-group.md: e3c6f350bd8c8341e0da831159044e2f32e914f9 -2026-07-28-experimental-plugin-package-group.zh.md: f92683316e1acbaf01d903e8dda37801e4b3d238 diff --git a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md deleted file mode 100644 index e3c6f350bd..0000000000 --- a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: Experimental plugin package group - -Status: proposed - -English | [中文](2026-07-28-experimental-plugin-package-group.zh.md) - -## Problem - -The [package hierarchy](../../../../packages/README.md) groups plugins by product role, but it cannot distinguish supported plugins from prototypes whose contracts and continued existence remain unsettled. After the first tagged release, contributors still need an obvious place for useful experiments that carry no stability, compatibility, migration, or support warranty. - -## Proposal - -Add `packages/experimental//` as the required home for Cordis plugin packages whose whole public contract is experimental. Package names remain `@deepseek-ai/dsh-`; promotion moves the package into its product-role group without renaming it. - -The group is also the team's in-repository place to share prototypes: members can discover, run, review, and extend one another's work against the real plugin graph without implying product support. - -Experimental packages carry no stability, compatibility, migration, or support promise: they may change APIs, configuration, or data, or disappear, without deprecation or migration. This status does not relax engineering standards; these packages retain the repository's type, test, security, documentation, lifecycle, and snapshot requirements. Non-experimental packages must not take runtime dependencies on them. Examples may use them; any other runtime dependent is itself experimental and belongs under `packages/experimental/`. Tests may use them as development dependencies. - -Examples include the pending `@deepseek-ai/dsh-tui-session-changes` `/diff` viewer and the `/btw` plugin; if accepted, they land in this group. A release never promotes a package implicitly. Promotion requires explicit review of the public contract, limitations, test evidence, and a named owner accepting stable-package obligations. - -## Alternatives considered - -**Keep experiments in product-role groups with README labels.** Labels are easy to miss and cannot enforce dependency boundaries. - -**Treat every package as experimental until the first tagged release.** This provides no durable incubation boundary. - -**Develop experiments elsewhere.** This loses the real plugin graph, examples, snapshots, and lifecycle checks needed to evaluate them. - -## Acceptance criteria - -- `packages/experimental/` has a concise group README defining the package-level status, all four disclaimed promises, and the promotion rule. -- Constraints require every experimental plugin package and every non-example runtime dependent of one to live there. -- Package and user documentation label experimental plugins and avoid stability, compatibility, migration, or support promises. - -## Risks - -The group can become a junk drawer or let “experimental” excuse weak engineering. The repository's [current-owner/current-need rule](../../../../packages/AGENTS.md) and unchanged engineering gates limit that risk. Promotion causes path churn, but the npm name remains stable. diff --git a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md b/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md deleted file mode 100644 index f92683316e..0000000000 --- a/.agents/notes/proposed/architecture/2026-07-28-experimental-plugin-package-group.zh.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: 实验性插件包(package)分组 - -Status: proposed - -[English](2026-07-28-experimental-plugin-package-group.md) | 中文 - -## 问题 - -[包层级结构](../../../../packages/README.md)按产品角色对插件分组,但无法区分受支持的插件和契约与去留均未确定的原型。首个带标签的版本发布后,贡献者仍需要一个明确的位置存放有价值的实验性插件;这些插件不提供稳定性、兼容性、迁移或支持保证。 - -## 提案 - -新增 `packages/experimental//`,并要求公开契约整体处于实验阶段的 Cordis 插件包全部放在其中。包名仍为 `@deepseek-ai/dsh-`;提升为稳定插件时,只将包移入对应的产品角色分组,不对其重命名。 - -该分组也是团队在仓库内共享原型的位置:成员可以基于真实插件图发现、运行、评审并扩展彼此的原型,但这不代表产品会提供支持。 - -实验性包不提供稳定性、兼容性、迁移或支持保证:其 API、配置和数据可以变更,包也可以移除,均不提供弃用期或迁移路径。实验性状态不表示降低工程标准;这些包仍须满足仓库对类型、测试、安全、文档、生命周期和快照的要求。非实验性包不得将其列为运行时依赖。示例包可以使用它们;其他任何运行时依赖方本身也必须是实验性包,并位于 `packages/experimental/` 下。测试可以将其用作开发依赖。 - -示例包括尚待完成的 `@deepseek-ai/dsh-tui-session-changes` `/diff` 查看器和 `/btw` 插件;如果获准合入,它们将直接进入该分组。发布不会自动将包提升为稳定状态。提升前必须明确评审其公开契约、限制和测试证据,并指定一名愿意承担稳定包义务的负责人。 - -## 考虑过的替代方案 - -**将实验性插件留在产品角色分组中,并用 README 标注。** 标注容易被忽略,也无法强制执行依赖边界。 - -**首个版本发布前,将所有包都视为实验性。** 这无法提供持久的孵化边界。 - -**在其他位置开发实验性插件。** 这会失去评估它们所需的真实插件图、示例、快照和生命周期检查。 - -## 验收标准 - -- `packages/experimental/` 包含一份简明的分组 README,定义包级状态、明确排除的四类保证以及提升规则。 -- 约束规则要求所有实验性插件包及其所有非示例运行时依赖方位于该目录。 -- 包文档和用户文档标明插件的实验性状态,且不作稳定性、兼容性、迁移或支持承诺。 - -## 风险 - -该分组可能无序积累原型,也可能让「实验性」成为降低工程标准的借口。仓库已有的[「必须有当前负责人和实际需求」规则](../../../../packages/AGENTS.md)以及不变的工程门禁可限制这项风险。提升为稳定插件会导致路径变动,但 npm 包名保持稳定。 diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 0969696696..6aae068fd7 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: f5420b6f2f30837b030a0e832a438c34674a6f23 -README.zh.md: 7beeaadf380a742cbdb6447553f42692a97fad10 +README.md: 706d741b87bd22656580419eb43a493c1ab2933a +README.zh.md: c41a15dcb743d024348d5a8a8c105b37e694205b diff --git a/packages/README.md b/packages/README.md index f5420b6f2f..706d741b87 100644 --- a/packages/README.md +++ b/packages/README.md @@ -44,11 +44,12 @@ Packages live at `packages///`; groups are containers, while names r | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface | | [`ui/`](ui/README.md) | TUI and JSON-RPC integrations, approval/interaction seams, ask-user tool | Product — stable surface | +| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra | | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | -Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table. +New packages join existing groups; new groups update their README and this table. ## Dependencies diff --git a/packages/README.zh.md b/packages/README.zh.md index 7beeaadf38..c41a15dcb7 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -44,11 +44,12 @@ | [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 | | [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 | | [`ui/`](ui/README.md) | TUI 与 JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 | +| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 | | [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 | | [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | | [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | -组用于区分产品 API 与支持基础设施。新包加入现有组;新组则更新其 README 和此表。 +新包加入现有组;新组更新其 README 和此表。 ## 依赖 diff --git a/packages/experimental/AGENTS.md b/packages/experimental/AGENTS.md new file mode 100644 index 0000000000..e9cb3d2b51 --- /dev/null +++ b/packages/experimental/AGENTS.md @@ -0,0 +1,11 @@ +# AGENTS.md — Experimental and internal packages + +These rules supplement the [package rules](../AGENTS.md). The [experimental and internal package group decision](../../.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md) owns the rationale. + +- All Cordis plugin packages whose whole public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group. +- Use this directory to share engineering and product-manager prototypes across the team so others can discover, run, review, and extend them against the real plugin graph. +- Official releases exclude this directory. A package enters a release only after moving to its product-role group; do not add packages here to release manifests or bundles. +- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define narrower internal contracts but make no public release promise. +- Experimental or internal-only status never relaxes repository engineering, security, documentation, lifecycle, testing, or snapshot requirements. +- Release packages must not take runtime dependencies on packages here. Examples may; every other runtime dependent is also experimental or internal-only and belongs here. Tests may use them as development dependencies. +- Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Require explicit review of its public contract, limitations, test evidence, and a named owner accepting stable-package obligations. diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml new file mode 100644 index 0000000000..fe4fcc3ecd --- /dev/null +++ b/packages/experimental/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/README.md +README.md: db39af8bb1b1bcfd257e16e4ad1dd112f604ffb1 +README.zh.md: df9b8cb2a91faab7af782e0f53685368e99583ff diff --git a/packages/experimental/README.md b/packages/experimental/README.md new file mode 100644 index 0000000000..db39af8bb1 --- /dev/null +++ b/packages/experimental/README.md @@ -0,0 +1,7 @@ +# experimental/ — experimental and internal packages + +English | [中文](README.zh.md) + +This group hosts team-shared engineering and product-manager prototypes plus internal-only Cordis plugins. It is excluded from official releases; packages move to their product-role group before release. + +No packages live here yet. The [subtree rules](AGENTS.md) define the no-warranty, dependency, and promotion boundaries. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md new file mode 100644 index 0000000000..df9b8cb2a9 --- /dev/null +++ b/packages/experimental/README.zh.md @@ -0,0 +1,7 @@ +# experimental/:实验性与内部专用包(package) + +[English](README.md) | 中文 + +该分组容纳工程人员与产品经理在团队内共享的原型,以及内部专用 Cordis 插件。该分组不纳入官方发布版本;包在发布前移入对应的产品角色分组。 + +该分组尚未包含任何包。[子树规则](AGENTS.md)界定不作保证、依赖关系和提升机制的边界。 From 61a388b54ee85415255d528ae7865c38a5175bb7 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:59:28 -0700 Subject: [PATCH 08/31] test(hooks): align matcher fixtures with message identity --- .../tests/snapshots/hook-cc-invalid-matcher/session.jsonl | 4 ++-- .../tests/snapshots/hook-codex-invalid-matcher/session.jsonl | 4 ++-- packages/hooks/hooks-claude/tests/bridge.spec.ts | 4 ++-- packages/hooks/hooks-codex/tests/bridge.spec.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl index 6c4e1d2a49..e235d78b00 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","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"}},"surfaceOp":"append"} +{"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"}} @@ -13,6 +13,6 @@ {"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,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"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":"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"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl index 6c4e1d2a49..1dce3afec3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","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"}},"surfaceOp":"append"} +{"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"}} @@ -13,6 +13,6 @@ {"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,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"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":"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"}}} diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 7b6579d122..abedced025 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -375,7 +375,7 @@ describe('hooks-claude bridge — load resilience', () => { 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({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + 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) @@ -394,7 +394,7 @@ describe('hooks-claude bridge — load resilience', () => { 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({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index e0374537ca..e8a9384cea 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -162,7 +162,7 @@ describe('hooks-codex bridge', () => { 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({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + 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) From ad93803431068b85cdd57d2cfce0be8cfef012db Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:54:25 -0700 Subject: [PATCH 09/31] docs(acp-snapshot): preserve merged workspace contracts --- packages/support/acp-snapshot/README.i18n.yaml | 4 ++-- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 338905642c..8742eb2d2a 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -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/support/acp-snapshot/README.md -README.md: 371ede587b84ba96770d4a2b1ee89b029d92dd25 -README.zh.md: f596f9021ae9b8c5973efafae7f7d695293b96e1 +README.md: 5e777c3ce6b46f0e61c47f330566fe0acae41a9b +README.zh.md: 40801980600fb8d55210d2c59eeef4468aa9483f diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index b42551b8b7..5e777c3ce6 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. A scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Reserve this hook for fixtures Git cannot represent portably, keep ordinary seeds in `workspace/`, and pair it with `posixOnly` when the generated paths are invalid on Windows. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. A scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Reserve this hook for fixtures Git cannot represent portably, keep ordinary seeds in `workspace/`, and pair it with `posixOnly` when the generated paths are invalid on Windows. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index eeb1179054..4080198060 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。此 hook 仅用于 Git 无法跨平台表示的 fixture;普通种子应留在 `workspace/` 中,而生成路径在 Windows 上无效时还必须搭配 `posixOnly`。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。此 hook 仅用于 Git 无法跨平台表示的 fixture;普通种子应留在 `workspace/` 中,而生成路径在 Windows 上无效时还必须搭配 `posixOnly`。 每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 From 55321fe7a419206eb5752046b0a907342ffda942 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:55:18 -0700 Subject: [PATCH 10/31] fix(hooks): ignore matcherless event fields --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 ++-- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../feature/2026-06-30-hook-protocol-lib.zh.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 5 +++-- .../workspace/hooks.json | 1 + .../workspace/codex-hooks.json | 1 + packages/hooks/hook-protocol/README.i18n.yaml | 4 ++-- packages/hooks/hook-protocol/README.md | 2 +- packages/hooks/hook-protocol/README.zh.md | 2 +- packages/hooks/hook-protocol/src/matcher.ts | 4 +++- packages/hooks/hooks-claude/README.i18n.yaml | 4 ++-- packages/hooks/hooks-claude/README.md | 2 +- packages/hooks/hooks-claude/README.zh.md | 2 +- packages/hooks/hooks-claude/src/config.ts | 9 ++++++--- packages/hooks/hooks-claude/tests/bridge.spec.ts | 5 +++-- packages/hooks/hooks-claude/tests/config.spec.ts | 12 ++++++++++++ packages/hooks/hooks-codex/README.i18n.yaml | 4 ++-- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/README.zh.md | 2 +- packages/hooks/hooks-codex/src/config.ts | 11 +++++++---- packages/hooks/hooks-codex/tests/bridge.spec.ts | 10 +++++----- packages/hooks/hooks-codex/tests/config.spec.ts | 16 ++++++++++++++-- 22 files changed, 71 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 4413405235..47ac4bf302 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: 11986c01f76c7b3cc7eb5ebe9627dc0ded8afd95 -2026-06-30-hook-protocol-lib.zh.md: 6abccabf517b69b642cd5db52281e4e8a8d526c7 +2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857 +2026-06-30-hook-protocol-lib.zh.md: 354edd9d03b49cf43b6ad500108e741787308ec6 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 11986c01f7..ce25f40e96 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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** — `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, validates runnable groups for supported events, 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. +- **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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 6abccabf51..354edd9d03 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,仅校验受支持事件中可运行的 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 +- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 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 解析 stdout;exit `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 按序累积。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 793044df7d..46de94806e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -212,8 +212,9 @@ 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 diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json index ee3da88fb1..d4ef9cc633 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/workspace/hooks.json @@ -2,6 +2,7 @@ "hooks": { "UserPromptSubmit": [ { + "matcher": "[", "hooks": [ { "type": "command", "command": "echo 'blocked by policy hook' >&2; exit 2" } ] diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json index 84bc6f37d0..f3fc9de501 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/workspace/codex-hooks.json @@ -2,6 +2,7 @@ "hooks": { "UserPromptSubmit": [ { + "matcher": "[", "hooks": [ { "type": "command", "command": "echo 'blocked by codex policy hook' >&2; exit 2" } ] diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 3869ef696c..231aab555d 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: 92b5e146c7da3531246da627884143991c76932b -README.zh.md: c49b7e75848f9bc736492b66d1126aa93287ace4 +README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805 +README.zh.md: 10fde6ec2fd0e6803bc91324fd11a9f9a1438db4 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 92b5e146c7..8cf4b95c95 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud ## Primitives -- **`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 use `matcherDiagnostic` to reject an invalid 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. +- **`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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index c49b7e7584..10fde6ec2f 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -19,7 +19,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。桥接解析器使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。 +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。运行时谓词仍将无效 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` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index ca3a867418..9c5606a975 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -21,7 +21,9 @@ const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ function compileRegex(pattern: string): RegExp | undefined { try { return new RegExp(pattern) - } catch { + } catch (_syntaxError) { + // RegExp construction is the try's only operation, so malformed pattern + // syntax is the only expected failure. return undefined } } diff --git a/packages/hooks/hooks-claude/README.i18n.yaml b/packages/hooks/hooks-claude/README.i18n.yaml index 515aba4a0b..fba2c87aa6 100644 --- a/packages/hooks/hooks-claude/README.i18n.yaml +++ b/packages/hooks/hooks-claude/README.i18n.yaml @@ -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: 413159759dc76478beeb65c8e380df77c0a26e86 -README.zh.md: 43c0b4644891a14e832ef35db7ffe11f11a4e545 +README.md: 61c2d152dacdbec31bca015b94b9f2ac6d24c3aa +README.zh.md: 4eae4072ec5054eaa9b1be3deb2074903bea3773 diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 413159759d..61c2d152da 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -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 — including an invalid regex matcher, 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 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. diff --git a/packages/hooks/hooks-claude/README.zh.md b/packages/hooks/hooks-claude/README.zh.md index 43c0b46448..4eae4072ec 100644 --- a/packages/hooks/hooks-claude/README.zh.md +++ b/packages/hooks/hooks-claude/README.zh.md @@ -28,7 +28,7 @@ const config: Config = { projectDir: . ``` -配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,因此一份配置应用于整个进程。尚未进行每会话(`session/new.cwd`)配置发现(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳,其中包括无效 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 默认值)。 +配置只在加载时解析**一次**。`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 作用于 user 项目树,而非服务器启动目录。 diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index aed4cab729..2650e940c2 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -65,8 +65,9 @@ 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; unsupported events are ignored before their groups are parsed, * non-command hooks are returned in `skipped`, and substitutions are applied to every surviving - * command. A supported runnable group with an invalid regex matcher throws a `SyntaxError`, allowing - * the bridge to reject the complete config before listener registration. + * 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. @@ -105,7 +106,9 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa }) } if (commands.length === 0) continue - const matcher = typeof group.matcher === 'string' ? group.matcher : undefined + 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({ diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index abedced025..c23625b392 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -90,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) diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts index d9713e998a..343fd6730e 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -70,6 +70,18 @@ describe('parseClaudeConfig', () => { })).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' }] }], diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index ba274e13e3..a9c4b33562 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -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: 62a9599b17a816205f91cee8ba6ce7eab1852681 -README.zh.md: 813c8ca1def904c3f6b79472ecc785ff316606d6 +README.md: e906810ed58c3d0204c618c32787af06c91cfb78 +README.zh.md: 8992cc63edf74d057114d888c396881dc8ee43d6 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 62a9599b17..e906810ed5 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -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); an invalid regex matcher 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 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. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 813c8ca1de..8992cc63ed 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -34,7 +34,7 @@ const config: Config = { model: deepseek-v4 ``` -配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳(记录 + 不注册任何内容);无效 matcher 正则属于此类失败,并报告其 pattern 与事件。只运行同步 `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 作用于 user 项目树,而非服务器启动目录。 diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index 97e1f23bd8..ae82340ad4 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -33,9 +33,10 @@ function asObject(value: unknown): Record | 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`. A runnable group - * with an invalid regex matcher throws a `SyntaxError`, allowing the bridge to reject the complete - * config before listener registration. + * 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. */ @@ -71,7 +72,9 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig { commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) } if (commands.length === 0) continue - const matcher = typeof group.matcher === 'string' ? group.matcher : undefined + 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 }) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index e8a9384cea..3e9ae5617a 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -89,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) @@ -156,7 +156,7 @@ describe('hooks-codex bridge', () => { const dir = configDir() writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }], - Stop: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }], + PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }], }) const adapter = new MockAdapter([textResponse('ok')]) const warn = vi.fn() @@ -168,7 +168,7 @@ describe('hooks-codex bridge', () => { expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false) expect(warn).toHaveBeenCalledWith(expect.stringContaining( - 'invalid codex regex matcher "[" on event "Stop"', + 'invalid codex regex matcher "[" on event "PreToolUse"', )) }) diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index a3a5bea827..8503d13151 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -68,7 +68,19 @@ describe('parseCodexConfig', () => { it('rejects an invalid regex matcher with its event name', () => { expect(() => parseCodexConfig({ - Stop: [{ matcher: '[', hooks: [{ type: 'command', command: 's.sh' }] }], - })).toThrow('invalid codex regex matcher "[" on event "Stop"') + 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' }] }], + }) }) }) From 0ef2327e3464214da482af024995c567234dfd7d Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:37:53 -0700 Subject: [PATCH 11/31] fix(hooks): match Codex Rust regex syntax --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- docs/config-catalog.md | 2 +- packages/hooks/README.i18n.yaml | 6 +- packages/hooks/README.md | 2 +- packages/hooks/README.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 4 +- packages/hooks/hook-protocol/package.json | 3 + packages/hooks/hook-protocol/src/matcher.ts | 70 +++++++++++++------ packages/hooks/hook-protocol/src/types.ts | 8 +-- .../hooks/hook-protocol/tests/matcher.spec.ts | 18 +++-- packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/README.zh.md | 2 +- packages/hooks/hooks-codex/src/index.ts | 11 +-- .../hooks/hooks-codex/tests/bridge.spec.ts | 6 +- .../hooks/hooks-codex/tests/config.spec.ts | 12 +++- pnpm-lock.yaml | 9 +++ 21 files changed, 115 insertions(+), 62 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 47ac4bf302..24770df7cb 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: ce25f40e96ffd5c319d9845e36eab5130cec5857 -2026-06-30-hook-protocol-lib.zh.md: 354edd9d03b49cf43b6ad500108e741787308ec6 +2026-06-30-hook-protocol-lib.md: 37f379a199b6e613101f76ac1700671eae1915b8 +2026-06-30-hook-protocol-lib.zh.md: 4f03ce6d9c33c16c9a12dc3dbe2a673a31eb0d85 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index ce25f40e96..37f379a199 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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** — `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. +- **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. Pure `[A-Za-z0-9_|]+` patterns use the shared exact-match fast path (pipe = alternatives); the ONE remaining dialect axis is collapsed to `mode`: other Claude patterns use JavaScript `RegExp`, while other Codex patterns use Rust `regex`, including Rust-only syntax such as `(?i)`. 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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 354edd9d03..4f03ce6d9c 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 +- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。纯 `[A-Za-z0-9_|]+` pattern 使用共享的精确匹配快速路径(管道符 = 多选);剩余的唯一方言差异收敛为 `mode`:其他 Claude pattern 使用 JavaScript `RegExp`,其他 Codex pattern 使用 Rust `regex`,包括 `(?i)` 等 Rust 专属语法。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 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 解析 stdout;exit `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 按序累积。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 14946b9f0f..ac34b8faa0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -482,7 +482,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-host-apiproxy` diff --git a/packages/hooks/README.i18n.yaml b/packages/hooks/README.i18n.yaml index af9d4aa4a8..b3dc73cce7 100644 --- a/packages/hooks/README.i18n.yaml +++ b/packages/hooks/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a -README.zh.md: 21c75f0476c76c0be75dc3af25ffb9a2be28dc4e +# pnpm run verify-translation-pairing --write packages/hooks/README.md +README.md: 9084f93e6b76e366f81986a052eb35e3811a0c13 +README.zh.md: 889538080cf12fc363838338b87cc0bc06127c4f diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 23478fb5e9..9084f93e6b 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -10,4 +10,4 @@ The hooks subsystem lets users extend the agent at lifecycle points the way Clau | `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin | | `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin | -Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). +Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, a Rust-regex matcher dialect, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). diff --git a/packages/hooks/README.zh.md b/packages/hooks/README.zh.md index 21c75f0476..889538080c 100644 --- a/packages/hooks/README.zh.md +++ b/packages/hooks/README.zh.md @@ -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 的众多事件、仅命令、使用 Rust 正则 matcher 方言、没有 env/替换),因此 `hook-protocol` 拥有真正相同的原语,每个桥接只拥有不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 231aab555d..de3ac9a7a6 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805 -README.zh.md: 10fde6ec2fd0e6803bc91324fd11a9f9a1438db4 +README.md: e8e3e1b078f74636ee23f90a96d1e8748d7af373 +README.zh.md: 4671b179b222eea68cbcb00042d2ddcfe9a5691f diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 8cf4b95c95..e8e3e1b078 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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 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 | +| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `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 -- **`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. +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). 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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 10fde6ec2f..4671b179b2 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于收敛的运行时匹配 | 选择自身 `mode`(`claude` = 字面或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 | +| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于收敛的运行时匹配 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),并拒绝带有诊断的配置组 | | 运行 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 Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` 模式将纯 `[A-Za-z0-9_|]+` pattern 视为字面值(pipe = 精确匹配交替),其他 pattern 视为正则;`codex` 模式始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。 +- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。运行时谓词仍将无效 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` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index f357278db3..5ae4b98169 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -26,6 +26,9 @@ "src" ], "license": "BSD-3-Clause", + "dependencies": { + "rregex": "1.12.0" + }, "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 9c5606a975..e851a62f2e 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -1,56 +1,74 @@ /** * 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. Runtime matching contains invalid regexes as non-matches; - * config parsers use {@link matcherDiagnostic} to reject them with a diagnostic. + * pipe patterns as literal alternatives and other patterns as regex. Codex + * uses the same literal fast path, then compiles regex patterns with Rust's + * `regex` dialect. Missing, empty, and `*` 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 */ +import { createRequire } from 'node:module' +import type { RRegex as RustRegex } from 'rregex' import type { MatcherMode } from './types.ts' +// rregex's ESM entry initializes WASM with top-level await. Hook plugins are +// discovered through Cordis Loader's synchronous module boundary, so use the +// package's equivalent synchronous Node entry rather than making both bridge +// modules async merely by importing this shared matcher. +const { RRegex } = createRequire(import.meta.url)('rregex') as { + RRegex: new(pattern: string) => RustRegex +} + /** True for an absent / empty / `'*'` pattern — the match-all sentinels. */ function isMatchAll(matcher: string | undefined): boolean { return matcher === undefined || matcher === '' || matcher === '*' } -/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ -const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ +/** An exact pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ +const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/ -/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */ -function compileRegex(pattern: string): RegExp | undefined { +/** Compile one dialect's unanchored regex; invalid patterns return `undefined`. */ +function compileRegex(pattern: string, mode: MatcherMode): RegExp | RustRegex | undefined { try { - return new RegExp(pattern) + return mode === 'codex' ? new RRegex(pattern) : new RegExp(pattern) } catch (_syntaxError) { - // RegExp construction is the try's only operation, so malformed pattern - // syntax is the only expected failure. + // Regex construction is the try's only operation, so malformed syntax in + // the selected dialect is the only expected failure. return undefined } } +/** Release the WASM-backed Codex regex once a one-shot validation or match is done. */ +function disposeRegex(regex: RegExp | RustRegex): void { + if (regex instanceof RRegex) regex.free() +} + /** * 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. + * @param mode - dialect deciding which regex engine validates non-literal patterns. * @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 + if (EXACT_MATCHER.test(pattern)) return undefined + const regex = compileRegex(pattern, mode) + if (regex === undefined) return `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` + disposeRegex(regex) + return 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; - * bridge config parsers surface them through {@link matcherDiagnostic} before use. + * Whether `matcher` selects `query` under the given dialect. Literal patterns + * exact-match pipe-separated alternatives; all other patterns are unanchored + * regexes in the selected dialect. 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. + * @param mode - the dialect deciding which regex engine matches the pattern. * @returns `true` when the pattern selects the query; `false` on a non-match or an invalid * regex. */ @@ -58,8 +76,14 @@ export function matchesMatcher(matcher: string | undefined, query: string, mode: if (isMatchAll(matcher)) return true // matcher is a non-empty string past the match-all guard. const pattern = matcher as string - if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) { + if (EXACT_MATCHER.test(pattern)) { return pattern.split('|').includes(query) } - return compileRegex(pattern)?.test(query) ?? false + const regex = compileRegex(pattern, mode) + if (regex === undefined) return false + try { + return regex instanceof RRegex ? regex.isMatch(query) : regex.test(query) + } finally { + disposeRegex(regex) + } } diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index e14473b3e1..0ff6d4dc48 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -71,10 +71,10 @@ export interface MatcherGroup { } /** - * How a matcher pattern is interpreted. Claude Code uses {@link literal} when the - * pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and - * {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the - * mode for its dialect. + * How a matcher pattern is interpreted. Both dialects use an exact-match fast + * path when the pattern is purely `[A-Za-z0-9_|]+` (pipe = alternation), then + * use their native regex dialect otherwise: JavaScript for Claude Code and Rust + * `regex` for Codex. The bridge picks the mode for its dialect. */ export type MatcherMode = 'claude' | 'codex' diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index a1f794aa28..7050ab260d 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -34,11 +34,10 @@ describe('matchesMatcher — claude dialect (literal-or-regex)', () => { }) }) -describe('matchesMatcher — codex dialect (always regex)', () => { - it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => { +describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => { + it('a word pattern uses Codex exact-match semantics', () => { expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true) - // codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring - expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true) + expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(false) }) it('regex alternation and anchors work', () => { @@ -46,6 +45,14 @@ describe('matchesMatcher — codex dialect (always regex)', () => { expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true) expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false) }) + + it('uses Rust regex syntax and matching semantics', () => { + expect(matchesMatcher('(?i)bash', 'xxBASHyy', 'codex')).toBe(true) + expect(matchesMatcher('(?x)^ b a s h $ # policy matcher', 'bash', 'codex')).toBe(true) + expect(matchesMatcher('^\\p{Greek}+$', 'αβ', 'codex')).toBe(true) + // JavaScript accepts look-around, but Rust regex deliberately does not. + expect(matchesMatcher('(?=Bash)', 'Bash', 'codex')).toBe(false) + }) }) describe('matchesMatcher — invalid regex is a non-match (never throws)', () => { @@ -65,10 +72,13 @@ describe('matcherDiagnostic — parse-time diagnostics', () => { expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined() expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined() expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined() + expect(matcherDiagnostic('(?i)bash', 'codex')).toBeUndefined() + expect(matcherDiagnostic('(?x)^ b a s h $ # policy matcher', '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 "["') + expect(matcherDiagnostic('(?=Bash)', 'codex')).toBe('invalid codex regex matcher "(?=Bash)"') }) }) diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index a9c4b33562..20d5781678 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -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: e906810ed58c3d0204c618c32787af06c91cfb78 -README.zh.md: 8992cc63edf74d057114d888c396881dc8ee43d6 +README.md: 0c9a6b22d0990d87ad081db4f2690c5d97357062 +README.zh.md: d3c88a75208257585255fc36ad6cc0a7a3b5c0f0 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index e906810ed5..0c9a6b22d0 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -7,7 +7,7 @@ A cordis plugin that runs the supported subset of a user's existing **Codex** ho This bridge implements a deliberate subset of Codex's current hook protocol: - **Five of ten hook points:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. -- **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex). +- **Native Codex matcher semantics:** pure word/pipe patterns are exact alternatives; other patterns are unanchored Rust `regex` expressions (including inline flags such as `(?i)`). - **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline. - **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell). - **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 8992cc63ed..d3c88a7520 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -7,7 +7,7 @@ 该桥接实现 Codex 当前 hook 协议的一个明确子集: - **10 个 hook 点中的 5 个:** `PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。 -- **只使用正则 matcher**(没有字面快速路径;matcher 始终是未锚定正则)。 +- **原生 Codex matcher 语义:**纯 word/pipe pattern 是精确匹配的多选;其他 pattern 是未锚定的 Rust `regex` 表达式(包括 `(?i)` 等内联 flag)。 - **snake_case stdin payload**,携带 `turn_id`/`model` 额外字段,写入时**不带** 尾随换行符。 - **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。 - **没有工具前批准或改写路径**:hook 可以阻塞,但桥接不会预批准或替换工具输入。 diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index d68e2b9d0a..0c1ce5f2db 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -1,9 +1,10 @@ /** * Bridge for unmodified Codex command hooks on harness interception seams. It - * supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only - * matchers, snake_case payloads without a trailing newline, no hook environment - * or command substitution, and no pre-tool approval or rewrite path; only - * blocking decisions are honored. Shared execution and parsing live in + * supports five points (SessionStart, prompt/tool pre/post, Stop), native + * literal-or-Rust-regex matchers, snake_case payloads without a trailing + * newline, no hook environment or command substitution, and no pre-tool + * approval or rewrite path; only blocking decisions are honored. Shared + * execution and parsing live in * `dsh-hook-protocol`; see the * [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md). * @module @deepseek-ai/dsh-hooks-codex @@ -127,7 +128,7 @@ export function apply(ctx: Context, config: Config): void { // user's project rather than the server launch directory. const workdir = opts.agent?.session.header.cwd for (const group of groups) { - // Codex always interprets matchers as regexes; it has no literal fast path. + // The protocol library owns Codex's exact-literal/Rust-regex split. if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 3e9ae5617a..91f30e33d4 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -66,11 +66,11 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): } describe('hooks-codex bridge', () => { - it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => { + it('a PreToolUse hook (exit 2) honors a Rust-regex inline flag matcher', async () => { const dir = configDir() const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\necho "codex blocked it" >&2\nexit 2\n') - // Codex regex matcher: "Bash" is /Bash/ — matches the tool name "Bash". - writeHooks(dir, { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: deny }] }] }) + // `(?i)` is accepted by Rust regex but rejected by JavaScript RegExp. + writeHooks(dir, { PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: deny }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(dir, adapter) diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index 8503d13151..e15adf9e45 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -61,9 +61,9 @@ describe('parseCodexConfig', () => { expect('matcher' in config.Stop![0]!).toBe(false) }) - it('keeps a matcher when present', () => { - const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) - expect(config.PreToolUse![0]!.matcher).toBe('^Bash$') + it('keeps a valid Rust-regex matcher when present', () => { + const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) + expect(config.PreToolUse![0]!.matcher).toBe('(?i)^bash$') }) it('rejects an invalid regex matcher with its event name', () => { @@ -72,6 +72,12 @@ describe('parseCodexConfig', () => { })).toThrow('invalid codex regex matcher "[" on event "PreToolUse"') }) + it('rejects JavaScript-only regex syntax that Codex cannot execute', () => { + expect(() => parseCodexConfig({ + PreToolUse: [{ matcher: '(?=Bash)', hooks: [{ type: 'command', command: 's.sh' }] }], + })).toThrow('invalid codex regex matcher "(?=Bash)" 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' }] }], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 033f120c03..b357bf4b87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2593,6 +2593,10 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/hooks/hook-protocol: + dependencies: + rregex: + specifier: 1.12.0 + version: 1.12.0 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -9977,6 +9981,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rregex@1.12.0: + resolution: {integrity: sha512-lMRD7lU4TYrAyhrN6/3PXp6wiOtbsdVuHD9JtNsFCW7ZsRaOWQ2vVB41whpU1jWny1JTTS6aRnnkdSOUMdwFKQ==} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -15376,6 +15383,8 @@ snapshots: transitivePeerDependencies: - supports-color + rregex@1.12.0: {} + rw@1.3.3: {} sade@1.8.1: From 2fbcfa16eafb7b06ff5533e2848502a8eb4d50ce Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:09:02 -0700 Subject: [PATCH 12/31] docs(hooks): align Codex matcher authority --- .../implemented/feature/2026-06-30-hook-bridges.i18n.yaml | 4 ++-- .agents/notes/implemented/feature/2026-06-30-hook-bridges.md | 2 +- .../notes/implemented/feature/2026-06-30-hook-bridges.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index 8d57b6fdcc..a686abafe3 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -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-bridges.md -2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe -2026-06-30-hook-bridges.zh.md: 11ed3a5d177661271b30f1a58d034caa577b5348 +2026-06-30-hook-bridges.md: 42e1aaceb74f65d4d8e0bbd6008cd8fcaccb15cb +2026-06-30-hook-bridges.zh.md: 7d87950f03af4988ac1f0d7fad8d77612925b1a1 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index 99c6b1941a..42e1aaceb7 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -15,7 +15,7 @@ The framing that shapes the whole design: **a bridge is a compatibility adapter, Two independent plugins in the `packages/hooks/` group, each a function/namespace plugin (`name`/`inject`/`Config`/`apply`, NO default export — see [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)) injecting only `bash`: - **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`transcript_path`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. `transcript_path` is the persistence locator result or `''`; stdin carries a **trailing newline**. -- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape. +- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. Pure `[A-Za-z0-9_|]+` matcher patterns share the CC dialect's exact-match fast path (pipe = alternatives), while every other pattern uses Rust `regex` syntax. It emits Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras WITHOUT a trailing newline, performs no Codex plugin-env injection or config-time placeholder substitution, and has no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape. ### Outcome → Decision mapping diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 11ed3a5d17..7d87950f03 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -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`。纯 `[A-Za-z0-9_|]+` matcher pattern 与 CC 方言共享精确匹配快速路径(管道符表示多选),其他 pattern 则使用 Rust `regex` 语法。它输出 Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)且写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。 ### Outcome → Decision 映射 From d3d370e4d2a10e17c19bafda58fbd95c2895bd41 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:48:59 -0700 Subject: [PATCH 13/31] fix(hooks): reuse compiled Codex matchers --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 4 +- packages/hooks/hook-protocol/src/index.ts | 3 +- packages/hooks/hook-protocol/src/matcher.ts | 72 +++++++++++++++--- .../tests/matcher-lifecycle.spec.ts | 45 ++++++++++++ .../hooks/hook-protocol/tests/matcher.spec.ts | 27 ++++++- packages/hooks/hooks-claude/src/index.ts | 19 ++++- packages/hooks/hooks-codex/src/index.ts | 26 ++++++- .../tests/matcher-lifecycle.spec.ts | 73 +++++++++++++++++++ 13 files changed, 254 insertions(+), 31 deletions(-) create mode 100644 packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts create mode 100644 packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 24770df7cb..2de097ee74 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: 37f379a199b6e613101f76ac1700671eae1915b8 -2026-06-30-hook-protocol-lib.zh.md: 4f03ce6d9c33c16c9a12dc3dbe2a673a31eb0d85 +2026-06-30-hook-protocol-lib.md: a169b94611fa3f5b9606a100957b3146b662ee12 +2026-06-30-hook-protocol-lib.zh.md: 56e32d36dbda882ce72026f8d9425aac5b97e9eb diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 37f379a199..a169b94611 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. Pure `[A-Za-z0-9_|]+` patterns use the shared exact-match fast path (pipe = alternatives); the ONE remaining dialect axis is collapsed to `mode`: other Claude patterns use JavaScript `RegExp`, while other Codex patterns use Rust `regex`, including Rust-only syntax such as `(?i)`. 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. +- **Matcher** — `matcherDiagnostic(pattern, mode)`, `compileMatchers(patterns, mode)`, and `matchesMatcher(pattern, query, mode)`. Pure `[A-Za-z0-9_|]+` patterns use the shared exact-match fast path (pipe = alternatives); the ONE remaining dialect axis is collapsed to `mode`: other Claude patterns use JavaScript `RegExp`, while other Codex patterns use Rust `regex`, including Rust-only syntax such as `(?i)`. 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. For runtime, a bridge compiles its finite set of unique config patterns ONCE, reuses that set across hook points, and disposes it after detached runs drain on plugin teardown. This config-scoped ownership avoids a module-global cache while preventing repeated Rust/WASM construction from raising a non-shrinking memory high-water mark on every match. The one-shot predicate 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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 4f03ce6d9c..56e32d36db 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。纯 `[A-Za-z0-9_|]+` pattern 使用共享的精确匹配快速路径(管道符 = 多选);剩余的唯一方言差异收敛为 `mode`:其他 Claude pattern 使用 JavaScript `RegExp`,其他 Codex pattern 使用 Rust `regex`,包括 `(?i)` 等 Rust 专属语法。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时匹配仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 +- **Matcher** — `matcherDiagnostic(pattern, mode)`、`compileMatchers(patterns, mode)` 与 `matchesMatcher(pattern, query, mode)`。纯 `[A-Za-z0-9_|]+` pattern 使用共享的精确匹配快速路径(管道符 = 多选);剩余的唯一方言差异收敛为 `mode`:其他 Claude pattern 使用 JavaScript `RegExp`,其他 Codex pattern 使用 Rust `regex`,包括 `(?i)` 等 Rust 专属语法。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时,桥接会将配置中有限的唯一 pattern 集合只编译一次,在各 hook 点重复使用,并在插件 teardown 时先 drain 脱离运行,再释放该集合。这种配置作用域的所有权既避免模块全局缓存,也防止反复构造 Rust/WASM 正则在每次匹配时抬高且无法收缩的内存高水位。一次性谓词仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 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 解析 stdout;exit `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 按序累积。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index de3ac9a7a6..6cd84c14a8 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: e8e3e1b078f74636ee23f90a96d1e8748d7af373 -README.zh.md: 4671b179b222eea68cbcb00042d2ddcfe9a5691f +README.md: 1b4c6c3b73b0d4f3d5df06405d27b94652b6c686 +README.zh.md: 5b55034ff136129f83ae90a84b8efc89affcfce0 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index e8e3e1b078..1b4c6c3b73 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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 validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`) and rejects a config group carrying a diagnostic | +| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `compileMatchers(patterns, mode)` for repeated config-lifetime matching; `matchesMatcher(pattern, query, mode)` for one-shot contained matching | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), rejects a config group carrying a diagnostic, and disposes the compiled set on teardown | | 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 -- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). 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. +- **`matcherDiagnostic(matcher, mode)` / `compileMatchers(matchers, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). 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. Each bridge uses `compileMatchers` to compile every unique config pattern once, reuses it at every hook point, and disposes the finite set after detached runs drain on plugin teardown; this avoids the Rust/WASM allocator's non-shrinking high-water mark growing on every match. `matchesMatcher` remains the contained one-shot predicate, and invalid runtime patterns are non-matches rather than exceptions. - **`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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 4671b179b2..5b55034ff1 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于收敛的运行时匹配 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),并拒绝带有诊断的配置组 | +| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`compileMatchers(patterns, mode)` 用于配置生命周期内的重复匹配;`matchesMatcher(pattern, query, mode)` 用于一次性的收敛匹配 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),拒绝带有诊断的配置组,并在 teardown 时释放已编译集合 | | 运行 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 Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。运行时谓词仍将无效 pattern 收敛为不匹配,因此直接库调用无法向 agent loop 抛出异常。 +- **`matcherDiagnostic(matcher, mode)` / `compileMatchers(matchers, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。每个桥接通过 `compileMatchers` 将配置中每个唯一 pattern 只编译一次,在各 hook 点重复使用,并在插件 teardown 时先 drain 脱离运行,再释放这个有限集合;因此 Rust/WASM 分配器不会因每次匹配都抬高且无法收缩的内存高水位。`matchesMatcher` 保留为收敛的一次性谓词,运行时无效 pattern 仍是不匹配而非异常。 - **`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` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index d67746f824..ba38cac693 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -13,7 +13,8 @@ export type { MatcherGroup, MatcherMode, } from './types.ts' -export { matcherDiagnostic, matchesMatcher } from './matcher.ts' +export { compileMatchers, matcherDiagnostic, matchesMatcher } from './matcher.ts' +export type { CompiledMatchers } from './matcher.ts' export { parseHookOutput } from './codec.ts' export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts' export type { RunHookOptions, RunHookResult } from './runner.ts' diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index e851a62f2e..f72c61f295 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -28,6 +28,19 @@ function isMatchAll(matcher: string | undefined): boolean { /** An exact pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/ +interface CompiledMatcher { + matches(query: string): boolean + dispose(): void +} + +/** A config-lifetime matcher set compiled once and explicitly released. */ +export interface CompiledMatchers { + /** Match one of the patterns supplied to {@link compileMatchers}. */ + matches(matcher: string | undefined, query: string): boolean + /** Release every native matcher. Safe to call more than once. */ + dispose(): void +} + /** Compile one dialect's unanchored regex; invalid patterns return `undefined`. */ function compileRegex(pattern: string, mode: MatcherMode): RegExp | RustRegex | undefined { try { @@ -39,11 +52,55 @@ function compileRegex(pattern: string, mode: MatcherMode): RegExp | RustRegex | } } -/** Release the WASM-backed Codex regex once a one-shot validation or match is done. */ +/** Release a WASM-backed Codex regex when its owning matcher lifetime ends. */ function disposeRegex(regex: RegExp | RustRegex): void { if (regex instanceof RRegex) regex.free() } +/** Compile one matcher into a reusable, explicitly disposable predicate. */ +function compileMatcher(matcher: string | undefined, mode: MatcherMode): CompiledMatcher { + if (isMatchAll(matcher)) return { matches: () => true, dispose: () => {} } + const pattern = matcher as string + if (EXACT_MATCHER.test(pattern)) { + const alternatives = new Set(pattern.split('|')) + return { matches: query => alternatives.has(query), dispose: () => {} } + } + const regex = compileRegex(pattern, mode) + if (regex === undefined) return { matches: () => false, dispose: () => {} } + return { + matches: query => regex instanceof RRegex ? regex.isMatch(query) : regex.test(query), + dispose: () => { disposeRegex(regex) }, + } +} + +/** + * Compile a finite config's unique matcher patterns for repeated evaluation. + * The returned registry owns native Rust-regex allocations; its caller must + * dispose it when the config/plugin lifetime ends. + * @param matchers - the complete finite set of patterns in one loaded config. + * @param mode - the native regex dialect used for non-literal patterns. + * @returns a reusable registry that owns and disposes its compiled regexes. + */ +export function compileMatchers(matchers: Iterable, mode: MatcherMode): CompiledMatchers { + const compiled = new Map() + for (const matcher of matchers) { + if (!compiled.has(matcher)) compiled.set(matcher, compileMatcher(matcher, mode)) + } + let disposed = false + return { + matches(matcher, query) { + if (disposed) return false + return compiled.get(matcher)?.matches(query) ?? false + }, + dispose() { + if (disposed) return + disposed = true + for (const matcher of compiled.values()) matcher.dispose() + compiled.clear() + }, + } +} + /** * Validate one matcher before a bridge accepts its config group. * @param matcher - configured pattern; match-all sentinels are valid. @@ -73,17 +130,10 @@ export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode * regex. */ export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean { - if (isMatchAll(matcher)) return true - // matcher is a non-empty string past the match-all guard. - const pattern = matcher as string - if (EXACT_MATCHER.test(pattern)) { - return pattern.split('|').includes(query) - } - const regex = compileRegex(pattern, mode) - if (regex === undefined) return false + const compiled = compileMatcher(matcher, mode) try { - return regex instanceof RRegex ? regex.isMatch(query) : regex.test(query) + return compiled.matches(query) } finally { - disposeRegex(regex) + compiled.dispose() } } diff --git a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts new file mode 100644 index 0000000000..0988bfaea7 --- /dev/null +++ b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts @@ -0,0 +1,45 @@ +import { createRequire } from 'node:module' +import { describe, expect, it, vi } from 'vitest' +import type { RRegex as RustRegex } from 'rregex' + +describe('compileMatchers — native regex lifecycle', () => { + it('constructs each unique Codex regex once across repeated matches and frees it once', async () => { + const require = createRequire(import.meta.url) + const rregex = require('rregex') as { RRegex: new(pattern: string) => RustRegex } + const OriginalRRegex = rregex.RRegex + const construct = vi.fn<(pattern: string) => void>() + const free = vi.fn<() => void>() + + class CountingRRegex extends OriginalRRegex { + constructor(pattern: string) { + super(pattern) + construct(pattern) + } + + override free(): void { + free() + super.free() + } + } + + rregex.RRegex = CountingRRegex + vi.resetModules() + try { + const { compileMatchers } = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts') + const matchers = compileMatchers(['(?i)^bash$', '(?i)^bash$', '^write$'], 'codex') + expect(construct.mock.calls.map(([pattern]) => pattern)).toEqual(['(?i)^bash$', '^write$']) + + for (let i = 0; i < 1_000; i++) { + expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) + } + expect(construct).toHaveBeenCalledTimes(2) + + matchers.dispose() + matchers.dispose() + expect(free).toHaveBeenCalledTimes(2) + } finally { + rregex.RRegex = OriginalRRegex + vi.resetModules() + } + }) +}) diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index 7050ab260d..aa9235a003 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol' +import { compileMatchers, matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol' describe('matchesMatcher — match-all sentinels (both dialects)', () => { for (const mode of ['claude', 'codex'] as const) { @@ -82,3 +82,28 @@ describe('matcherDiagnostic — parse-time diagnostics', () => { expect(matcherDiagnostic('(?=Bash)', 'codex')).toBe('invalid codex regex matcher "(?=Bash)"') }) }) + +describe('compileMatchers — config-lifetime reuse', () => { + it('compiles a finite set, contains unknown patterns, and stops after disposal', () => { + const matchers = compileMatchers([undefined, 'Edit|Write', '(?i)^bash$', '['], 'codex') + + expect(matchers.matches(undefined, 'anything')).toBe(true) + expect(matchers.matches('Edit|Write', 'Write')).toBe(true) + expect(matchers.matches('Edit|Write', 'WriteFile')).toBe(false) + expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) + expect(matchers.matches('[', 'anything')).toBe(false) + expect(matchers.matches('not-compiled', 'not-compiled')).toBe(false) + + matchers.dispose() + expect(matchers.matches(undefined, 'anything')).toBe(false) + expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(false) + expect(() => { matchers.dispose() }).not.toThrow() + }) + + it('reuses JavaScript regexes too', () => { + const matchers = compileMatchers(['^Bash$', '^Bash$'], 'claude') + expect(matchers.matches('^Bash$', 'Bash')).toBe(true) + expect(matchers.matches('^Bash$', 'BashOutput')).toBe(false) + matchers.dispose() + }) +}) diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 8552598818..2df88410ad 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -21,10 +21,10 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes import { appendHookInvoked, appendHookResult, + compileMatchers, createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, - matchesMatcher, mergeHookOutputs, runHook, type HookOutput, @@ -116,10 +116,21 @@ export function apply(ctx: Context, config: Config): void { return } + const matchers = compileMatchers( + Object.values(parsed).flatMap(groups => groups.map(group => group.matcher)), + 'claude', + ) + // Emit-shaped points run detached, so track their chains; disposal aborts - // active hooks and drains continuations before resolving. + // active hooks and drains continuations before releasing matchers. const detached = createDetachedRuns() - ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs') + ctx.effect(() => async () => { + try { + await detached.drain() + } finally { + matchers.dispose() + } + }, 'hooks-claude: drain detached hook runs and dispose matchers') /** * Run every command hook configured for `point` whose matcher selects @@ -147,7 +158,7 @@ export function apply(ctx: Context, config: Config): void { const projectDir = config.projectDir ?? workdir const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined for (const group of groups) { - if (!matchesMatcher(group.matcher, matchQuery, 'claude')) continue + if (!matchers.matches(group.matcher, matchQuery)) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) const session = opts.agent?.session diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 0c1ce5f2db..ad6d528f53 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -25,10 +25,10 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes import { appendHookInvoked, appendHookResult, + compileMatchers, createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, - matchesMatcher, mergeHookOutputs, runHook, type HookOutput, @@ -99,11 +99,26 @@ export function apply(ctx: Context, config: Config): void { const model = config.model ?? '' + // Compile each distinct config matcher once. In particular, rebuilding an + // rregex WASM value on every hook point permanently raises the module's WASM + // memory high-water mark even when each value is freed. + const matchers = compileMatchers( + Object.values(parsed).flatMap(groups => groups.map(group => group.matcher)), + 'codex', + ) + // SessionStart is the one emit-shaped (detached) point Codex has: track its // run chains so disposal aborts a still-running hook process and drains the - // continuation (docs/defensive-patterns.md: dispose must reach quiescence). + // continuation before releasing matchers (docs/defensive-patterns.md: + // dispose must reach quiescence). const detached = createDetachedRuns() - ctx.effect(() => () => detached.drain(), 'hooks-codex: drain detached hook runs') + ctx.effect(() => async () => { + try { + await detached.drain() + } finally { + matchers.dispose() + } + }, 'hooks-codex: drain detached hook runs and dispose matchers') /** * Run and fold one configured Codex hook point. @@ -127,9 +142,11 @@ export function apply(ctx: Context, config: Config): void { // Run hooks in the agent's session workspace so relative paths address the // user's project rather than the server launch directory. const workdir = opts.agent?.session.header.cwd + // Keep each dialect's audit stamping readable beside its payload mapping. + /* jscpd:ignore-start */ for (const group of groups) { // The protocol library owns Codex's exact-literal/Rust-regex split. - if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue + if (!matchers.matches(group.matcher, matchQuery)) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) const session = opts.agent?.session @@ -139,6 +156,7 @@ export function apply(ctx: Context, config: Config): void { ...group.matcher !== undefined ? { matcher: group.matcher } : {}, }) } + /* jscpd:ignore-end */ const { output, durationMs } = await runHook(ctx.bash, hook, { payload, defaultTimeoutMs, diff --git a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts new file mode 100644 index 0000000000..874daddd90 --- /dev/null +++ b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts @@ -0,0 +1,73 @@ +import { createRequire } from 'node:module' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' + +interface RustRegexInstance { + free(): void +} + +const dirs: string[] = [] +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) + +describe('hooks-codex matcher lifecycle', () => { + it('constructs one reusable runtime regex and frees it on plugin teardown', async () => { + // The product deliberately loads rregex through createRequire so Cordis can + // discover the bridge synchronously. Patch that SAME CJS export, rather + // than an ESM mock that would not observe the production load path. + const require = createRequire(new URL('../../hook-protocol/package.json', import.meta.url)) + const rregex = require('rregex') as { RRegex: new(pattern: string) => RustRegexInstance } + const OriginalRRegex = rregex.RRegex + const construct = vi.fn<(pattern: string) => void>() + const free = vi.fn<() => void>() + + class CountingRRegex extends OriginalRRegex { + constructor(pattern: string) { + super(pattern) + construct(pattern) + } + + override free(): void { + free() + super.free() + } + } + + const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-matchers-')) + dirs.push(dir) + const configPath = join(dir, 'hooks.json') + writeFileSync(configPath, JSON.stringify({ hooks: { + PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'true' }] }], + PostToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'true' }] }], + } })) + + rregex.RRegex = CountingRRegex + vi.resetModules() + try { + const HooksCodex = await import('@deepseek-ai/dsh-hooks-codex') + const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + const fiber = await ctx.plugin(HooksCodex, { configPath, model: 'm' }) + + // The parser validates both groups one-shot (2 construct/free pairs), then + // the runtime registry compiles the duplicate pattern only once and owns it. + expect(construct.mock.calls.map(([pattern]) => pattern)).toEqual([ + '(?i)^bash$', + '(?i)^bash$', + '(?i)^bash$', + ]) + expect(free).toHaveBeenCalledTimes(2) + + await fiber.dispose() + expect(free).toHaveBeenCalledTimes(3) + } finally { + rregex.RRegex = OriginalRRegex + vi.resetModules() + } + }) +}) From 7121d25c8a678238f1f2747774abe7ed530e63d0 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:56:03 -0700 Subject: [PATCH 14/31] test(hooks): keep regex lifecycle ownership explicit --- .../tests/matcher-lifecycle.spec.ts | 81 ++++++++----------- 1 file changed, 32 insertions(+), 49 deletions(-) diff --git a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts index 874daddd90..d87d49ccd1 100644 --- a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts +++ b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts @@ -1,4 +1,3 @@ -import { createRequire } from 'node:module' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -7,36 +6,30 @@ import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' -interface RustRegexInstance { - free(): void -} +const matcherLifecycle = vi.hoisted(() => { + const registry = { + matches: vi.fn(() => true), + dispose: vi.fn<() => void>(), + } + return { + registry, + compileMatchers: vi.fn(() => registry), + } +}) + +vi.mock('@deepseek-ai/dsh-hook-protocol', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, compileMatchers: matcherLifecycle.compileMatchers } +}) const dirs: string[] = [] -afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + vi.clearAllMocks() +}) describe('hooks-codex matcher lifecycle', () => { - it('constructs one reusable runtime regex and frees it on plugin teardown', async () => { - // The product deliberately loads rregex through createRequire so Cordis can - // discover the bridge synchronously. Patch that SAME CJS export, rather - // than an ESM mock that would not observe the production load path. - const require = createRequire(new URL('../../hook-protocol/package.json', import.meta.url)) - const rregex = require('rregex') as { RRegex: new(pattern: string) => RustRegexInstance } - const OriginalRRegex = rregex.RRegex - const construct = vi.fn<(pattern: string) => void>() - const free = vi.fn<() => void>() - - class CountingRRegex extends OriginalRRegex { - constructor(pattern: string) { - super(pattern) - construct(pattern) - } - - override free(): void { - free() - super.free() - } - } - + it('gives the loaded config one matcher registry and disposes it on plugin teardown', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-matchers-')) dirs.push(dir) const configPath = join(dir, 'hooks.json') @@ -45,29 +38,19 @@ describe('hooks-codex matcher lifecycle', () => { PostToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'true' }] }], } })) - rregex.RRegex = CountingRRegex - vi.resetModules() - try { - const HooksCodex = await import('@deepseek-ai/dsh-hooks-codex') - const ctx = new Context() - await ctx.plugin(LocalSubprocessService) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - const fiber = await ctx.plugin(HooksCodex, { configPath, model: 'm' }) + const HooksCodex = await import('@deepseek-ai/dsh-hooks-codex') + const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) + const fiber = await ctx.plugin(HooksCodex, { configPath, model: 'm' }) - // The parser validates both groups one-shot (2 construct/free pairs), then - // the runtime registry compiles the duplicate pattern only once and owns it. - expect(construct.mock.calls.map(([pattern]) => pattern)).toEqual([ - '(?i)^bash$', - '(?i)^bash$', - '(?i)^bash$', - ]) - expect(free).toHaveBeenCalledTimes(2) + expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith([ + '(?i)^bash$', + '(?i)^bash$', + ], 'codex') + expect(matcherLifecycle.registry.dispose).not.toHaveBeenCalled() - await fiber.dispose() - expect(free).toHaveBeenCalledTimes(3) - } finally { - rregex.RRegex = OriginalRRegex - vi.resetModules() - } + await fiber.dispose() + expect(matcherLifecycle.registry.dispose).toHaveBeenCalledOnce() }) }) From ec72d0b57e526070239ff75ac557afe228aee193 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:10:21 -0700 Subject: [PATCH 15/31] fix(hooks): share matcher validation instances --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- docs/config-catalog.md | 4 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 4 +- packages/hooks/hook-protocol/src/matcher.ts | 32 ++++--- .../tests/matcher-lifecycle.spec.ts | 1 + .../hooks/hook-protocol/tests/matcher.spec.ts | 4 + packages/hooks/hooks-claude/src/config.ts | 86 +++++++++++------- packages/hooks/hooks-claude/src/index.ts | 23 +++-- .../hooks/hooks-claude/tests/config.spec.ts | 20 ++++- packages/hooks/hooks-codex/src/config.ts | 88 +++++++++++-------- packages/hooks/hooks-codex/src/index.ts | 27 +++--- .../hooks/hooks-codex/tests/config.spec.ts | 16 +++- .../tests/matcher-lifecycle.spec.ts | 28 +++++- 17 files changed, 221 insertions(+), 128 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 2de097ee74..48e43071b4 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: a169b94611fa3f5b9606a100957b3146b662ee12 -2026-06-30-hook-protocol-lib.zh.md: 56e32d36dbda882ce72026f8d9425aac5b97e9eb +2026-06-30-hook-protocol-lib.md: 611acd88547456514375e6850698c4c5d974c989 +2026-06-30-hook-protocol-lib.zh.md: 28dce0365775b6142406ffdda82b643b5038e606 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index a169b94611..611acd8854 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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** — `matcherDiagnostic(pattern, mode)`, `compileMatchers(patterns, mode)`, and `matchesMatcher(pattern, query, mode)`. Pure `[A-Za-z0-9_|]+` patterns use the shared exact-match fast path (pipe = alternatives); the ONE remaining dialect axis is collapsed to `mode`: other Claude patterns use JavaScript `RegExp`, while other Codex patterns use Rust `regex`, including Rust-only syntax such as `(?i)`. 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. For runtime, a bridge compiles its finite set of unique config patterns ONCE, reuses that set across hook points, and disposes it after detached runs drain on plugin teardown. This config-scoped ownership avoids a module-global cache while preventing repeated Rust/WASM construction from raising a non-shrinking memory high-water mark on every match. The one-shot predicate still contains an invalid regex as a non-match, so a direct library caller never throws into the loop. +- **Matcher** — `compileMatchers(patterns, mode)`, `matcherDiagnostic(pattern, mode)`, and `matchesMatcher(pattern, query, mode)`. Pure `[A-Za-z0-9_|]+` patterns use the shared exact-match fast path (pipe = alternatives); the ONE remaining dialect axis is collapsed to `mode`: other Claude patterns use JavaScript `RegExp`, while other Codex patterns use Rust `regex`, including Rust-only syntax such as `(?i)`. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, collects the remaining runnable groups, and compiles their finite set of unique patterns ONCE. It reads validation diagnostics from that compiled registry: an invalid regex causes whole-config rejection after the registry is disposed, while a valid parse returns the SAME registry for hook-point matching and plugin-teardown disposal after detached runs drain. The stable diagnostic still names dialect/pattern/event and no hook listeners are registered on failure. This config-scoped ownership avoids both a module-global cache and separate validation/runtime Rust/WASM construction, whose non-shrinking allocator raises its memory high-water mark on every construction. The one-shot helpers remain contained, 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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 56e32d36db..28dce03657 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `matcherDiagnostic(pattern, mode)`、`compileMatchers(patterns, mode)` 与 `matchesMatcher(pattern, query, mode)`。纯 `[A-Za-z0-9_|]+` pattern 使用共享的精确匹配快速路径(管道符 = 多选);剩余的唯一方言差异收敛为 `mode`:其他 Claude pattern 使用 JavaScript `RegExp`,其他 Codex pattern 使用 Rust `regex`,包括 `(?i)` 等 Rust 专属语法。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带 matcher 字段,再校验剩余的可运行 group;其中的无效正则会导致整份配置加载失败,并输出稳定的方言/模式/事件诊断,且不注册任何 hook 监听器。运行时,桥接会将配置中有限的唯一 pattern 集合只编译一次,在各 hook 点重复使用,并在插件 teardown 时先 drain 脱离运行,再释放该集合。这种配置作用域的所有权既避免模块全局缓存,也防止反复构造 Rust/WASM 正则在每次匹配时抬高且无法收缩的内存高水位。一次性谓词仍将无效正则收敛为不匹配,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 +- **Matcher** — `compileMatchers(patterns, mode)`、`matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。纯 `[A-Za-z0-9_|]+` pattern 使用共享的精确匹配快速路径(管道符 = 多选);剩余的唯一方言差异收敛为 `mode`:其他 Claude pattern 使用 JavaScript `RegExp`,其他 Codex pattern 使用 Rust `regex`,包括 `(?i)` 等 Rust 专属语法。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,收集其余可运行 group,再将其中有限的唯一 pattern 集合只编译一次。它直接从该 registry 读取校验诊断:无效正则会在释放 registry 后导致整份配置加载失败;有效解析则把同一个 registry 交给各 hook 点匹配,并在插件 teardown 时先 drain 脱离运行,再释放它。稳定诊断仍包含方言/模式/事件,失败时不会注册任何 hook 监听器。这种配置作用域的所有权既避免模块全局缓存,也避免校验和运行时分别构造 Rust/WASM 正则;其无法收缩的分配器会在每次构造时抬高内存高水位。一次性 helper 仍是收敛的,因此直接调用本库时绝不向 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 解析 stdout;exit `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 按序累积。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cdcfdd0fd2..f49ae65158 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -478,7 +478,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:46`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:45`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -503,7 +503,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-host-apiproxy` diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 6cd84c14a8..8c879474c8 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: 1b4c6c3b73b0d4f3d5df06405d27b94652b6c686 -README.zh.md: 5b55034ff136129f83ae90a84b8efc89affcfce0 +README.md: 36607ed9b98a97288690c869e58ee1d45ba765c4 +README.zh.md: 5e1abce23aea65f670bde8c8c5d74c40f6afd07e diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 1b4c6c3b73..36607ed9b9 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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 validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `compileMatchers(patterns, mode)` for repeated config-lifetime matching; `matchesMatcher(pattern, query, mode)` for one-shot contained matching | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), rejects a config group carrying a diagnostic, and disposes the compiled set on teardown | +| Matcher validation + test | `compileMatchers(patterns, mode)` exposes diagnostics and repeated config-lifetime matching from one compiled set; `matcherDiagnostic` / `matchesMatcher` are contained one-shot helpers | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), compiles the unique runnable patterns once, rejects a group carrying a registry diagnostic, and disposes that same set on failure or teardown | | 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 -- **`matcherDiagnostic(matcher, mode)` / `compileMatchers(matchers, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). 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. Each bridge uses `compileMatchers` to compile every unique config pattern once, reuses it at every hook point, and disposes the finite set after detached runs drain on plugin teardown; this avoids the Rust/WASM allocator's non-shrinking high-water mark growing on every match. `matchesMatcher` remains the contained one-shot predicate, and invalid runtime patterns are non-matches rather than exceptions. +- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). A bridge parser first discards matcher fields for events without matcher subjects and collects the remaining runnable groups, then compiles their unique patterns once. It reads `registry.diagnostic(pattern)` from those exact instances, disposes the registry before throwing on an invalid consumed regex, or returns the same registry for runtime matching. The plugin reuses it at every hook point and disposes it after detached runs drain on teardown. Thus neither validation nor matching reconstructs a Rust/WASM regex and raises its non-shrinking memory high-water mark. `matcherDiagnostic` and `matchesMatcher` remain contained one-shot helpers; invalid runtime patterns are non-matches rather than exceptions. - **`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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 5b55034ff1..5e1abce23a 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`compileMatchers(patterns, mode)` 用于配置生命周期内的重复匹配;`matchesMatcher(pattern, query, mode)` 用于一次性的收敛匹配 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),拒绝带有诊断的配置组,并在 teardown 时释放已编译集合 | +| Matcher 校验 + 测试 | `compileMatchers(patterns, mode)` 从同一已编译集合提供诊断与配置生命周期内的重复匹配;`matcherDiagnostic`/`matchesMatcher` 是收敛的一次性 helper | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),将可运行的唯一 pattern 只编译一次,拒绝带有 registry 诊断的配置组,并在失败或 teardown 时释放同一集合 | | 运行 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 Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`matcherDiagnostic(matcher, mode)` / `compileMatchers(matchers, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会丢弃没有 matcher 匹配对象的事件所带 matcher 字段,再使用 `matcherDiagnostic` 在注册任何 hook 之前拒绝实际会被消费的无效正则,并输出稳定诊断。每个桥接通过 `compileMatchers` 将配置中每个唯一 pattern 只编译一次,在各 hook 点重复使用,并在插件 teardown 时先 drain 脱离运行,再释放这个有限集合;因此 Rust/WASM 分配器不会因每次匹配都抬高且无法收缩的内存高水位。`matchesMatcher` 保留为收敛的一次性谓词,运行时无效 pattern 仍是不匹配而非异常。 +- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会先丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行 group,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`;实际消费的正则无效时,会先释放 registry 再抛错,否则把同一 registry 交给运行时。插件会在各 hook 点重复使用它,并在 teardown 时先 drain 脱离运行,再释放该集合。因此校验和匹配都不会重复构造 Rust/WASM 正则并抬高其无法收缩的内存高水位。`matcherDiagnostic` 与 `matchesMatcher` 保留为收敛的一次性 helper;运行时无效 pattern 仍是不匹配而非异常。 - **`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` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index f72c61f295..ad2792379b 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -3,8 +3,8 @@ * pipe patterns as literal alternatives and other patterns as regex. Codex * uses the same literal fast path, then compiles regex patterns with Rust's * `regex` dialect. Missing, empty, and `*` match all. Runtime matching contains - * invalid regexes as non-matches; config parsers use {@link matcherDiagnostic} - * to reject them with a diagnostic. + * invalid regexes as non-matches. A compiled config registry exposes the same + * stable diagnostic without constructing a second native regex. * @module @deepseek-ai/dsh-hook-protocol/matcher */ @@ -30,6 +30,7 @@ const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/ interface CompiledMatcher { matches(query: string): boolean + diagnostic?: string dispose(): void } @@ -37,6 +38,8 @@ interface CompiledMatcher { export interface CompiledMatchers { /** Match one of the patterns supplied to {@link compileMatchers}. */ matches(matcher: string | undefined, query: string): boolean + /** Diagnose one supplied pattern using the already-compiled instance. */ + diagnostic(matcher: string | undefined): string | undefined /** Release every native matcher. Safe to call more than once. */ dispose(): void } @@ -66,7 +69,13 @@ function compileMatcher(matcher: string | undefined, mode: MatcherMode): Compile return { matches: query => alternatives.has(query), dispose: () => {} } } const regex = compileRegex(pattern, mode) - if (regex === undefined) return { matches: () => false, dispose: () => {} } + if (regex === undefined) { + return { + matches: () => false, + diagnostic: `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`, + dispose: () => {}, + } + } return { matches: query => regex instanceof RRegex ? regex.isMatch(query) : regex.test(query), dispose: () => { disposeRegex(regex) }, @@ -92,6 +101,10 @@ export function compileMatchers(matchers: Iterable, mode: Ma if (disposed) return false return compiled.get(matcher)?.matches(query) ?? false }, + diagnostic(matcher) { + if (disposed) return undefined + return compiled.get(matcher)?.diagnostic + }, dispose() { if (disposed) return disposed = true @@ -108,13 +121,12 @@ export function compileMatchers(matchers: Iterable, mode: Ma * @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 (EXACT_MATCHER.test(pattern)) return undefined - const regex = compileRegex(pattern, mode) - if (regex === undefined) return `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` - disposeRegex(regex) - return undefined + const compiled = compileMatcher(matcher, mode) + try { + return compiled.diagnostic + } finally { + compiled.dispose() + } } /** diff --git a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts index 0988bfaea7..9e40e6db89 100644 --- a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts @@ -30,6 +30,7 @@ describe('compileMatchers — native regex lifecycle', () => { expect(construct.mock.calls.map(([pattern]) => pattern)).toEqual(['(?i)^bash$', '^write$']) for (let i = 0; i < 1_000; i++) { + expect(matchers.diagnostic('(?i)^bash$')).toBeUndefined() expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) } expect(construct).toHaveBeenCalledTimes(2) diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index aa9235a003..959b5070e0 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -93,10 +93,14 @@ describe('compileMatchers — config-lifetime reuse', () => { expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) expect(matchers.matches('[', 'anything')).toBe(false) expect(matchers.matches('not-compiled', 'not-compiled')).toBe(false) + expect(matchers.diagnostic('(?i)^bash$')).toBeUndefined() + expect(matchers.diagnostic('[')).toBe('invalid codex regex matcher "["') + expect(matchers.diagnostic('not-compiled')).toBeUndefined() matchers.dispose() expect(matchers.matches(undefined, 'anything')).toBe(false) expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(false) + expect(matchers.diagnostic('[')).toBeUndefined() expect(() => { matchers.dispose() }).not.toThrow() }) diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 2650e940c2..8d6b9b5a4f 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -6,7 +6,11 @@ * @module @deepseek-ai/dsh-hooks-claude/config */ -import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' +import { + compileMatchers, + type CompiledMatchers, + type MatcherGroup, +} from '@deepseek-ai/dsh-hook-protocol' const CLAUDE_EVENTS = [ 'SessionStart', @@ -31,6 +35,8 @@ export interface SkippedHook { export interface ParsedClaudeConfig { config: ClaudeHookConfig skipped: SkippedHook[] + /** Config-scoped matcher registry; the caller owns and must dispose it. */ + matchers: CompiledMatchers } /** Substitution variables applied to each `command` string at parse time. */ @@ -68,6 +74,7 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri * 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. + * Validation and runtime matching share the returned compiled registry; its caller must dispose it. * * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare * event map. @@ -81,43 +88,54 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa // Accept either `{ hooks: { … } }` (a settings file) or the bare event map. const root = asObject(raw) const hooksMap = root ? asObject(root.hooks) ?? root : undefined - if (!hooksMap) return { config, skipped } - - for (const event of CLAUDE_EVENTS) { - const rawGroups = hooksMap[event] - if (!Array.isArray(rawGroups)) continue - const groups: MatcherGroup[] = [] - for (const rawGroup of rawGroups) { - const group = asObject(rawGroup) - if (!group || !Array.isArray(group.hooks)) continue - const commands: MatcherGroup['hooks'] = [] - for (const rawHook of group.hooks) { - const hook = asObject(rawHook) - if (!hook) continue - const type = typeof hook.type === 'string' ? hook.type : 'command' - if (type !== 'command') { - skipped.push({ event, type }) - continue + if (hooksMap) { + for (const event of CLAUDE_EVENTS) { + const rawGroups = hooksMap[event] + if (!Array.isArray(rawGroups)) continue + const groups: MatcherGroup[] = [] + for (const rawGroup of rawGroups) { + const group = asObject(rawGroup) + if (!group || !Array.isArray(group.hooks)) continue + const commands: MatcherGroup['hooks'] = [] + for (const rawHook of group.hooks) { + const hook = asObject(rawHook) + if (!hook) continue + const type = typeof hook.type === 'string' ? hook.type : 'command' + if (type !== 'command') { + skipped.push({ event, type }) + continue + } + if (typeof hook.command !== 'string') continue + commands.push({ + command: substituteCommand(hook.command, vars), + ...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}, + }) } - if (typeof hook.command !== 'string') continue - commands.push({ - command: substituteCommand(hook.command, vars), - ...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}, + if (commands.length === 0) continue + const matcher = event === 'UserPromptSubmit' || event === 'Stop' + ? undefined + : typeof group.matcher === 'string' ? group.matcher : undefined + groups.push({ + ...matcher !== undefined ? { matcher } : {}, + hooks: commands, }) } - 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({ - ...matcher !== undefined ? { matcher } : {}, - hooks: commands, - }) + if (groups.length > 0) config[event] = groups } - if (groups.length > 0) config[event] = groups } - return { config, skipped } + /* jscpd:ignore-start -- dialect-local event diagnostics intentionally stay beside parsing. */ + const entries = Object.entries(config).flatMap(([event, groups]) => ( + groups.map(group => ({ event, matcher: group.matcher })) + )) + const matchers = compileMatchers(new Set(entries.map(entry => entry.matcher)), 'claude') + for (const { event, matcher } of entries) { + const diagnostic = matchers.diagnostic(matcher) + if (diagnostic === undefined) continue + matchers.dispose() + throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) + } + /* jscpd:ignore-end */ + + return { config, skipped, matchers } } diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 2df88410ad..d41419b185 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -21,7 +21,6 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes import { appendHookInvoked, appendHookResult, - compileMatchers, createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, @@ -35,7 +34,7 @@ import { // declarations (declaration-merged into cordis `Events` by dsh-subagent) so the // SubagentStart/SubagentStop listeners below type-check. import type {} from '@deepseek-ai/dsh-subagent' -import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts' +import { parseClaudeConfig, type ParsedClaudeConfig } from './config.ts' export const name = 'hooks-claude' // `bash` is required to run hooks; the rest are read opportunistically via @@ -100,26 +99,22 @@ export function apply(ctx: Context, config: Config): void { assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS // Parse once at load. A read or parse failure logs and registers nothing. - let parsed: ClaudeHookConfig = {} + let result: ParsedClaudeConfig try { const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) - const result = parseClaudeConfig(raw, { + result = parseClaudeConfig(raw, { ...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {}, ...config.projectDir !== undefined ? { projectDir: config.projectDir } : {}, }) - parsed = result.config - for (const s of result.skipped) { - ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`) - } } catch (error: unknown) { ctx.logger.warn(`hooks-claude: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`) return } - const matchers = compileMatchers( - Object.values(parsed).flatMap(groups => groups.map(group => group.matcher)), - 'claude', - ) + const parsed = result.config + // Parsing validates through this same registry, so admission and runtime do + // not construct separate matcher instances. + const matchers = result.matchers // Emit-shaped points run detached, so track their chains; disposal aborts // active hooks and drains continuations before releasing matchers. @@ -132,6 +127,10 @@ export function apply(ctx: Context, config: Config): void { } }, 'hooks-claude: drain detached hook runs and dispose matchers') + for (const s of result.skipped) { + ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`) + } + /** * Run every command hook configured for `point` whose matcher selects * `matchQuery`, with the per-event `payload` on stdin, and fold the results. diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts index 343fd6730e..277924fbd3 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from 'vitest' -import { parseClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts' +import { afterEach, describe, expect, it } from 'vitest' +import { parseClaudeConfig as parseRawClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts' + +const matcherSets: Array['matchers']> = [] +afterEach(() => { for (const matchers of matcherSets.splice(0)) matchers.dispose() }) + +function parseClaudeConfig(...args: Parameters): ReturnType { + const result = parseRawClaudeConfig(...args) + matcherSets.push(result.matchers) + return result +} describe('substituteCommand', () => { it('replaces CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR (all occurrences)', () => { @@ -64,6 +73,13 @@ describe('parseClaudeConfig', () => { expect('matcher' in config.Stop![0]!).toBe(false) }) + it('returns the same validated matcher registry for runtime use', () => { + const { matchers } = parseClaudeConfig({ + PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'x.sh' }] }], + }) + expect(matchers.matches('^Bash$', 'Bash')).toBe(true) + }) + it('rejects an invalid regex matcher with its event name', () => { expect(() => parseClaudeConfig({ PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }], diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index ae82340ad4..6279473d91 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -5,7 +5,11 @@ * @module @deepseek-ai/dsh-hooks-codex/config */ -import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' +import { + compileMatchers, + type CompiledMatchers, + 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 @@ -23,6 +27,8 @@ export interface SkippedHook { export interface ParsedCodexConfig { config: CodexHookConfig skipped: SkippedHook[] + /** Config-scoped matcher registry; the caller owns and must dispose it. */ + matchers: CompiledMatchers } function asObject(value: unknown): Record | undefined { @@ -36,7 +42,8 @@ function asObject(value: unknown): Record | undefined { * 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. + * to reject the complete config before listener registration. Validation and runtime matching + * share the returned compiled registry; its caller must dispose it. * @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. */ @@ -45,42 +52,51 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig { const skipped: SkippedHook[] = [] const root = asObject(raw) const hooksMap = root ? asObject(root.hooks) ?? root : undefined - if (!hooksMap) return { config, skipped } - - for (const event of CODEX_EVENTS) { - const rawGroups = hooksMap[event] - // Matcher-group parsing remains dialect-local because the supported hook - // shapes and skip reasons differ from Claude Code's. - /* jscpd:ignore-start */ - if (!Array.isArray(rawGroups)) continue - const groups: MatcherGroup[] = [] - for (const rawGroup of rawGroups) { - const group = asObject(rawGroup) - if (!group || !Array.isArray(group.hooks)) continue - const commands: MatcherGroup['hooks'] = [] - for (const rawHook of group.hooks) { - const hook = asObject(rawHook) - if (!hook) continue - const type = typeof hook.type === 'string' ? hook.type : 'command' - if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue } - /* jscpd:ignore-end */ - if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue } - if (typeof hook.command !== 'string') continue - // Codex accepts `timeout` or the `timeoutSec` alias. - const timeout = typeof hook.timeout === 'number' ? hook.timeout - : typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined - commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) + if (hooksMap) { + for (const event of CODEX_EVENTS) { + const rawGroups = hooksMap[event] + // Matcher-group parsing remains dialect-local because the supported hook + // shapes and skip reasons differ from Claude Code's. + /* jscpd:ignore-start */ + if (!Array.isArray(rawGroups)) continue + const groups: MatcherGroup[] = [] + for (const rawGroup of rawGroups) { + const group = asObject(rawGroup) + if (!group || !Array.isArray(group.hooks)) continue + const commands: MatcherGroup['hooks'] = [] + for (const rawHook of group.hooks) { + const hook = asObject(rawHook) + if (!hook) continue + const type = typeof hook.type === 'string' ? hook.type : 'command' + if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue } + /* jscpd:ignore-end */ + if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue } + if (typeof hook.command !== 'string') continue + // Codex accepts `timeout` or the `timeoutSec` alias. + const timeout = typeof hook.timeout === 'number' ? hook.timeout + : typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined + commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) + } + if (commands.length === 0) continue + const matcher = event === 'UserPromptSubmit' || event === 'Stop' + ? undefined + : typeof group.matcher === 'string' ? group.matcher : undefined + groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands }) } - if (commands.length === 0) continue - 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 } - if (groups.length > 0) config[event] = groups } - return { config, skipped } + const entries = Object.entries(config).flatMap(([event, groups]) => ( + groups.map(group => ({ event, matcher: group.matcher })) + )) + const matchers = compileMatchers(new Set(entries.map(entry => entry.matcher)), 'codex') + for (const { event, matcher } of entries) { + const diagnostic = matchers.diagnostic(matcher) + if (diagnostic === undefined) continue + matchers.dispose() + throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) + } + + return { config, skipped, matchers } } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index ad6d528f53..b4089a48ec 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -25,7 +25,6 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes import { appendHookInvoked, appendHookResult, - compileMatchers, createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, @@ -35,7 +34,7 @@ import { type MatcherGroup, type MergedHookOutcome, } from '@deepseek-ai/dsh-hook-protocol' -import { parseCodexConfig, type CodexHookConfig } from './config.ts' +import { parseCodexConfig, type ParsedCodexConfig } from './config.ts' /* jscpd:ignore-end */ export const name = 'hooks-codex' @@ -84,28 +83,20 @@ export function apply(ctx: Context, config: Config): void { const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS - let parsed: CodexHookConfig = {} + let result: ParsedCodexConfig try { const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) - const result = parseCodexConfig(raw) - parsed = result.config - for (const s of result.skipped) { - ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`) - } + result = parseCodexConfig(raw) } catch (error: unknown) { ctx.logger.warn(`hooks-codex: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`) return } + const parsed = result.config const model = config.model ?? '' - - // Compile each distinct config matcher once. In particular, rebuilding an - // rregex WASM value on every hook point permanently raises the module's WASM - // memory high-water mark even when each value is freed. - const matchers = compileMatchers( - Object.values(parsed).flatMap(groups => groups.map(group => group.matcher)), - 'codex', - ) + // Parsing validates through this same registry, so no native regex is rebuilt + // between config admission and runtime matching. + const matchers = result.matchers // SessionStart is the one emit-shaped (detached) point Codex has: track its // run chains so disposal aborts a still-running hook process and drains the @@ -120,6 +111,10 @@ export function apply(ctx: Context, config: Config): void { } }, 'hooks-codex: drain detached hook runs and dispose matchers') + for (const s of result.skipped) { + ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`) + } + /** * Run and fold one configured Codex hook point. * diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index e15adf9e45..541365a531 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from 'vitest' -import { parseCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts' +import { afterEach, describe, expect, it } from 'vitest' +import { parseCodexConfig as parseRawCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts' + +const matcherSets: Array['matchers']> = [] +afterEach(() => { for (const matchers of matcherSets.splice(0)) matchers.dispose() }) + +function parseCodexConfig(...args: Parameters): ReturnType { + const result = parseRawCodexConfig(...args) + matcherSets.push(result.matchers) + return result +} describe('parseCodexConfig', () => { it('honors only the five bridge-supported Codex events, dropping the rest', () => { @@ -62,8 +71,9 @@ describe('parseCodexConfig', () => { }) it('keeps a valid Rust-regex matcher when present', () => { - const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) + const { config, matchers } = parseCodexConfig({ PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) expect(config.PreToolUse![0]!.matcher).toBe('(?i)^bash$') + expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) }) it('rejects an invalid regex matcher with its event name', () => { diff --git a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts index d87d49ccd1..0255b29d09 100644 --- a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts +++ b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts @@ -9,6 +9,7 @@ import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' const matcherLifecycle = vi.hoisted(() => { const registry = { matches: vi.fn(() => true), + diagnostic: vi.fn<(matcher: string | undefined) => string | undefined>(() => undefined), dispose: vi.fn<() => void>(), } return { @@ -26,9 +27,30 @@ const dirs: string[] = [] afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) vi.clearAllMocks() + matcherLifecycle.registry.diagnostic.mockReturnValue(undefined) }) describe('hooks-codex matcher lifecycle', () => { + it('disposes the compiled set when one event-specific diagnostic rejects the config', async () => { + const { parseCodexConfig } = await import('@deepseek-ai/dsh-hooks-codex/src/config.ts') + matcherLifecycle.registry.diagnostic.mockImplementation((matcher: string | undefined) => ( + matcher === '[' ? 'invalid codex regex matcher "["' : undefined + )) + + expect(() => parseCodexConfig({ + PreToolUse: [ + { matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'first' }] }, + { matcher: '[', hooks: [{ type: 'command', command: 'second' }] }, + ], + })).toThrow('invalid codex regex matcher "[" on event "PreToolUse"') + + expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith( + new Set(['(?i)^bash$', '[']), + 'codex', + ) + expect(matcherLifecycle.registry.dispose).toHaveBeenCalledOnce() + }) + it('gives the loaded config one matcher registry and disposes it on plugin teardown', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-matchers-')) dirs.push(dir) @@ -44,10 +66,10 @@ describe('hooks-codex matcher lifecycle', () => { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath, model: 'm' }) - expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith([ + expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith(new Set([ '(?i)^bash$', - '(?i)^bash$', - ], 'codex') + ]), 'codex') + expect(matcherLifecycle.registry.diagnostic).toHaveBeenCalledTimes(2) expect(matcherLifecycle.registry.dispose).not.toHaveBeenCalled() await fiber.dispose() From c7076e15b8d39b0e7e1c04fe14fb2e5c98767892 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:33:08 -0700 Subject: [PATCH 16/31] fix(hooks): bound regex reuse across reloads --- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 4 +- packages/hooks/hook-protocol/src/index.ts | 7 +- packages/hooks/hook-protocol/src/matcher.ts | 115 +++++++++++------- .../tests/matcher-lifecycle.spec.ts | 103 +++++++++++++--- packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 2 +- packages/hooks/hooks-codex/README.zh.md | 2 +- 12 files changed, 180 insertions(+), 73 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index 48e43071b4..b01819b9d5 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: 611acd88547456514375e6850698c4c5d974c989 -2026-06-30-hook-protocol-lib.zh.md: 28dce0365775b6142406ffdda82b643b5038e606 +2026-06-30-hook-protocol-lib.md: 40b0f80e8f7c0f7e129c083c3589ce05706fe9c5 +2026-06-30-hook-protocol-lib.zh.md: e09cbbce7d5adb3e7d5f7f41fd0e5e1de1a749e4 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 611acd8854..40b0f80e8f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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** — `compileMatchers(patterns, mode)`, `matcherDiagnostic(pattern, mode)`, and `matchesMatcher(pattern, query, mode)`. Pure `[A-Za-z0-9_|]+` patterns use the shared exact-match fast path (pipe = alternatives); the ONE remaining dialect axis is collapsed to `mode`: other Claude patterns use JavaScript `RegExp`, while other Codex patterns use Rust `regex`, including Rust-only syntax such as `(?i)`. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, collects the remaining runnable groups, and compiles their finite set of unique patterns ONCE. It reads validation diagnostics from that compiled registry: an invalid regex causes whole-config rejection after the registry is disposed, while a valid parse returns the SAME registry for hook-point matching and plugin-teardown disposal after detached runs drain. The stable diagnostic still names dialect/pattern/event and no hook listeners are registered on failure. This config-scoped ownership avoids both a module-global cache and separate validation/runtime Rust/WASM construction, whose non-shrinking allocator raises its memory high-water mark on every construction. The one-shot helpers remain contained, so a direct library caller never throws into the loop. +- **Matcher** — `compileMatchers(patterns, mode)`, `matcherDiagnostic(pattern, mode)`, and `matchesMatcher(pattern, query, mode)`. Pure `[A-Za-z0-9_|]+` patterns use the shared exact-match fast path (pipe = alternatives); the ONE remaining dialect axis is collapsed to `mode`: other Claude patterns use JavaScript `RegExp`, while other Codex patterns use Rust `regex`, including Rust-only syntax such as `(?i)`. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, collects the remaining runnable groups, and compiles their finite set of unique patterns ONCE. It reads validation diagnostics from that config registry: rejection disposes the registry before whole-config failure, while admission returns the SAME registry for hook-point matching and plugin-teardown disposal after detached runs drain. Codex valid instances and invalid diagnostics additionally use a versioned interner on the synchronous `rregex` CJS module, so one-shot calls and hook-protocol/Cordis reloads reuse them without putting state on `globalThis`. Because that dependency's WASM allocator does not shrink after `free()`, the interner deliberately retains at most `MAX_INTERNED_CODEX_REGEX_PATTERNS` (128) distinct non-literal patterns per process. At capacity, a new distinct pattern is rejected before native construction with a stable capacity/pattern/event diagnostic; known patterns remain usable and process restart resets the budget. The hard bound covers adversarial unique-pattern reloads without an unbounded cache, while direct library calls remain contained and never throw 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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index 28dce03657..e09cbbce7d 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `compileMatchers(patterns, mode)`、`matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。纯 `[A-Za-z0-9_|]+` pattern 使用共享的精确匹配快速路径(管道符 = 多选);剩余的唯一方言差异收敛为 `mode`:其他 Claude pattern 使用 JavaScript `RegExp`,其他 Codex pattern 使用 Rust `regex`,包括 `(?i)` 等 Rust 专属语法。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,收集其余可运行 group,再将其中有限的唯一 pattern 集合只编译一次。它直接从该 registry 读取校验诊断:无效正则会在释放 registry 后导致整份配置加载失败;有效解析则把同一个 registry 交给各 hook 点匹配,并在插件 teardown 时先 drain 脱离运行,再释放它。稳定诊断仍包含方言/模式/事件,失败时不会注册任何 hook 监听器。这种配置作用域的所有权既避免模块全局缓存,也避免校验和运行时分别构造 Rust/WASM 正则;其无法收缩的分配器会在每次构造时抬高内存高水位。一次性 helper 仍是收敛的,因此直接调用本库时绝不向 agent loop(智能体循环)抛异常。 +- **Matcher** — `compileMatchers(patterns, mode)`、`matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。纯 `[A-Za-z0-9_|]+` pattern 使用共享的精确匹配快速路径(管道符 = 多选);剩余的唯一方言差异收敛为 `mode`:其他 Claude pattern 使用 JavaScript `RegExp`,其他 Codex pattern 使用 Rust `regex`,包括 `(?i)` 等 Rust 专属语法。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,收集其余可运行 group,再将其中有限的唯一 pattern 集合只编译一次。它直接从该配置 registry 读取校验诊断:pattern 被拒绝时,会先释放 registry 再让整份配置加载失败;有效解析则把同一个 registry 交给各 hook 点匹配,并在插件 teardown 时先 drain 脱离运行,再释放它。Codex 的有效实例与无效诊断还会使用同步 `rregex` CJS 模块上带版本号的 interner,因此一次性调用及 hook-protocol/Cordis 重载都能复用它们,而无需把状态放在 `globalThis` 上。由于该依赖的 WASM 分配器在 `free()` 后也不会缩小,interner 会有意将每进程不同的非字面 pattern 上限设为 `MAX_INTERNED_CODEX_REGEX_PATTERNS`(128)。容量用满时,新的不同 pattern 会在原生构造前被包含容量/pattern/事件的稳定诊断拒绝;已知 pattern 仍可使用,重启进程会重置预算。硬上限可覆盖恶意唯一 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 解析 stdout;exit `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 按序累积。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 8c879474c8..b5c4b0acb1 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: 36607ed9b98a97288690c869e58ee1d45ba765c4 -README.zh.md: 5e1abce23aea65f670bde8c8c5d74c40f6afd07e +README.md: 3a44aaaf17034b310a91ac9686bd9a0d6690de11 +README.zh.md: 2adae13dd10cc0a0c38791be604b83283698d892 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 36607ed9b9..3a44aaaf17 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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 validation + test | `compileMatchers(patterns, mode)` exposes diagnostics and repeated config-lifetime matching from one compiled set; `matcherDiagnostic` / `matchesMatcher` are contained one-shot helpers | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), compiles the unique runnable patterns once, rejects a group carrying a registry diagnostic, and disposes that same set on failure or teardown | +| Matcher validation + test | `compileMatchers(patterns, mode)` exposes diagnostics and repeated config-lifetime matching from one registry; Codex uses a bounded reload-stable Rust-regex interner; `matcherDiagnostic` / `matchesMatcher` are contained one-shot helpers | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), compiles the unique runnable patterns once, rejects a group carrying a registry diagnostic, and disposes its config registry on failure or teardown | | 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 -- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). A bridge parser first discards matcher fields for events without matcher subjects and collects the remaining runnable groups, then compiles their unique patterns once. It reads `registry.diagnostic(pattern)` from those exact instances, disposes the registry before throwing on an invalid consumed regex, or returns the same registry for runtime matching. The plugin reuses it at every hook point and disposes it after detached runs drain on teardown. Thus neither validation nor matching reconstructs a Rust/WASM regex and raises its non-shrinking memory high-water mark. `matcherDiagnostic` and `matchesMatcher` remain contained one-shot helpers; invalid runtime patterns are non-matches rather than exceptions. +- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). A bridge parser first discards matcher fields for events without matcher subjects and collects the remaining runnable groups, then compiles their unique patterns once. It reads `registry.diagnostic(pattern)` from those exact instances, disposes the config registry before throwing on a rejected pattern, or returns that registry for runtime matching and teardown after detached runs drain. Codex's valid instances and invalid diagnostics are interned on the synchronous `rregex` dependency module, so they survive hook-protocol/Cordis reloads without using `globalThis`; one-shot helpers share the same interner. Because `rregex` cannot shrink its WASM allocation after `free()`, the process deliberately retains at most `MAX_INTERNED_CODEX_REGEX_PATTERNS` (128) distinct non-literal patterns. Once full, a new distinct pattern is rejected with a capacity diagnostic before calling WASM; previously interned patterns continue to work, and a process restart resets the budget. This is bounded for both same-pattern and adversarial unique reloads without an unbounded cache. - **`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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 5e1abce23a..2adae13dd1 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 校验 + 测试 | `compileMatchers(patterns, mode)` 从同一已编译集合提供诊断与配置生命周期内的重复匹配;`matcherDiagnostic`/`matchesMatcher` 是收敛的一次性 helper | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),将可运行的唯一 pattern 只编译一次,拒绝带有 registry 诊断的配置组,并在失败或 teardown 时释放同一集合 | +| Matcher 校验 + 测试 | `compileMatchers(patterns, mode)` 从同一 registry 提供诊断与配置生命周期内的重复匹配;Codex 使用有界且跨重载稳定的 Rust-regex interner;`matcherDiagnostic`/`matchesMatcher` 是收敛的一次性 helper | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),将可运行的唯一 pattern 只编译一次,拒绝带有 registry 诊断的配置组,并在失败或 teardown 时释放配置 registry | | 运行 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 Code/Codex hook 协议格式的**共享核心**。它不是 cordis 插 ## 原语 -- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会先丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行 group,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`;实际消费的正则无效时,会先释放 registry 再抛错,否则把同一 registry 交给运行时。插件会在各 hook 点重复使用它,并在 teardown 时先 drain 脱离运行,再释放该集合。因此校验和匹配都不会重复构造 Rust/WASM 正则并抬高其无法收缩的内存高水位。`matcherDiagnostic` 与 `matchesMatcher` 保留为收敛的一次性 helper;运行时无效 pattern 仍是不匹配而非异常。 +- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会先丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行 group,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`;pattern 被拒绝时,会先释放配置 registry 再抛错,否则把该 registry 交给运行时,并在 teardown 时先 drain 脱离运行,再释放它。Codex 的有效实例和无效诊断会 intern 在同步 `rregex` 依赖模块上,因此无需使用 `globalThis`,也能跨 hook-protocol/Cordis 重载保留;一次性 helper 共享同一 interner。由于 `rregex` 在 `free()` 后也不能缩小 WASM 分配,进程会有意最多保留 `MAX_INTERNED_CODEX_REGEX_PATTERNS`(128)个不同的非字面 pattern。容量用满后,新的不同 pattern 会在调用 WASM 前被容量诊断拒绝;已经 intern 的 pattern 继续工作,重启进程会重置预算。这样既覆盖相同 pattern 重载,也能在恶意唯一 pattern 重载下保持有界,而无需无界缓存。 - **`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` 按顺序累积。 diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index ba38cac693..f5acc2de6a 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -13,7 +13,12 @@ export type { MatcherGroup, MatcherMode, } from './types.ts' -export { compileMatchers, matcherDiagnostic, matchesMatcher } from './matcher.ts' +export { + compileMatchers, + matcherDiagnostic, + matchesMatcher, + MAX_INTERNED_CODEX_REGEX_PATTERNS, +} from './matcher.ts' export type { CompiledMatchers } from './matcher.ts' export { parseHookOutput } from './codec.ts' export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts' diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index ad2792379b..7d6de5ca06 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -3,8 +3,9 @@ * pipe patterns as literal alternatives and other patterns as regex. Codex * uses the same literal fast path, then compiles regex patterns with Rust's * `regex` dialect. Missing, empty, and `*` match all. Runtime matching contains - * invalid regexes as non-matches. A compiled config registry exposes the same - * stable diagnostic without constructing a second native regex. + * invalid regexes as non-matches. Codex regexes are interned in a bounded pool + * shared across module reloads; a config registry leases those instances for + * diagnostics and runtime matching without reconstructing them. * @module @deepseek-ai/dsh-hook-protocol/matcher */ @@ -12,12 +13,32 @@ import { createRequire } from 'node:module' import type { RRegex as RustRegex } from 'rregex' import type { MatcherMode } from './types.ts' +type CodexRegexPoolEntry = + | { regex: RustRegex; diagnostic?: never } + | { regex?: never; diagnostic: string } + +type RRegexModule = { + RRegex: new(pattern: string) => RustRegex +} & Record + +/** Process-wide ceiling for distinct non-literal Codex matcher patterns. */ +export const MAX_INTERNED_CODEX_REGEX_PATTERNS = 128 + // rregex's ESM entry initializes WASM with top-level await. Hook plugins are // discovered through Cordis Loader's synchronous module boundary, so use the // package's equivalent synchronous Node entry rather than making both bridge -// modules async merely by importing this shared matcher. -const { RRegex } = createRequire(import.meta.url)('rregex') as { - RRegex: new(pattern: string) => RustRegex +// modules async merely by importing this shared matcher. The versioned symbol +// lives on that CJS module instance: Cordis may reload this library module, but +// Node retains the dependency module and therefore its bounded intern pool. +const rregexModule = createRequire(import.meta.url)('rregex') as RRegexModule +const { RRegex } = rregexModule +const CODEX_REGEX_POOL_KEY = Symbol.for('@deepseek-ai/dsh-hook-protocol/rregex-pool/v1') +const priorPool = rregexModule[CODEX_REGEX_POOL_KEY] +const codexRegexPool = priorPool instanceof Map + ? priorPool as Map + : new Map() +if (!(priorPool instanceof Map)) { + rregexModule[CODEX_REGEX_POOL_KEY] = codexRegexPool } /** True for an absent / empty / `'*'` pattern — the match-all sentinels. */ @@ -31,64 +52,81 @@ const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/ interface CompiledMatcher { matches(query: string): boolean diagnostic?: string - dispose(): void } -/** A config-lifetime matcher set compiled once and explicitly released. */ +/** A config-lifetime matcher set compiled once and explicitly disconnected. */ export interface CompiledMatchers { /** Match one of the patterns supplied to {@link compileMatchers}. */ matches(matcher: string | undefined, query: string): boolean /** Diagnose one supplied pattern using the already-compiled instance. */ diagnostic(matcher: string | undefined): string | undefined - /** Release every native matcher. Safe to call more than once. */ + /** Release this registry's references. Safe to call more than once. */ dispose(): void } -/** Compile one dialect's unanchored regex; invalid patterns return `undefined`. */ -function compileRegex(pattern: string, mode: MatcherMode): RegExp | RustRegex | undefined { +/** Intern one Codex regex or its diagnostic without exceeding the process budget. */ +function internCodexRegex(pattern: string): CodexRegexPoolEntry { + const existing = codexRegexPool.get(pattern) + if (existing !== undefined) return existing + if (codexRegexPool.size >= MAX_INTERNED_CODEX_REGEX_PATTERNS) { + return { + diagnostic: `codex regex matcher capacity exceeded (${MAX_INTERNED_CODEX_REGEX_PATTERNS} distinct patterns per process) for ${JSON.stringify(pattern)}`, + } + } + + let entry: CodexRegexPoolEntry try { - return mode === 'codex' ? new RRegex(pattern) : new RegExp(pattern) + entry = { regex: new RRegex(pattern) } } catch (_syntaxError) { // Regex construction is the try's only operation, so malformed syntax in - // the selected dialect is the only expected failure. - return undefined + // Rust's dialect is the only expected failure. Cache failures too: a bad + // config repeatedly reloaded must not keep growing WASM memory. + entry = { diagnostic: `invalid codex regex matcher ${JSON.stringify(pattern)}` } } + codexRegexPool.set(pattern, entry) + return entry } -/** Release a WASM-backed Codex regex when its owning matcher lifetime ends. */ -function disposeRegex(regex: RegExp | RustRegex): void { - if (regex instanceof RRegex) regex.free() -} - -/** Compile one matcher into a reusable, explicitly disposable predicate. */ +/** Compile one matcher into a reusable predicate. */ function compileMatcher(matcher: string | undefined, mode: MatcherMode): CompiledMatcher { - if (isMatchAll(matcher)) return { matches: () => true, dispose: () => {} } + if (isMatchAll(matcher)) return { matches: () => true } const pattern = matcher as string if (EXACT_MATCHER.test(pattern)) { const alternatives = new Set(pattern.split('|')) - return { matches: query => alternatives.has(query), dispose: () => {} } + return { matches: query => alternatives.has(query) } } - const regex = compileRegex(pattern, mode) - if (regex === undefined) { + + if (mode === 'codex') { + const entry = internCodexRegex(pattern) + if (entry.regex !== undefined) { + const regex = entry.regex + return { matches: query => regex.isMatch(query) } + } return { matches: () => false, - diagnostic: `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`, - dispose: () => {}, + diagnostic: entry.diagnostic, } } - return { - matches: query => regex instanceof RRegex ? regex.isMatch(query) : regex.test(query), - dispose: () => { disposeRegex(regex) }, + + try { + const regex = new RegExp(pattern) + return { matches: query => regex.test(query) } + } catch (_syntaxError) { + return { + matches: () => false, + diagnostic: `invalid claude regex matcher ${JSON.stringify(pattern)}`, + } } } /** * Compile a finite config's unique matcher patterns for repeated evaluation. - * The returned registry owns native Rust-regex allocations; its caller must - * dispose it when the config/plugin lifetime ends. + * The returned registry owns one config's references. Codex native instances + * live in a bounded, reload-stable process pool; disposal disconnects this + * config but deliberately keeps interned instances for later reloads. * @param matchers - the complete finite set of patterns in one loaded config. * @param mode - the native regex dialect used for non-literal patterns. - * @returns a reusable registry that owns and disposes its compiled regexes. + * @returns a reusable registry that disconnects its config-local lookups on disposal. */ export function compileMatchers(matchers: Iterable, mode: MatcherMode): CompiledMatchers { const compiled = new Map() @@ -108,7 +146,6 @@ export function compileMatchers(matchers: Iterable, mode: Ma dispose() { if (disposed) return disposed = true - for (const matcher of compiled.values()) matcher.dispose() compiled.clear() }, } @@ -121,12 +158,7 @@ export function compileMatchers(matchers: Iterable, mode: Ma * @returns `undefined` for a valid matcher, otherwise a stable diagnostic. */ export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined { - const compiled = compileMatcher(matcher, mode) - try { - return compiled.diagnostic - } finally { - compiled.dispose() - } + return compileMatcher(matcher, mode).diagnostic } /** @@ -142,10 +174,5 @@ export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode * regex. */ export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean { - const compiled = compileMatcher(matcher, mode) - try { - return compiled.matches(query) - } finally { - compiled.dispose() - } + return compileMatcher(matcher, mode).matches(query) } diff --git a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts index 9e40e6db89..a500de6c9b 100644 --- a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts @@ -2,11 +2,28 @@ import { createRequire } from 'node:module' import { describe, expect, it, vi } from 'vitest' import type { RRegex as RustRegex } from 'rregex' -describe('compileMatchers — native regex lifecycle', () => { - it('constructs each unique Codex regex once across repeated matches and frees it once', async () => { +const POOL_KEY = Symbol.for('@deepseek-ai/dsh-hook-protocol/rregex-pool/v1') + +interface PoolEntry { + regex?: RustRegex +} + +type RRegexModule = { + RRegex: new(pattern: string) => RustRegex + __wbindgen_memory(): WebAssembly.Memory +} & Record + +function restorePool(rregex: RRegexModule, original: unknown): void { + Reflect.deleteProperty(rregex, POOL_KEY) + if (original !== undefined) rregex[POOL_KEY] = original +} + +describe('Codex regex intern lifecycle', () => { + it('keeps 100,000 same-pattern reloads bounded and reuses across module reload', async () => { const require = createRequire(import.meta.url) - const rregex = require('rregex') as { RRegex: new(pattern: string) => RustRegex } + const rregex = require('rregex') as RRegexModule const OriginalRRegex = rregex.RRegex + const originalPool = rregex[POOL_KEY] const construct = vi.fn<(pattern: string) => void>() const free = vi.fn<() => void>() @@ -22,24 +39,82 @@ describe('compileMatchers — native regex lifecycle', () => { } } + Reflect.deleteProperty(rregex, POOL_KEY) rregex.RRegex = CountingRRegex vi.resetModules() + const before = rregex.__wbindgen_memory().buffer.byteLength try { - const { compileMatchers } = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts') - const matchers = compileMatchers(['(?i)^bash$', '(?i)^bash$', '^write$'], 'codex') - expect(construct.mock.calls.map(([pattern]) => pattern)).toEqual(['(?i)^bash$', '^write$']) - - for (let i = 0; i < 1_000; i++) { - expect(matchers.diagnostic('(?i)^bash$')).toBeUndefined() - expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) + const first = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts') + for (let i = 0; i < 100_000; i++) { + first.compileMatchers(['(?i)^bash$'], 'codex').dispose() } - expect(construct).toHaveBeenCalledTimes(2) + expect(construct).toHaveBeenCalledExactlyOnceWith('(?i)^bash$') + expect(free).not.toHaveBeenCalled() + expect(rregex.__wbindgen_memory().buffer.byteLength - before).toBeLessThanOrEqual(4 * 1024 * 1024) - matchers.dispose() - matchers.dispose() - expect(free).toHaveBeenCalledTimes(2) + vi.resetModules() + const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts') + expect(reloaded.matcherDiagnostic('(?i)^bash$', 'codex')).toBeUndefined() + expect(reloaded.matchesMatcher('(?i)^bash$', 'BASH', 'codex')).toBe(true) + expect(construct).toHaveBeenCalledTimes(1) + expect(free).not.toHaveBeenCalled() + } finally { + const temporaryPool = rregex[POOL_KEY] + if (temporaryPool instanceof Map) { + for (const entry of temporaryPool.values() as Iterable) entry.regex?.free() + } + rregex.RRegex = OriginalRRegex + restorePool(rregex, originalPool) + vi.resetModules() + } + }) + + it('memoizes failures and rejects a new pattern before construction at the hard cap', async () => { + const require = createRequire(import.meta.url) + const rregex = require('rregex') as RRegexModule + const OriginalRRegex = rregex.RRegex + const originalPool = rregex[POOL_KEY] + const construct = vi.fn<(pattern: string) => void>() + + class FakeRRegex { + constructor(pattern: string) { + construct(pattern) + if (pattern === 'invalid(') throw new SyntaxError('invalid test pattern') + } + + isMatch(): boolean { + return true + } + } + + Reflect.deleteProperty(rregex, POOL_KEY) + rregex.RRegex = FakeRRegex as unknown as typeof rregex.RRegex + vi.resetModules() + try { + const matcher = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts') + expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("') + expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("') + expect(construct).toHaveBeenCalledTimes(1) + + for (let i = 0; i < matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS - 1; i++) { + expect(matcher.matcherDiagnostic(`^value-${i}$`, 'codex')).toBeUndefined() + } + expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS) + + expect(matcher.matcherDiagnostic('^overflow$', 'codex')).toBe( + `codex regex matcher capacity exceeded (${matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS} distinct patterns per process) for "^overflow$"`, + ) + expect(matcher.matchesMatcher('^overflow$', 'overflow', 'codex')).toBe(false) + expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS) + + vi.resetModules() + const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts') + expect(reloaded.matchesMatcher('^value-0$', 'anything', 'codex')).toBe(true) + expect(reloaded.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("') + expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS) } finally { rregex.RRegex = OriginalRRegex + restorePool(rregex, originalPool) vi.resetModules() } }) diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index 20d5781678..c48757a3e9 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -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: 0c9a6b22d0990d87ad081db4f2690c5d97357062 -README.zh.md: d3c88a75208257585255fc36ad6cc0a7a3b5c0f0 +README.md: eb8882cda590293e21dd6011244f15359a797768 +README.zh.md: b154c4e825844883a0810b7951b0d50ca4951dfb diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 0c9a6b22d0..eb8882cda5 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -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); 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 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. Non-literal Rust-regex patterns are interned across reloads under a process budget of 128 distinct patterns: once full, a new distinct pattern is rejected before WASM construction with a capacity diagnostic, while already interned patterns remain usable; restarting the process resets the budget. 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. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index d3c88a7520..b154c4e825 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -34,7 +34,7 @@ const config: Config = { model: deepseek-v4 ``` -配置只在加载时解析**一次**。`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 分钟)。五个桥接支持点之外的事件会在解析时丢弃。 +配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳(记录 + 不注册任何内容);实际消费 matcher 的事件所带的无效 matcher 正则属于此类失败,并报告其 pattern 与事件。非字面的 Rust-regex pattern 会跨重载 intern,并受每进程最多 128 个不同 pattern 的预算约束:容量用满后,新的不同 pattern 会在 WASM 构造前被容量诊断拒绝,已经 intern 的 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 作用于 user 项目树,而非服务器启动目录。 From 4997846e453aa3f74dedf6a1b7aba6494c284177 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:58:28 +0800 Subject: [PATCH 17/31] refactor(hooks): simplify regex pool entry --- packages/hooks/hook-protocol/src/matcher.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 7d6de5ca06..3cfca019c3 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -14,8 +14,8 @@ import type { RRegex as RustRegex } from 'rregex' import type { MatcherMode } from './types.ts' type CodexRegexPoolEntry = - | { regex: RustRegex; diagnostic?: never } - | { regex?: never; diagnostic: string } + | { regex: RustRegex } + | { diagnostic: string } type RRegexModule = { RRegex: new(pattern: string) => RustRegex @@ -98,7 +98,7 @@ function compileMatcher(matcher: string | undefined, mode: MatcherMode): Compile if (mode === 'codex') { const entry = internCodexRegex(pattern) - if (entry.regex !== undefined) { + if ('regex' in entry) { const regex = entry.regex return { matches: query => regex.isMatch(query) } } From 99daef69ef886a412bfc21ee22d22b086d273190 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:14:31 +0800 Subject: [PATCH 18/31] fix(hooks): remove speculative regex runtime --- .../feature/2026-06-30-hook-bridges.i18n.yaml | 4 +- .../feature/2026-06-30-hook-bridges.md | 2 +- .../feature/2026-06-30-hook-bridges.zh.md | 2 +- .../2026-06-30-hook-protocol-lib.i18n.yaml | 4 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../2026-06-30-hook-protocol-lib.zh.md | 2 +- docs/config-catalog.md | 2 +- packages/hooks/README.i18n.yaml | 4 +- packages/hooks/README.md | 2 +- packages/hooks/README.zh.md | 2 +- packages/hooks/hook-protocol/README.i18n.yaml | 4 +- packages/hooks/hook-protocol/README.md | 4 +- packages/hooks/hook-protocol/README.zh.md | 6 +- packages/hooks/hook-protocol/package.json | 3 - packages/hooks/hook-protocol/src/index.ts | 8 +- packages/hooks/hook-protocol/src/matcher.ts | 175 ++++-------------- packages/hooks/hook-protocol/src/types.ts | 8 +- .../tests/matcher-lifecycle.spec.ts | 121 ------------ .../hooks/hook-protocol/tests/matcher.spec.ts | 49 +---- packages/hooks/hooks-claude/src/config.ts | 86 ++++----- packages/hooks/hooks-claude/src/index.ts | 32 ++-- .../hooks/hooks-claude/tests/config.spec.ts | 20 +- packages/hooks/hooks-codex/README.i18n.yaml | 4 +- packages/hooks/hooks-codex/README.md | 4 +- packages/hooks/hooks-codex/README.zh.md | 4 +- packages/hooks/hooks-codex/src/config.ts | 88 ++++----- packages/hooks/hooks-codex/src/index.ts | 46 ++--- .../hooks/hooks-codex/tests/bridge.spec.ts | 6 +- .../hooks/hooks-codex/tests/config.spec.ts | 26 +-- .../tests/matcher-lifecycle.spec.ts | 78 -------- pnpm-lock.yaml | 9 - 31 files changed, 174 insertions(+), 633 deletions(-) delete mode 100644 packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts delete mode 100644 packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index a686abafe3..809c14dedb 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml @@ -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-bridges.md -2026-06-30-hook-bridges.md: 42e1aaceb74f65d4d8e0bbd6008cd8fcaccb15cb -2026-06-30-hook-bridges.zh.md: 7d87950f03af4988ac1f0d7fad8d77612925b1a1 +2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe +2026-06-30-hook-bridges.zh.md: 66855c3c4f36877aa627173de8e73250546e9621 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index 42e1aaceb7..99c6b1941a 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -15,7 +15,7 @@ The framing that shapes the whole design: **a bridge is a compatibility adapter, Two independent plugins in the `packages/hooks/` group, each a function/namespace plugin (`name`/`inject`/`Config`/`apply`, NO default export — see [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)) injecting only `bash`: - **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`transcript_path`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. `transcript_path` is the persistence locator result or `''`; stdin carries a **trailing newline**. -- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. Pure `[A-Za-z0-9_|]+` matcher patterns share the CC dialect's exact-match fast path (pipe = alternatives), while every other pattern uses Rust `regex` syntax. It emits Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras WITHOUT a trailing newline, performs no Codex plugin-env injection or config-time placeholder substitution, and has no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape. +- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. `transcript_path` is the same locator result or `null`; tool payloads carry the real `tool_name` in the reduced `tool_input: { command }` shape. ### Outcome → Decision mapping diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 7d87950f03..66855c3c4f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -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`。纯 `[A-Za-z0-9_|]+` matcher pattern 与 CC 方言共享精确匹配快速路径(管道符表示多选),其他 pattern 则使用 Rust `regex` 语法。它输出 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 映射 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml index b01819b9d5..3ecd4e2dbe 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml @@ -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: 40b0f80e8f7c0f7e129c083c3589ce05706fe9c5 -2026-06-30-hook-protocol-lib.zh.md: e09cbbce7d5adb3e7d5f7f41fd0e5e1de1a749e4 +2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857 +2026-06-30-hook-protocol-lib.zh.md: 062160931f52576e65557b6e0d385ccaac54aceb diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md index 40b0f80e8f..ce25f40e96 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -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** — `compileMatchers(patterns, mode)`, `matcherDiagnostic(pattern, mode)`, and `matchesMatcher(pattern, query, mode)`. Pure `[A-Za-z0-9_|]+` patterns use the shared exact-match fast path (pipe = alternatives); the ONE remaining dialect axis is collapsed to `mode`: other Claude patterns use JavaScript `RegExp`, while other Codex patterns use Rust `regex`, including Rust-only syntax such as `(?i)`. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, collects the remaining runnable groups, and compiles their finite set of unique patterns ONCE. It reads validation diagnostics from that config registry: rejection disposes the registry before whole-config failure, while admission returns the SAME registry for hook-point matching and plugin-teardown disposal after detached runs drain. Codex valid instances and invalid diagnostics additionally use a versioned interner on the synchronous `rregex` CJS module, so one-shot calls and hook-protocol/Cordis reloads reuse them without putting state on `globalThis`. Because that dependency's WASM allocator does not shrink after `free()`, the interner deliberately retains at most `MAX_INTERNED_CODEX_REGEX_PATTERNS` (128) distinct non-literal patterns per process. At capacity, a new distinct pattern is rejected before native construction with a stable capacity/pattern/event diagnostic; known patterns remain usable and process restart resets the budget. The hard bound covers adversarial unique-pattern reloads without an unbounded cache, while direct library calls remain contained and never throw 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. diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md index e09cbbce7d..062160931f 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md @@ -15,7 +15,7 @@ Status: implemented 在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。 **共享(本库):** -- **Matcher** — `compileMatchers(patterns, mode)`、`matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。纯 `[A-Za-z0-9_|]+` pattern 使用共享的精确匹配快速路径(管道符 = 多选);剩余的唯一方言差异收敛为 `mode`:其他 Claude pattern 使用 JavaScript `RegExp`,其他 Codex pattern 使用 Rust `regex`,包括 `(?i)` 等 Rust 专属语法。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,收集其余可运行 group,再将其中有限的唯一 pattern 集合只编译一次。它直接从该配置 registry 读取校验诊断:pattern 被拒绝时,会先释放 registry 再让整份配置加载失败;有效解析则把同一个 registry 交给各 hook 点匹配,并在插件 teardown 时先 drain 脱离运行,再释放它。Codex 的有效实例与无效诊断还会使用同步 `rregex` CJS 模块上带版本号的 interner,因此一次性调用及 hook-protocol/Cordis 重载都能复用它们,而无需把状态放在 `globalThis` 上。由于该依赖的 WASM 分配器在 `free()` 后也不会缩小,interner 会有意将每进程不同的非字面 pattern 上限设为 `MAX_INTERNED_CODEX_REGEX_PATTERNS`(128)。容量用满时,新的不同 pattern 会在原生构造前被包含容量/pattern/事件的稳定诊断拒绝;已知 pattern 仍可使用,重启进程会重置预算。硬上限可覆盖恶意唯一 pattern 重载而无需无界缓存,同时直接调用本库仍是收敛的,绝不向 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 解析 stdout;exit `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 按序累积。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 05d61f8863..514cda962b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -478,7 +478,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:45`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:46`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` diff --git a/packages/hooks/README.i18n.yaml b/packages/hooks/README.i18n.yaml index 2baf2208ad..165722ec0d 100644 --- a/packages/hooks/README.i18n.yaml +++ b/packages/hooks/README.i18n.yaml @@ -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/README.md -README.md: 9084f93e6b76e366f81986a052eb35e3811a0c13 -README.zh.md: e61753b39d1f2af97f6ab4d5ab2fa18d8f84ec99 +README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a +README.zh.md: 741300a9a390a8f254c01733e5326be84541a78d diff --git a/packages/hooks/README.md b/packages/hooks/README.md index 9084f93e6b..23478fb5e9 100644 --- a/packages/hooks/README.md +++ b/packages/hooks/README.md @@ -10,4 +10,4 @@ The hooks subsystem lets users extend the agent at lifecycle points the way Clau | `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin | | `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin | -Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, a Rust-regex matcher dialect, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). +Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md). diff --git a/packages/hooks/README.zh.md b/packages/hooks/README.zh.md index e61753b39d..741300a9a3 100644 --- a/packages/hooks/README.zh.md +++ b/packages/hooks/README.zh.md @@ -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 的众多事件、仅命令、使用 Rust 正则 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)。 diff --git a/packages/hooks/hook-protocol/README.i18n.yaml b/packages/hooks/hook-protocol/README.i18n.yaml index 381a90d067..deed052066 100644 --- a/packages/hooks/hook-protocol/README.i18n.yaml +++ b/packages/hooks/hook-protocol/README.i18n.yaml @@ -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: 3a44aaaf17034b310a91ac9686bd9a0d6690de11 -README.zh.md: 3e3e56f9af740a973b5765aa57467ebe09a27c83 +README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805 +README.zh.md: 15a537b67677a401ab434a3e73af1973030780c0 diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 3a44aaaf17..8cf4b95c95 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -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 validation + test | `compileMatchers(patterns, mode)` exposes diagnostics and repeated config-lifetime matching from one registry; Codex uses a bounded reload-stable Rust-regex interner; `matcherDiagnostic` / `matchesMatcher` are contained one-shot helpers | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), compiles the unique runnable patterns once, rejects a group carrying a registry diagnostic, and disposes its config registry on failure or teardown | +| 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 -- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). A bridge parser first discards matcher fields for events without matcher subjects and collects the remaining runnable groups, then compiles their unique patterns once. It reads `registry.diagnostic(pattern)` from those exact instances, disposes the config registry before throwing on a rejected pattern, or returns that registry for runtime matching and teardown after detached runs drain. Codex's valid instances and invalid diagnostics are interned on the synchronous `rregex` dependency module, so they survive hook-protocol/Cordis reloads without using `globalThis`; one-shot helpers share the same interner. Because `rregex` cannot shrink its WASM allocation after `free()`, the process deliberately retains at most `MAX_INTERNED_CODEX_REGEX_PATTERNS` (128) distinct non-literal patterns. Once full, a new distinct pattern is rejected with a capacity diagnostic before calling WASM; previously interned patterns continue to work, and a process restart resets the budget. This is bounded for both same-pattern and adversarial unique reloads without an unbounded cache. +- **`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. diff --git a/packages/hooks/hook-protocol/README.zh.md b/packages/hooks/hook-protocol/README.zh.md index 3e3e56f9af..15a537b676 100644 --- a/packages/hooks/hook-protocol/README.zh.md +++ b/packages/hooks/hook-protocol/README.zh.md @@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它 | 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) | |---|---|---| -| Matcher 校验 + 测试 | `compileMatchers(patterns, mode)` 从同一注册表提供诊断与配置生命周期内的重复匹配;Codex 使用有界且跨重载稳定的 Rust 正则 interner;`matcherDiagnostic`/`matchesMatcher` 是隔离的一次性辅助函数 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),将可运行的唯一 pattern 只编译一次,拒绝带有注册表诊断的配置组,并在失败或 teardown 时释放配置注册表 | +| 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 Code/Codex hook 协议格式(wire format)的**共享核心**。它 ## 原语 -- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会先丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行的配置组,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`;pattern 被拒绝时,会先释放配置注册表再抛错,否则把该注册表交给运行时,并在 teardown 时先 drain 脱离运行,再释放它。Codex 的有效实例和无效诊断会 intern 在同步 `rregex` 依赖模块上,因此无需使用 `globalThis`,也能跨 hook-protocol/Cordis 重载保留;一次性辅助函数共享同一 interner。由于 `rregex` 在 `free()` 后也不能缩小 WASM 分配,进程会有意最多保留 `MAX_INTERNED_CODEX_REGEX_PATTERNS`(128)个不同的非字面 pattern。容量用满后,新的不同 pattern 会在调用 WASM 前被容量诊断拒绝;已经 intern 的 pattern 继续工作,重启进程会重置预算。这样既覆盖相同 pattern 重载,也能在恶意唯一 pattern 重载下保持有界,而无需无界缓存。 +- **`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 Code/Codex 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 Note(agent 决策记录)。 +Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。 ## 模型体验 diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 5ae4b98169..f357278db3 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -26,9 +26,6 @@ "src" ], "license": "BSD-3-Clause", - "dependencies": { - "rregex": "1.12.0" - }, "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index f5acc2de6a..d67746f824 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -13,13 +13,7 @@ export type { MatcherGroup, MatcherMode, } from './types.ts' -export { - compileMatchers, - matcherDiagnostic, - matchesMatcher, - MAX_INTERNED_CODEX_REGEX_PATTERNS, -} from './matcher.ts' -export type { CompiledMatchers } 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' diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 3cfca019c3..9c5606a975 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -1,178 +1,65 @@ /** * Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/ - * pipe patterns as literal alternatives and other patterns as regex. Codex - * uses the same literal fast path, then compiles regex patterns with Rust's - * `regex` dialect. Missing, empty, and `*` match all. Runtime matching contains - * invalid regexes as non-matches. Codex regexes are interned in a bounded pool - * shared across module reloads; a config registry leases those instances for - * diagnostics and runtime matching without reconstructing them. + * 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. 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 */ -import { createRequire } from 'node:module' -import type { RRegex as RustRegex } from 'rregex' import type { MatcherMode } from './types.ts' -type CodexRegexPoolEntry = - | { regex: RustRegex } - | { diagnostic: string } - -type RRegexModule = { - RRegex: new(pattern: string) => RustRegex -} & Record - -/** Process-wide ceiling for distinct non-literal Codex matcher patterns. */ -export const MAX_INTERNED_CODEX_REGEX_PATTERNS = 128 - -// rregex's ESM entry initializes WASM with top-level await. Hook plugins are -// discovered through Cordis Loader's synchronous module boundary, so use the -// package's equivalent synchronous Node entry rather than making both bridge -// modules async merely by importing this shared matcher. The versioned symbol -// lives on that CJS module instance: Cordis may reload this library module, but -// Node retains the dependency module and therefore its bounded intern pool. -const rregexModule = createRequire(import.meta.url)('rregex') as RRegexModule -const { RRegex } = rregexModule -const CODEX_REGEX_POOL_KEY = Symbol.for('@deepseek-ai/dsh-hook-protocol/rregex-pool/v1') -const priorPool = rregexModule[CODEX_REGEX_POOL_KEY] -const codexRegexPool = priorPool instanceof Map - ? priorPool as Map - : new Map() -if (!(priorPool instanceof Map)) { - rregexModule[CODEX_REGEX_POOL_KEY] = codexRegexPool -} - /** True for an absent / empty / `'*'` pattern — the match-all sentinels. */ function isMatchAll(matcher: string | undefined): boolean { return matcher === undefined || matcher === '' || matcher === '*' } -/** An exact pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ -const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/ +/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */ +const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ -interface CompiledMatcher { - matches(query: string): boolean - diagnostic?: string -} - -/** A config-lifetime matcher set compiled once and explicitly disconnected. */ -export interface CompiledMatchers { - /** Match one of the patterns supplied to {@link compileMatchers}. */ - matches(matcher: string | undefined, query: string): boolean - /** Diagnose one supplied pattern using the already-compiled instance. */ - diagnostic(matcher: string | undefined): string | undefined - /** Release this registry's references. Safe to call more than once. */ - dispose(): void -} - -/** Intern one Codex regex or its diagnostic without exceeding the process budget. */ -function internCodexRegex(pattern: string): CodexRegexPoolEntry { - const existing = codexRegexPool.get(pattern) - if (existing !== undefined) return existing - if (codexRegexPool.size >= MAX_INTERNED_CODEX_REGEX_PATTERNS) { - return { - diagnostic: `codex regex matcher capacity exceeded (${MAX_INTERNED_CODEX_REGEX_PATTERNS} distinct patterns per process) for ${JSON.stringify(pattern)}`, - } - } - - let entry: CodexRegexPoolEntry +/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */ +function compileRegex(pattern: string): RegExp | undefined { try { - entry = { regex: new RRegex(pattern) } + return new RegExp(pattern) } catch (_syntaxError) { - // Regex construction is the try's only operation, so malformed syntax in - // Rust's dialect is the only expected failure. Cache failures too: a bad - // config repeatedly reloaded must not keep growing WASM memory. - entry = { diagnostic: `invalid codex regex matcher ${JSON.stringify(pattern)}` } - } - codexRegexPool.set(pattern, entry) - return entry -} - -/** Compile one matcher into a reusable predicate. */ -function compileMatcher(matcher: string | undefined, mode: MatcherMode): CompiledMatcher { - if (isMatchAll(matcher)) return { matches: () => true } - const pattern = matcher as string - if (EXACT_MATCHER.test(pattern)) { - const alternatives = new Set(pattern.split('|')) - return { matches: query => alternatives.has(query) } - } - - if (mode === 'codex') { - const entry = internCodexRegex(pattern) - if ('regex' in entry) { - const regex = entry.regex - return { matches: query => regex.isMatch(query) } - } - return { - matches: () => false, - diagnostic: entry.diagnostic, - } - } - - try { - const regex = new RegExp(pattern) - return { matches: query => regex.test(query) } - } catch (_syntaxError) { - return { - matches: () => false, - diagnostic: `invalid claude regex matcher ${JSON.stringify(pattern)}`, - } - } -} - -/** - * Compile a finite config's unique matcher patterns for repeated evaluation. - * The returned registry owns one config's references. Codex native instances - * live in a bounded, reload-stable process pool; disposal disconnects this - * config but deliberately keeps interned instances for later reloads. - * @param matchers - the complete finite set of patterns in one loaded config. - * @param mode - the native regex dialect used for non-literal patterns. - * @returns a reusable registry that disconnects its config-local lookups on disposal. - */ -export function compileMatchers(matchers: Iterable, mode: MatcherMode): CompiledMatchers { - const compiled = new Map() - for (const matcher of matchers) { - if (!compiled.has(matcher)) compiled.set(matcher, compileMatcher(matcher, mode)) - } - let disposed = false - return { - matches(matcher, query) { - if (disposed) return false - return compiled.get(matcher)?.matches(query) ?? false - }, - diagnostic(matcher) { - if (disposed) return undefined - return compiled.get(matcher)?.diagnostic - }, - dispose() { - if (disposed) return - disposed = true - compiled.clear() - }, + // 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 which regex engine validates non-literal patterns. + * @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 { - return compileMatcher(matcher, mode).diagnostic + 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. Literal patterns - * exact-match pipe-separated alternatives; all other patterns are unanchored - * regexes in the selected dialect. Invalid regexes return `false` rather than - * throwing; bridge config parsers surface them through {@link matcherDiagnostic} - * before use. + * 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; + * 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 which regex engine matches the pattern. + * @param mode - the dialect deciding literal-vs-regex interpretation of the pattern. * @returns `true` when the pattern selects the query; `false` on a non-match or an invalid * regex. */ export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean { - return compileMatcher(matcher, mode).matches(query) + if (isMatchAll(matcher)) return true + // matcher is a non-empty string past the match-all guard. + const pattern = matcher as string + if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) { + return pattern.split('|').includes(query) + } + return compileRegex(pattern)?.test(query) ?? false } diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index 0ff6d4dc48..e14473b3e1 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -71,10 +71,10 @@ export interface MatcherGroup { } /** - * How a matcher pattern is interpreted. Both dialects use an exact-match fast - * path when the pattern is purely `[A-Za-z0-9_|]+` (pipe = alternation), then - * use their native regex dialect otherwise: JavaScript for Claude Code and Rust - * `regex` for Codex. The bridge picks the mode for its dialect. + * How a matcher pattern is interpreted. Claude Code uses {@link literal} when the + * pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and + * {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the + * mode for its dialect. */ export type MatcherMode = 'claude' | 'codex' diff --git a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts b/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts deleted file mode 100644 index a500de6c9b..0000000000 --- a/packages/hooks/hook-protocol/tests/matcher-lifecycle.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { createRequire } from 'node:module' -import { describe, expect, it, vi } from 'vitest' -import type { RRegex as RustRegex } from 'rregex' - -const POOL_KEY = Symbol.for('@deepseek-ai/dsh-hook-protocol/rregex-pool/v1') - -interface PoolEntry { - regex?: RustRegex -} - -type RRegexModule = { - RRegex: new(pattern: string) => RustRegex - __wbindgen_memory(): WebAssembly.Memory -} & Record - -function restorePool(rregex: RRegexModule, original: unknown): void { - Reflect.deleteProperty(rregex, POOL_KEY) - if (original !== undefined) rregex[POOL_KEY] = original -} - -describe('Codex regex intern lifecycle', () => { - it('keeps 100,000 same-pattern reloads bounded and reuses across module reload', async () => { - const require = createRequire(import.meta.url) - const rregex = require('rregex') as RRegexModule - const OriginalRRegex = rregex.RRegex - const originalPool = rregex[POOL_KEY] - const construct = vi.fn<(pattern: string) => void>() - const free = vi.fn<() => void>() - - class CountingRRegex extends OriginalRRegex { - constructor(pattern: string) { - super(pattern) - construct(pattern) - } - - override free(): void { - free() - super.free() - } - } - - Reflect.deleteProperty(rregex, POOL_KEY) - rregex.RRegex = CountingRRegex - vi.resetModules() - const before = rregex.__wbindgen_memory().buffer.byteLength - try { - const first = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts') - for (let i = 0; i < 100_000; i++) { - first.compileMatchers(['(?i)^bash$'], 'codex').dispose() - } - expect(construct).toHaveBeenCalledExactlyOnceWith('(?i)^bash$') - expect(free).not.toHaveBeenCalled() - expect(rregex.__wbindgen_memory().buffer.byteLength - before).toBeLessThanOrEqual(4 * 1024 * 1024) - - vi.resetModules() - const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts') - expect(reloaded.matcherDiagnostic('(?i)^bash$', 'codex')).toBeUndefined() - expect(reloaded.matchesMatcher('(?i)^bash$', 'BASH', 'codex')).toBe(true) - expect(construct).toHaveBeenCalledTimes(1) - expect(free).not.toHaveBeenCalled() - } finally { - const temporaryPool = rregex[POOL_KEY] - if (temporaryPool instanceof Map) { - for (const entry of temporaryPool.values() as Iterable) entry.regex?.free() - } - rregex.RRegex = OriginalRRegex - restorePool(rregex, originalPool) - vi.resetModules() - } - }) - - it('memoizes failures and rejects a new pattern before construction at the hard cap', async () => { - const require = createRequire(import.meta.url) - const rregex = require('rregex') as RRegexModule - const OriginalRRegex = rregex.RRegex - const originalPool = rregex[POOL_KEY] - const construct = vi.fn<(pattern: string) => void>() - - class FakeRRegex { - constructor(pattern: string) { - construct(pattern) - if (pattern === 'invalid(') throw new SyntaxError('invalid test pattern') - } - - isMatch(): boolean { - return true - } - } - - Reflect.deleteProperty(rregex, POOL_KEY) - rregex.RRegex = FakeRRegex as unknown as typeof rregex.RRegex - vi.resetModules() - try { - const matcher = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts') - expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("') - expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("') - expect(construct).toHaveBeenCalledTimes(1) - - for (let i = 0; i < matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS - 1; i++) { - expect(matcher.matcherDiagnostic(`^value-${i}$`, 'codex')).toBeUndefined() - } - expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS) - - expect(matcher.matcherDiagnostic('^overflow$', 'codex')).toBe( - `codex regex matcher capacity exceeded (${matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS} distinct patterns per process) for "^overflow$"`, - ) - expect(matcher.matchesMatcher('^overflow$', 'overflow', 'codex')).toBe(false) - expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS) - - vi.resetModules() - const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts') - expect(reloaded.matchesMatcher('^value-0$', 'anything', 'codex')).toBe(true) - expect(reloaded.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("') - expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS) - } finally { - rregex.RRegex = OriginalRRegex - restorePool(rregex, originalPool) - vi.resetModules() - } - }) -}) diff --git a/packages/hooks/hook-protocol/tests/matcher.spec.ts b/packages/hooks/hook-protocol/tests/matcher.spec.ts index 959b5070e0..a1f794aa28 100644 --- a/packages/hooks/hook-protocol/tests/matcher.spec.ts +++ b/packages/hooks/hook-protocol/tests/matcher.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { compileMatchers, matcherDiagnostic, 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) { @@ -34,10 +34,11 @@ describe('matchesMatcher — claude dialect (literal-or-regex)', () => { }) }) -describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => { - it('a word pattern uses Codex exact-match semantics', () => { +describe('matchesMatcher — codex dialect (always regex)', () => { + it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => { expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true) - expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(false) + // codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring + expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true) }) it('regex alternation and anchors work', () => { @@ -45,14 +46,6 @@ describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => { expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true) expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false) }) - - it('uses Rust regex syntax and matching semantics', () => { - expect(matchesMatcher('(?i)bash', 'xxBASHyy', 'codex')).toBe(true) - expect(matchesMatcher('(?x)^ b a s h $ # policy matcher', 'bash', 'codex')).toBe(true) - expect(matchesMatcher('^\\p{Greek}+$', 'αβ', 'codex')).toBe(true) - // JavaScript accepts look-around, but Rust regex deliberately does not. - expect(matchesMatcher('(?=Bash)', 'Bash', 'codex')).toBe(false) - }) }) describe('matchesMatcher — invalid regex is a non-match (never throws)', () => { @@ -72,42 +65,10 @@ describe('matcherDiagnostic — parse-time diagnostics', () => { expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined() expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined() expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined() - expect(matcherDiagnostic('(?i)bash', 'codex')).toBeUndefined() - expect(matcherDiagnostic('(?x)^ b a s h $ # policy matcher', '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 "["') - expect(matcherDiagnostic('(?=Bash)', 'codex')).toBe('invalid codex regex matcher "(?=Bash)"') - }) -}) - -describe('compileMatchers — config-lifetime reuse', () => { - it('compiles a finite set, contains unknown patterns, and stops after disposal', () => { - const matchers = compileMatchers([undefined, 'Edit|Write', '(?i)^bash$', '['], 'codex') - - expect(matchers.matches(undefined, 'anything')).toBe(true) - expect(matchers.matches('Edit|Write', 'Write')).toBe(true) - expect(matchers.matches('Edit|Write', 'WriteFile')).toBe(false) - expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) - expect(matchers.matches('[', 'anything')).toBe(false) - expect(matchers.matches('not-compiled', 'not-compiled')).toBe(false) - expect(matchers.diagnostic('(?i)^bash$')).toBeUndefined() - expect(matchers.diagnostic('[')).toBe('invalid codex regex matcher "["') - expect(matchers.diagnostic('not-compiled')).toBeUndefined() - - matchers.dispose() - expect(matchers.matches(undefined, 'anything')).toBe(false) - expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(false) - expect(matchers.diagnostic('[')).toBeUndefined() - expect(() => { matchers.dispose() }).not.toThrow() - }) - - it('reuses JavaScript regexes too', () => { - const matchers = compileMatchers(['^Bash$', '^Bash$'], 'claude') - expect(matchers.matches('^Bash$', 'Bash')).toBe(true) - expect(matchers.matches('^Bash$', 'BashOutput')).toBe(false) - matchers.dispose() }) }) diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 8d6b9b5a4f..2650e940c2 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -6,11 +6,7 @@ * @module @deepseek-ai/dsh-hooks-claude/config */ -import { - compileMatchers, - type CompiledMatchers, - type MatcherGroup, -} from '@deepseek-ai/dsh-hook-protocol' +import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' const CLAUDE_EVENTS = [ 'SessionStart', @@ -35,8 +31,6 @@ export interface SkippedHook { export interface ParsedClaudeConfig { config: ClaudeHookConfig skipped: SkippedHook[] - /** Config-scoped matcher registry; the caller owns and must dispose it. */ - matchers: CompiledMatchers } /** Substitution variables applied to each `command` string at parse time. */ @@ -74,7 +68,6 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri * 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. - * Validation and runtime matching share the returned compiled registry; its caller must dispose it. * * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare * event map. @@ -88,54 +81,43 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa // Accept either `{ hooks: { … } }` (a settings file) or the bare event map. const root = asObject(raw) const hooksMap = root ? asObject(root.hooks) ?? root : undefined - if (hooksMap) { - for (const event of CLAUDE_EVENTS) { - const rawGroups = hooksMap[event] - if (!Array.isArray(rawGroups)) continue - const groups: MatcherGroup[] = [] - for (const rawGroup of rawGroups) { - const group = asObject(rawGroup) - if (!group || !Array.isArray(group.hooks)) continue - const commands: MatcherGroup['hooks'] = [] - for (const rawHook of group.hooks) { - const hook = asObject(rawHook) - if (!hook) continue - const type = typeof hook.type === 'string' ? hook.type : 'command' - if (type !== 'command') { - skipped.push({ event, type }) - continue - } - if (typeof hook.command !== 'string') continue - commands.push({ - command: substituteCommand(hook.command, vars), - ...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}, - }) + if (!hooksMap) return { config, skipped } + + for (const event of CLAUDE_EVENTS) { + const rawGroups = hooksMap[event] + if (!Array.isArray(rawGroups)) continue + const groups: MatcherGroup[] = [] + for (const rawGroup of rawGroups) { + const group = asObject(rawGroup) + if (!group || !Array.isArray(group.hooks)) continue + const commands: MatcherGroup['hooks'] = [] + for (const rawHook of group.hooks) { + const hook = asObject(rawHook) + if (!hook) continue + const type = typeof hook.type === 'string' ? hook.type : 'command' + if (type !== 'command') { + skipped.push({ event, type }) + continue } - if (commands.length === 0) continue - const matcher = event === 'UserPromptSubmit' || event === 'Stop' - ? undefined - : typeof group.matcher === 'string' ? group.matcher : undefined - groups.push({ - ...matcher !== undefined ? { matcher } : {}, - hooks: commands, + if (typeof hook.command !== 'string') continue + commands.push({ + command: substituteCommand(hook.command, vars), + ...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}, }) } - if (groups.length > 0) config[event] = groups + 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({ + ...matcher !== undefined ? { matcher } : {}, + hooks: commands, + }) } + if (groups.length > 0) config[event] = groups } - /* jscpd:ignore-start -- dialect-local event diagnostics intentionally stay beside parsing. */ - const entries = Object.entries(config).flatMap(([event, groups]) => ( - groups.map(group => ({ event, matcher: group.matcher })) - )) - const matchers = compileMatchers(new Set(entries.map(entry => entry.matcher)), 'claude') - for (const { event, matcher } of entries) { - const diagnostic = matchers.diagnostic(matcher) - if (diagnostic === undefined) continue - matchers.dispose() - throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) - } - /* jscpd:ignore-end */ - - return { config, skipped, matchers } + return { config, skipped } } diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index d41419b185..8552598818 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -24,6 +24,7 @@ import { createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, + matchesMatcher, mergeHookOutputs, runHook, type HookOutput, @@ -34,7 +35,7 @@ import { // declarations (declaration-merged into cordis `Events` by dsh-subagent) so the // SubagentStart/SubagentStop listeners below type-check. import type {} from '@deepseek-ai/dsh-subagent' -import { parseClaudeConfig, type ParsedClaudeConfig } from './config.ts' +import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts' export const name = 'hooks-claude' // `bash` is required to run hooks; the rest are read opportunistically via @@ -99,37 +100,26 @@ export function apply(ctx: Context, config: Config): void { assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS // Parse once at load. A read or parse failure logs and registers nothing. - let result: ParsedClaudeConfig + let parsed: ClaudeHookConfig = {} try { const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) - result = parseClaudeConfig(raw, { + const result = parseClaudeConfig(raw, { ...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {}, ...config.projectDir !== undefined ? { projectDir: config.projectDir } : {}, }) + parsed = result.config + for (const s of result.skipped) { + ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`) + } } catch (error: unknown) { ctx.logger.warn(`hooks-claude: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`) return } - const parsed = result.config - // Parsing validates through this same registry, so admission and runtime do - // not construct separate matcher instances. - const matchers = result.matchers - // Emit-shaped points run detached, so track their chains; disposal aborts - // active hooks and drains continuations before releasing matchers. + // active hooks and drains continuations before resolving. const detached = createDetachedRuns() - ctx.effect(() => async () => { - try { - await detached.drain() - } finally { - matchers.dispose() - } - }, 'hooks-claude: drain detached hook runs and dispose matchers') - - for (const s of result.skipped) { - ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`) - } + ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs') /** * Run every command hook configured for `point` whose matcher selects @@ -157,7 +147,7 @@ export function apply(ctx: Context, config: Config): void { const projectDir = config.projectDir ?? workdir const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined for (const group of groups) { - if (!matchers.matches(group.matcher, matchQuery)) continue + if (!matchesMatcher(group.matcher, matchQuery, 'claude')) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) const session = opts.agent?.session diff --git a/packages/hooks/hooks-claude/tests/config.spec.ts b/packages/hooks/hooks-claude/tests/config.spec.ts index 277924fbd3..343fd6730e 100644 --- a/packages/hooks/hooks-claude/tests/config.spec.ts +++ b/packages/hooks/hooks-claude/tests/config.spec.ts @@ -1,14 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { parseClaudeConfig as parseRawClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts' - -const matcherSets: Array['matchers']> = [] -afterEach(() => { for (const matchers of matcherSets.splice(0)) matchers.dispose() }) - -function parseClaudeConfig(...args: Parameters): ReturnType { - const result = parseRawClaudeConfig(...args) - matcherSets.push(result.matchers) - return result -} +import { describe, expect, it } from 'vitest' +import { parseClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts' describe('substituteCommand', () => { it('replaces CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR (all occurrences)', () => { @@ -73,13 +64,6 @@ describe('parseClaudeConfig', () => { expect('matcher' in config.Stop![0]!).toBe(false) }) - it('returns the same validated matcher registry for runtime use', () => { - const { matchers } = parseClaudeConfig({ - PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'x.sh' }] }], - }) - expect(matchers.matches('^Bash$', 'Bash')).toBe(true) - }) - it('rejects an invalid regex matcher with its event name', () => { expect(() => parseClaudeConfig({ PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }], diff --git a/packages/hooks/hooks-codex/README.i18n.yaml b/packages/hooks/hooks-codex/README.i18n.yaml index 268651fb15..90e7f7c1dd 100644 --- a/packages/hooks/hooks-codex/README.i18n.yaml +++ b/packages/hooks/hooks-codex/README.i18n.yaml @@ -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: eb8882cda590293e21dd6011244f15359a797768 -README.zh.md: 641b7b57ad78f21d2df52dfc05ff4e8466a0f1c0 +README.md: e906810ed58c3d0204c618c32787af06c91cfb78 +README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334 diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index eb8882cda5..e906810ed5 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -7,7 +7,7 @@ A cordis plugin that runs the supported subset of a user's existing **Codex** ho This bridge implements a deliberate subset of Codex's current hook protocol: - **Five of ten hook points:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. -- **Native Codex matcher semantics:** pure word/pipe patterns are exact alternatives; other patterns are unanchored Rust `regex` expressions (including inline flags such as `(?i)`). +- **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex). - **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline. - **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell). - **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input. @@ -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); an invalid regex matcher on an event that consumes matchers is one such failure and reports its pattern and event. Non-literal Rust-regex patterns are interned across reloads under a process budget of 128 distinct patterns: once full, a new distinct pattern is rejected before WASM construction with a capacity diagnostic, while already interned patterns remain usable; restarting the process resets the budget. 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. diff --git a/packages/hooks/hooks-codex/README.zh.md b/packages/hooks/hooks-codex/README.zh.md index 641b7b57ad..4940fdb976 100644 --- a/packages/hooks/hooks-codex/README.zh.md +++ b/packages/hooks/hooks-codex/README.zh.md @@ -7,7 +7,7 @@ 该桥接实现 Codex 当前 hook 协议的一个明确子集: - **10 个 hook 点中的 5 个:** `PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。 -- **Codex 原生 matcher 语义:** 纯 word/pipe pattern 是精确匹配的多选;其他 pattern 是未锚定的 Rust `regex` 表达式(包括 `(?i)` 等内联 flag)。 +- **仅使用正则的 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)`)。读取/解析失败会被隔离处理(记录 + 不注册任何内容);实际消费 matcher 的事件所带的无效 matcher 正则属于此类失败,并报告其 pattern 与事件。非字面的 Rust-regex pattern 会跨重载 intern,并受每进程最多 128 个不同 pattern 的预算约束:容量用满后,新的不同 pattern 会在 WASM 构造前被容量诊断拒绝,已经 intern 的 pattern 仍可使用;重启进程会重置预算。只运行同步 `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 作用于用户项目树,而非服务器启动目录。 diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index 6279473d91..ae82340ad4 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -5,11 +5,7 @@ * @module @deepseek-ai/dsh-hooks-codex/config */ -import { - compileMatchers, - type CompiledMatchers, - 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 @@ -27,8 +23,6 @@ export interface SkippedHook { export interface ParsedCodexConfig { config: CodexHookConfig skipped: SkippedHook[] - /** Config-scoped matcher registry; the caller owns and must dispose it. */ - matchers: CompiledMatchers } function asObject(value: unknown): Record | undefined { @@ -42,8 +36,7 @@ function asObject(value: unknown): Record | undefined { * 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. Validation and runtime matching - * share the returned compiled registry; its caller must dispose it. + * 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. */ @@ -52,51 +45,42 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig { const skipped: SkippedHook[] = [] const root = asObject(raw) const hooksMap = root ? asObject(root.hooks) ?? root : undefined - if (hooksMap) { - for (const event of CODEX_EVENTS) { - const rawGroups = hooksMap[event] - // Matcher-group parsing remains dialect-local because the supported hook - // shapes and skip reasons differ from Claude Code's. - /* jscpd:ignore-start */ - if (!Array.isArray(rawGroups)) continue - const groups: MatcherGroup[] = [] - for (const rawGroup of rawGroups) { - const group = asObject(rawGroup) - if (!group || !Array.isArray(group.hooks)) continue - const commands: MatcherGroup['hooks'] = [] - for (const rawHook of group.hooks) { - const hook = asObject(rawHook) - if (!hook) continue - const type = typeof hook.type === 'string' ? hook.type : 'command' - if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue } - /* jscpd:ignore-end */ - if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue } - if (typeof hook.command !== 'string') continue - // Codex accepts `timeout` or the `timeoutSec` alias. - const timeout = typeof hook.timeout === 'number' ? hook.timeout - : typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined - commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) - } - if (commands.length === 0) continue - const matcher = event === 'UserPromptSubmit' || event === 'Stop' - ? undefined - : typeof group.matcher === 'string' ? group.matcher : undefined - groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands }) + if (!hooksMap) return { config, skipped } + + for (const event of CODEX_EVENTS) { + const rawGroups = hooksMap[event] + // Matcher-group parsing remains dialect-local because the supported hook + // shapes and skip reasons differ from Claude Code's. + /* jscpd:ignore-start */ + if (!Array.isArray(rawGroups)) continue + const groups: MatcherGroup[] = [] + for (const rawGroup of rawGroups) { + const group = asObject(rawGroup) + if (!group || !Array.isArray(group.hooks)) continue + const commands: MatcherGroup['hooks'] = [] + for (const rawHook of group.hooks) { + const hook = asObject(rawHook) + if (!hook) continue + const type = typeof hook.type === 'string' ? hook.type : 'command' + if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue } + /* jscpd:ignore-end */ + if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue } + if (typeof hook.command !== 'string') continue + // Codex accepts `timeout` or the `timeoutSec` alias. + const timeout = typeof hook.timeout === 'number' ? hook.timeout + : typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined + commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} }) } - if (groups.length > 0) config[event] = groups + if (commands.length === 0) continue + 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 } - const entries = Object.entries(config).flatMap(([event, groups]) => ( - groups.map(group => ({ event, matcher: group.matcher })) - )) - const matchers = compileMatchers(new Set(entries.map(entry => entry.matcher)), 'codex') - for (const { event, matcher } of entries) { - const diagnostic = matchers.diagnostic(matcher) - if (diagnostic === undefined) continue - matchers.dispose() - throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`) - } - - return { config, skipped, matchers } + return { config, skipped } } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index b4089a48ec..d68e2b9d0a 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -1,10 +1,9 @@ /** * Bridge for unmodified Codex command hooks on harness interception seams. It - * supports five points (SessionStart, prompt/tool pre/post, Stop), native - * literal-or-Rust-regex matchers, snake_case payloads without a trailing - * newline, no hook environment or command substitution, and no pre-tool - * approval or rewrite path; only blocking decisions are honored. Shared - * execution and parsing live in + * supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only + * matchers, snake_case payloads without a trailing newline, no hook environment + * or command substitution, and no pre-tool approval or rewrite path; only + * blocking decisions are honored. Shared execution and parsing live in * `dsh-hook-protocol`; see the * [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md). * @module @deepseek-ai/dsh-hooks-codex @@ -28,13 +27,14 @@ import { createDetachedRuns, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, + matchesMatcher, mergeHookOutputs, runHook, type HookOutput, type MatcherGroup, type MergedHookOutcome, } from '@deepseek-ai/dsh-hook-protocol' -import { parseCodexConfig, type ParsedCodexConfig } from './config.ts' +import { parseCodexConfig, type CodexHookConfig } from './config.ts' /* jscpd:ignore-end */ export const name = 'hooks-codex' @@ -83,37 +83,26 @@ export function apply(ctx: Context, config: Config): void { const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS - let result: ParsedCodexConfig + let parsed: CodexHookConfig = {} try { const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) - result = parseCodexConfig(raw) + const result = parseCodexConfig(raw) + parsed = result.config + for (const s of result.skipped) { + ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`) + } } catch (error: unknown) { ctx.logger.warn(`hooks-codex: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`) return } - const parsed = result.config const model = config.model ?? '' - // Parsing validates through this same registry, so no native regex is rebuilt - // between config admission and runtime matching. - const matchers = result.matchers // SessionStart is the one emit-shaped (detached) point Codex has: track its // run chains so disposal aborts a still-running hook process and drains the - // continuation before releasing matchers (docs/defensive-patterns.md: - // dispose must reach quiescence). + // continuation (docs/defensive-patterns.md: dispose must reach quiescence). const detached = createDetachedRuns() - ctx.effect(() => async () => { - try { - await detached.drain() - } finally { - matchers.dispose() - } - }, 'hooks-codex: drain detached hook runs and dispose matchers') - - for (const s of result.skipped) { - ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`) - } + ctx.effect(() => () => detached.drain(), 'hooks-codex: drain detached hook runs') /** * Run and fold one configured Codex hook point. @@ -137,11 +126,9 @@ export function apply(ctx: Context, config: Config): void { // Run hooks in the agent's session workspace so relative paths address the // user's project rather than the server launch directory. const workdir = opts.agent?.session.header.cwd - // Keep each dialect's audit stamping readable beside its payload mapping. - /* jscpd:ignore-start */ for (const group of groups) { - // The protocol library owns Codex's exact-literal/Rust-regex split. - if (!matchers.matches(group.matcher, matchQuery)) continue + // Codex always interprets matchers as regexes; it has no literal fast path. + if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) const session = opts.agent?.session @@ -151,7 +138,6 @@ export function apply(ctx: Context, config: Config): void { ...group.matcher !== undefined ? { matcher: group.matcher } : {}, }) } - /* jscpd:ignore-end */ const { output, durationMs } = await runHook(ctx.bash, hook, { payload, defaultTimeoutMs, diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 91f30e33d4..3e9ae5617a 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -66,11 +66,11 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): } describe('hooks-codex bridge', () => { - it('a PreToolUse hook (exit 2) honors a Rust-regex inline flag matcher', async () => { + it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => { const dir = configDir() const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\necho "codex blocked it" >&2\nexit 2\n') - // `(?i)` is accepted by Rust regex but rejected by JavaScript RegExp. - writeHooks(dir, { PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: deny }] }] }) + // Codex regex matcher: "Bash" is /Bash/ — matches the tool name "Bash". + writeHooks(dir, { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: deny }] }] }) const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(dir, adapter) diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index 541365a531..8503d13151 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -1,14 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { parseCodexConfig as parseRawCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts' - -const matcherSets: Array['matchers']> = [] -afterEach(() => { for (const matchers of matcherSets.splice(0)) matchers.dispose() }) - -function parseCodexConfig(...args: Parameters): ReturnType { - const result = parseRawCodexConfig(...args) - matcherSets.push(result.matchers) - return result -} +import { describe, expect, it } from 'vitest' +import { parseCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts' describe('parseCodexConfig', () => { it('honors only the five bridge-supported Codex events, dropping the rest', () => { @@ -70,10 +61,9 @@ describe('parseCodexConfig', () => { expect('matcher' in config.Stop![0]!).toBe(false) }) - it('keeps a valid Rust-regex matcher when present', () => { - const { config, matchers } = parseCodexConfig({ PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] }) - expect(config.PreToolUse![0]!.matcher).toBe('(?i)^bash$') - expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true) + it('keeps a matcher when present', () => { + 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', () => { @@ -82,12 +72,6 @@ describe('parseCodexConfig', () => { })).toThrow('invalid codex regex matcher "[" on event "PreToolUse"') }) - it('rejects JavaScript-only regex syntax that Codex cannot execute', () => { - expect(() => parseCodexConfig({ - PreToolUse: [{ matcher: '(?=Bash)', hooks: [{ type: 'command', command: 's.sh' }] }], - })).toThrow('invalid codex regex matcher "(?=Bash)" 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' }] }], diff --git a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts b/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts deleted file mode 100644 index 0255b29d09..0000000000 --- a/packages/hooks/hooks-codex/tests/matcher-lifecycle.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Context } from 'cordis' -import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' - -const matcherLifecycle = vi.hoisted(() => { - const registry = { - matches: vi.fn(() => true), - diagnostic: vi.fn<(matcher: string | undefined) => string | undefined>(() => undefined), - dispose: vi.fn<() => void>(), - } - return { - registry, - compileMatchers: vi.fn(() => registry), - } -}) - -vi.mock('@deepseek-ai/dsh-hook-protocol', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, compileMatchers: matcherLifecycle.compileMatchers } -}) - -const dirs: string[] = [] -afterEach(() => { - for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) - vi.clearAllMocks() - matcherLifecycle.registry.diagnostic.mockReturnValue(undefined) -}) - -describe('hooks-codex matcher lifecycle', () => { - it('disposes the compiled set when one event-specific diagnostic rejects the config', async () => { - const { parseCodexConfig } = await import('@deepseek-ai/dsh-hooks-codex/src/config.ts') - matcherLifecycle.registry.diagnostic.mockImplementation((matcher: string | undefined) => ( - matcher === '[' ? 'invalid codex regex matcher "["' : undefined - )) - - expect(() => parseCodexConfig({ - PreToolUse: [ - { matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'first' }] }, - { matcher: '[', hooks: [{ type: 'command', command: 'second' }] }, - ], - })).toThrow('invalid codex regex matcher "[" on event "PreToolUse"') - - expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith( - new Set(['(?i)^bash$', '[']), - 'codex', - ) - expect(matcherLifecycle.registry.dispose).toHaveBeenCalledOnce() - }) - - it('gives the loaded config one matcher registry and disposes it on plugin teardown', async () => { - const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-matchers-')) - dirs.push(dir) - const configPath = join(dir, 'hooks.json') - writeFileSync(configPath, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'true' }] }], - PostToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'true' }] }], - } })) - - const HooksCodex = await import('@deepseek-ai/dsh-hooks-codex') - const ctx = new Context() - await ctx.plugin(LocalSubprocessService) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - const fiber = await ctx.plugin(HooksCodex, { configPath, model: 'm' }) - - expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith(new Set([ - '(?i)^bash$', - ]), 'codex') - expect(matcherLifecycle.registry.diagnostic).toHaveBeenCalledTimes(2) - expect(matcherLifecycle.registry.dispose).not.toHaveBeenCalled() - - await fiber.dispose() - expect(matcherLifecycle.registry.dispose).toHaveBeenCalledOnce() - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cc14d36a7..99ac494365 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2799,10 +2799,6 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/hooks/hook-protocol: - dependencies: - rregex: - specifier: 1.12.0 - version: 1.12.0 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -10310,9 +10306,6 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} - rregex@1.12.0: - resolution: {integrity: sha512-lMRD7lU4TYrAyhrN6/3PXp6wiOtbsdVuHD9JtNsFCW7ZsRaOWQ2vVB41whpU1jWny1JTTS6aRnnkdSOUMdwFKQ==} - rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -15714,8 +15707,6 @@ snapshots: transitivePeerDependencies: - supports-color - rregex@1.12.0: {} - rw@1.3.3: {} sade@1.8.1: From 4e7f90667a95b2f7b28b2101c50c1b20b84a239e Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 29 Jul 2026 23:59:58 +0800 Subject: [PATCH 19/31] fix: chat agent message actions display --- ...2026-07-29-web-message-icon-actions-and-clock.md | 10 +++++++--- ...6-07-29-web-message-icon-actions-and-clock.zh.md | 10 +++++++--- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/AssistantMarkdown.module.css | 8 -------- .../src/client/chat/AssistantMarkdown.tsx | 12 +++++++++--- .../src/client/chat/MessageIconActions.module.css | 10 +--------- .../src/client/chat/MessageIconActions.tsx | 2 +- .../src/client/chat/MessageItem.module.css | 8 -------- .../tests/chat-branch-tails.spec.tsx | 13 ++++++++++++- 10 files changed, 39 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md index e0072458e4..e796620567 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md @@ -10,18 +10,22 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo ## Decision -**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant nodes append a copy / branch / clock row with `margin-top: 16px`; both seats re-format at the next local midnight.** +**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant *content* nodes (non-empty text blocks) append a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.** -Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) and only when `streaming` is false with a known event time; the streaming tail omits the row. Copy writes joined text blocks. Branch stays a chrome stub. Hover-capable pointers keep both footers opacity-hidden until hover/focus-within. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. +Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content; Think-only nodes and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`. ## Alternatives considered **Show assistant IconActions during streaming.** Rejected: the request is to reveal the row only after output completes; mid-stream chrome would flicker and invite copying a partial answer. +**Put IconActions under every finalized assistant node (including Think-only).** Rejected: copy has nothing useful to write without text content, and repeating the chrome under every step/Think row clutters the flow; only content output owns the seat. + +**Hover-reveal the action row on hover-capable pointers.** Rejected: once the row exists it should stay discoverable; opacity hiding made the chrome easy to miss and required parent hover selectors that duplicated the mount gate. + **Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat. **Publish the calendar day through a chat store or inject hook.** Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source. ## Consequences -Settled assistant answers expose copy and the event clock immediately; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes and the midnight widen; the web e2e scenario pins the assembled IconActions chrome. +Settled assistant content answers expose copy and the event clock as soon as the row mounts; Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, and the content-only assistant gate; the web e2e scenario pins the assembled IconActions chrome. diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md index 1cc25a9656..72d3b4e0cd 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md @@ -10,18 +10,22 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有 ## 决策 -**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant 节点在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边都在下一个本地午夜重新格式化。** +**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant *内容*节点(非空 text 块)在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。** -两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false 且已知事件时间时渲染;流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。具备 hover 能力的指针在 hover/focus-within 前保持两条 footer 透明。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 +两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染;纯 Think 节点与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。 ## 曾考虑的方案 **在流式过程中展示 assistant IconActions。** 否决:需求是输出完成后才展示该行;中途 chrome 会闪烁,并诱使复制半截回答。 +**给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。** 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。 + +**在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。 + **把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。 **通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。 ## 后果 -已定稿的 assistant 回答立刻暴露复制与事件时钟;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态与午夜加宽;Web e2e 场景钉住组装后的 IconActions chrome。 +已定稿的 assistant 内容回答在行挂载后立刻暴露复制与事件时钟;纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽与 assistant 仅内容门控;Web e2e 场景钉住组装后的 IconActions chrome。 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5e24e4aad5..09adb3fd75 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,7 +36,7 @@ None; this package neither assembles nor sends a provider request. - **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub. +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 89a34041e1..d92f2ca727 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,7 +36,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index d988cf52f8..8fdba2baa0 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css @@ -34,11 +34,3 @@ /* Optical align with 28px icon hit targets that pad 6px past the glyph. */ margin-left: -6px; } - -/* Hover-capable pointers: reveal shared actions on root hover/focus. */ -@media (hover: hover) { - .root:hover .actions, - .root:focus-within .actions { - opacity: 1; - } -} diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index e5a89a9e88..904aeee0d8 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -4,7 +4,8 @@ // view groups them into tool rows through its keyed toolview slot (figma // step-summary flow). Shared by finalized nodes and the streaming partial; // the turn-level loading dots live in the chat view's tail, not here. -// Finalized nodes append IconActions (copy / branch / clock) once streaming ends. +// Finalized content (text) nodes append IconActions once streaming ends; +// Think / tool-head-only nodes stay chrome-free. import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -38,6 +39,11 @@ function copyText(blocks: readonly AssistantBlock[]): string { return parts.join('') } +/** True when the node has model-visible text content worth chrome under. */ +function hasContentText(blocks: readonly AssistantBlock[]): boolean { + return blocks.some(block => block.kind === 'text' && block.text.trim() !== '') +} + /** Reasoning block as the Think variant summary row (figma 39:28304). */ function ThinkRow({ text, running }: { text: string; running: boolean }) { return ( @@ -64,8 +70,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ || interrupted === true || blocks.some(block => block.kind !== 'tool-call') if (!hasVisible) return null - // Footer only after the turn settles with a known event time; streaming omits it. - const showActions = !streaming && time !== undefined + // Footer only under settled content text; Think-only / streaming omit it. + const showActions = !streaming && time !== undefined && hasContentText(blocks) return (
diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css index 30d6920609..b247b7e2bf 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css @@ -1,5 +1,5 @@ /* Shared message IconActions row (user + assistant). Parent modules own - hover-reveal selectors and layout offsets via the composed className. */ + layout offsets via the composed className. Always visible when mounted. */ .actions { display: flex; @@ -25,14 +25,6 @@ white-space: nowrap; } -/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */ -@media (hover: hover) { - .actions { - opacity: 0; - transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); - } -} - .action { display: inline-flex; align-items: center; diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index 7579a4c249..fc76cfb753 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -18,7 +18,7 @@ export interface MessageIconActionsProps { clock: 'start' | 'end' /** When true, append the stub edit control (user bubble). */ edit?: boolean | undefined - /** Parent layout / hover-reveal class composed onto the actions row. */ + /** Parent layout class composed onto the actions row. */ className?: string | undefined } diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 260382d530..2667024bcd 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -20,14 +20,6 @@ color: var(--dsw-alias-label-primary); } -/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */ -@media (hover: hover) { - .userRow:hover .actions, - .userRow:focus-within .actions { - opacity: 1; - } -} - .badge { display: inline-block; margin-bottom: 4px; diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 9bb6ba539a..c9d92aafec 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -177,7 +177,7 @@ describe('small branch tails', () => { expect(view.getByText('one-liner')).toBeTruthy() }) - it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => { + it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -199,6 +199,17 @@ describe('small branch tails', () => { expect(writeText).toHaveBeenCalledWith('answer body') settled.unmount() + const thinkOnly = render( + , + ) + expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull() + expect(thinkOnly.queryByText('14:24')).toBeNull() + thinkOnly.unmount() + const streaming = render( , ) From f893e2281dd3187328427f6463dfeb8924e1fa1c Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 00:05:00 +0800 Subject: [PATCH 20/31] feat(agent): add addressable queue operations --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 4 +- ...ied-send-and-coalesced-user-messages.zh.md | 4 +- ...-29-addressable-queue-operations.i18n.yaml | 6 + ...2026-07-29-addressable-queue-operations.md | 41 +++ ...6-07-29-addressable-queue-operations.zh.md | 41 +++ .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 4 +- .../2026-07-17-one-send-one-turn.zh.md | 4 +- apps/web/tests/queue-actions.e2e.ts | 117 ++++++++ .../queue-actions/editing.expected.md | 40 +++ .../snapshots/queue-actions/ui.expected.md | 34 +++ apps/web/tests/steering.e2e.ts | 12 +- apps/web/tsconfig.json | 3 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/cordis-catalog/events.md | 73 +++-- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 41 ++- docs/core-data-structures/core.zh.md | 41 ++- docs/event-producer-consumer.md | 31 +- packages/acp/acp/tests/turns.spec.ts | 4 +- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 6 + .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 2 + packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 4 + packages/client/runtime/README.zh.md | 4 + .../runtime/src/client/contract/session.ts | 11 +- .../src/client/sessions/conversation.ts | 11 +- .../runtime/src/client/sessions/manager.ts | 12 +- .../runtime/src/client/sessions/session.ts | 82 ++--- packages/client/runtime/tests/fake-api.ts | 2 + .../client/runtime/tests/queue-store.spec.ts | 281 +++++++----------- packages/client/test-runtime/src/sessions.ts | 8 + packages/client/tsdown.client.ts | 2 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/input/contract.ts | 8 +- .../src/client/queue/QueueDock.module.css | 118 ++++++-- .../src/client/queue/QueueDock.tsx | 180 +++++++++-- .../ui-conversation/src/client/queue/store.ts | 4 +- .../ui-conversation/src/client/service.ts | 17 ++ .../ui-conversation/tests/queue-dock.spec.tsx | 174 +++++++++-- .../tests/service-orchestration.spec.ts | 7 +- .../time-context/tests/time-context.spec.ts | 1 + .../tests/workspace-context.spec.ts | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 33 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/README.zh.md | 4 +- packages/core/agent-loop/src/agent.ts | 90 +++++- .../tests/contract-regressions.spec.ts | 83 +++++- .../agent-loop/tests/interception.spec.ts | 11 +- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 3 +- packages/core/agent/README.zh.md | 3 +- packages/core/agent/package.json | 5 + packages/core/agent/src/brand.ts | 23 ++ packages/core/agent/src/index.ts | 1 + packages/core/agent/src/types.ts | 68 ++++- packages/core/agent/tests/agent.spec.ts | 1 + packages/core/agent/tests/invariant.spec.ts | 27 +- .../core/scope/src/scoped-events.generated.ts | 1 + packages/core/scope/tests/invariant.spec.ts | 8 +- .../command-goal/tests/command-goal.spec.ts | 1 + packages/goal/goal-session/src/index.ts | 4 +- .../goal-session/tests/goal-session.spec.ts | 14 +- packages/goal/goal/tests/goal.spec.ts | 1 + packages/goal/goal/tests/projection.spec.ts | 1 + .../goal/tool-goal/tests/tool-goal.spec.ts | 1 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/src/api-proxy.ts | 98 ++++-- .../host/apiproxy/src/api/events.schema.ts | 14 +- packages/host/apiproxy/src/api/events.ts | 27 +- packages/host/apiproxy/src/api/index.ts | 5 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 2 + .../host/apiproxy/src/api/sessions.schema.ts | 20 ++ packages/host/apiproxy/src/api/sessions.ts | 15 + packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 2 + .../apiproxy/tests/api-proxy-commands.spec.ts | 130 ++++---- .../tests/api-proxy-workspace.spec.ts | 1 + .../apiproxy/tests/client-handler.spec.ts | 1 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 28 +- packages/pty/pty-local/tests/index.spec.ts | 6 +- packages/pty/pty-local/tests/local.spec.ts | 2 +- packages/pty/pty/tests/service.spec.ts | 1 + .../tool-pty/tests/loader-composition.spec.ts | 2 +- packages/pty/tool-pty/tests/tools.spec.ts | 2 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 1 + .../tasks/tasks-local/tests/tasks.spec.ts | 1 + packages/ui/tui/src/index.ts | 12 +- packages/ui/tui/tests/harness.ts | 1 + packages/ui/tui/tests/tui.spec.ts | 68 +++-- scripts/client-bundle-purity.spec.ts | 48 ++- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 15 + tsconfig.host.json | 1 + 108 files changed, 1785 insertions(+), 608 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md create mode 100644 .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md create mode 100644 apps/web/tests/queue-actions.e2e.ts create mode 100644 apps/web/tests/snapshots/queue-actions/editing.expected.md create mode 100644 apps/web/tests/snapshots/queue-actions/ui.expected.md create mode 100644 packages/core/agent/src/brand.ts diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 46b84213b9..54bfa56d7a 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md -2026-07-22-unified-send-and-coalesced-user-messages.md: ed171735cf483938c70291963a6e68dc02d7bde2 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 8b2a3ebabb493954e653255e876255b9c0810c19 +2026-07-22-unified-send-and-coalesced-user-messages.md: 3b2187f9e9ae24f3a03c1418daf1c0aec255b314 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 02a21193ce63926b6f04a02cb7ea8a64fe6603cb diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index ed171735cf..3b2187f9e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -22,7 +22,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj **`send` does not return identity.** Callers already own the complete message and its opaque `MessageId`; creation and freezing are owned by the [identified immutable message decision](2026-07-28-identified-immutable-message-values.md), not by routing. -**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) carry the accepted `UserMessage`. Enqueue and dequeue also carry the resolved `queued | steering` placement captured at acceptance, so observers and reconnect mirrors retire repeated message identities from the correct FIFO without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including steering submitted by an `agent/turn-stopping` listener, so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. +**Inbox lifecycle events carry occurrence identities.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/update` (a pending item was edited or promoted), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (pending items were dropped) carry an `InboxItem`: an occurrence-local `InboxItemId`, the accepted `UserMessage`, and the resolved `queued | steering` placement captured at acceptance. The occurrence identity lets observers and reconnect mirrors distinguish repeated sends of the same `MessageId` without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes one enqueue and exactly one terminal dequeue or discard; updates are non-terminal. The `dsh-agent` invariant companion asserts this FIFO conservation. **Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt. When a later prompt is admitted, retained outbox input enters its turn before that prompt, while input accepted during the current admission remains after the prompt. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate. @@ -43,7 +43,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. The goal fold's channel split moved from event type to `source.round`, and every consumer that filtered `context/message` now filters `user/message` by source. An idle injection appends `user/message` between turns without opening a turn or running the model. -`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to dequeue: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-item representation keeps public lifecycle events correlated without maintaining a second steering wrapper or allowing its durable data to diverge. +`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to dequeue: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-item representation keeps public lifecycle events correlated without maintaining a second steering wrapper or allowing its durable data to diverge. The later [addressable queue operations](../feature/2026-07-29-addressable-queue-operations.md) decision adds live mutations over that occurrence identity without changing the one-message-per-turn or durable-message contracts. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 8b2a3ebabb..02a21193ce 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -22,7 +22,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` **`send` 不返回标识。** 调用方已经持有完整消息及其不透明的 `MessageId`;消息的创建与冻结由[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)负责,而不是由路由负责。 -**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都会携带已接受的 `UserMessage`。enqueue 和 dequeue 还会携带生产方在接受消息时捕获的已解析 `queued | steering` 放置方式,因此观察方和重连镜像可以从正确的 FIFO 中结算重复出现的消息标识,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括 `agent/turn-stopping` 监听器提交的 steering,因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 +**Inbox 生命周期事件携带单次入队标识。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/update`(待处理项被编辑或前移)、`agent/inbox/dequeue`(驱动器认领一个项)和 `agent/inbox/discard`(待处理项被丢弃)都会携带一个 `InboxItem`:仅属于本次入队的 `InboxItemId`、已接受的 `UserMessage`,以及生产方在接受消息时捕获的已解析 `queued | steering` 放置方式。单次入队标识让观察方和重连镜像能够区分同一 `MessageId` 的多次发送,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每次 FIFO 入队都会发布一个 enqueue,并且恰好发布一个终态 dequeue 或 discard;update 不是终态。`dsh-agent` 的不变量配套断言这种 FIFO 守恒。 **准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词。后续提示词获准时,保留在 outbox 中的输入会先于该提示词进入其轮次,而当前准入期间接受的输入则留在提示词之后。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。 @@ -43,7 +43,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲状态下的注入会在两个轮次之间追加 `user/message`,既不打开轮次,也不运行模型。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算完全停稳。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。后续的[可寻址队列操作](../feature/2026-07-29-addressable-queue-operations.md)决策在该单次入队标识上增加了实时变更,但不改变单消息单轮次或持久消息契约。 ## 相关 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml new file mode 100644 index 0000000000..fe91b8ccda --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +2026-07-29-addressable-queue-operations.md: 7462b882dde0c3b25ddfb321ab339b6cd51bd170 +2026-07-29-addressable-queue-operations.zh.md: ac442421a1e21b2e09bb003ca9be1a0412374d7f diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md new file mode 100644 index 0000000000..7462b882dd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -0,0 +1,41 @@ +# Agent Note: Address pending queue occurrences for edit, remove, and promotion + +Status: implemented + +English | [中文](2026-07-29-addressable-queue-operations.zh.md) + +## Problem + +The Web queue rendered pending messages but could not act on one row. `MessageId` was insufficient as an address because callers may enqueue the same immutable message more than once. The browser also inferred queue retirement from turn and status events, so a row operation racing with driver claim had no authoritative outcome. + +“Send now” introduced a separate semantic choice: it could mean reorder the next independent turn, interrupt the current turn as steering, or cancel current work. Only the first interpretation preserves the queue row’s original delivery contract. + +## Decision + +**Each accepted FIFO occurrence has its own identity.** AgentLoop mints an opaque `InboxItemId` and publishes an `InboxItem` containing that id, the identified `UserMessage`, and its acceptance-time `queued | steering` placement. Reusing one `MessageId` creates distinct inbox identities. Injection bypasses the FIFOs and receives no inbox identity. + +**Mutation ends at driver claim.** `Agent.updateInbox(id, action)` synchronously searches the pending queued and steering FIFOs. Edit replaces frozen content while preserving `InboxItemId`, `MessageId`, source, placement, wake policy, and position. Remove emits the occurrence’s terminal discard. Promote moves it to the front of its current FIFO; an ordinary queued item also becomes waking. The driver removes an occurrence before prompt admission or steering drain, so a later mutation returns `not-found` and never rewrites durable history. + +**The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every live mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from `turn/start`, `steering/message`, or status changes. + +**Web actions preserve delivery kind.** QueueDock projects only `queued` occurrences; pending `steering` occurrences remain in the authoritative snapshot but wait for a dedicated Web interaction. It exposes edit and delete, but no send-now control. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. Protocol-level promotion remains available without being presented as a Web interaction; it never converts queued work into steering or cancels active work. + +## Alternatives considered + +**Address rows by `MessageId`.** Rejected because one immutable message may be sent repeatedly; editing or deleting by message identity would affect an ambiguous occurrence. + +**Apply optimistic browser mutations.** Rejected because driver claim and another client can win before the Host action. Waiting for the authoritative snapshot makes the ownership boundary visible and lets `queue-item-not-found` report a real race. + +**Treat send-now as steering.** Rejected because it would change a queued independent turn into current-turn context, bypass ordinary prompt admission, and alter the one-send-one-turn guarantee. Promotion changes priority, not delivery semantics. + +**Cancel the active turn before promotion.** Rejected because a row-local action must not destroy unrelated in-flight work. + +## Verification + +AgentLoop contract tests hold prompt admission while editing, removing, and promoting exact occurrences, then verify the resulting independent-turn order and terminal lifecycle events. Host schema and proxy tests cover authoritative snapshots, reconnect, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, queued-only Web projection, text-only editing, save and cancel affordances, removal, retirement races, disabled mixed-content editing, and the absent send-now control. Keyless browser scenarios drive the exposed edit and delete actions and keep accepted pending steering hidden until it becomes a durable transcript event through the built Web composition and real HTTP/SSE wire. + +## Consequences + +Pending work gains precise row operations without becoming durable session history. Occurrence identity is a live process-local capability and disappears at claim, cancellation, disposal, or restart; reconnect recovers only items still held by the live Agent. Send-now is intentionally weaker than interruption, and editing intentionally excludes mixed content until an editor can preserve every block. + +The protocol now carries full queue snapshots on each change. Queues are expected to remain short, so deterministic recovery and multi-client convergence are preferred over an incremental mutation protocol. diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md new file mode 100644 index 0000000000..ac442421a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -0,0 +1,41 @@ +# Agent Note(agent 决策记录):为待处理队列项提供编辑、移除与前移操作 + +Status: implemented + +[English](2026-07-29-addressable-queue-operations.md) | 中文 + +## 问题 + +Web 队列能够渲染待处理消息,但无法操作其中某一行。`MessageId` 不足以充当寻址标识,因为调用方可以多次将同一条不可变消息加入队列。浏览器还会根据轮次和状态事件推断队列项已退役,因此当行操作与驱动器认领发生竞态时,系统无法给出权威结果。 + +“立即发送”还引入了另一项语义选择:它可以表示重新排序下一个独立轮次、以 steering(中途引导)方式打断当前轮次,或取消当前工作。只有第一种解释能够保留该队列行原有的投递契约。 + +## 决策 + +**每次获准进入 FIFO 的项都有独立标识。** AgentLoop 会铸造不透明的 `InboxItemId`,并发布一个 `InboxItem`,其中包含该 id、已有标识的 `UserMessage`,以及接受时确定的 `queued | steering` 放置方式。复用同一个 `MessageId` 会创建不同的 inbox 标识。注入绕过 FIFO,因此不会获得 inbox 标识。 + +**变更边界止于驱动器认领。** `Agent.updateInbox(id, action)` 会同步搜索仍处于待处理状态的 queued 和 steering FIFO。编辑会替换已冻结的内容,同时保留 `InboxItemId`、`MessageId`、来源、放置方式、唤醒策略和位置。移除会发出该次入队项的终态 discard。前移会把它移至当前 FIFO 的队首;普通 queued 项还会变为可唤醒。驱动器会在提示词接纳或排空 steering 之前移除该项,因此之后的变更会返回 `not-found`,绝不会改写持久历史。 + +**实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 Host 镜像。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次实时变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据 `turn/start`、`steering/message` 或状态变化退役队列行。 + +**Web 操作保持投递类型。** QueueDock 只投影 `queued` 入队项;待处理的 `steering` 入队项仍保留在权威快照中,等待 Web 提供专用交互。它只暴露编辑和删除,不提供立即发送控件。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。协议层仍保留前移操作,但不会把它呈现为 Web 交互;该操作绝不会把 queued 工作转换为 steering,也不会取消活动工作。 + +## 考虑过的替代方案 + +**通过 `MessageId` 寻址行。** 不予采纳,因为同一条不可变消息可以重复发送;按消息标识编辑或删除会无法确定应操作哪一次入队。 + +**在浏览器中进行乐观变更。** 不予采纳,因为驱动器认领或另一个客户端可能先于 Host 操作完成。等待权威快照可以显式呈现所有权边界,并让 `queue-item-not-found` 报告真实竞态。 + +**把立即发送视为 steering。** 不予采纳,因为这会把一个独立的排队轮次变成当前轮次的上下文,绕过普通提示词接纳,并改变单次 send 单轮次保证。前移只改变优先级,不改变投递语义。 + +**前移前取消活动轮次。** 不予采纳,因为仅影响某一行的操作不应破坏无关的进行中工作。 + +## 验证 + +AgentLoop 契约测试会在编辑、移除和前移对应的精确入队项时阻塞提示词接纳,随后验证所得独立轮次顺序及终态生命周期事件。Host schema 与代理测试覆盖权威快照、重连、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、Web 仅投影 queued 项、仅文本编辑、保存与取消入口、移除、退役竞态、禁用混合内容编辑,以及不提供立即发送控件。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除,并使已接受的待处理 steering 在成为持久 transcript(文本记录)事件之前保持隐藏。 + +## 后果 + +待处理工作获得精确的行操作,但不会因此成为持久会话历史。单次入队标识是进程本地的实时寻址凭据,会在认领、取消、dispose 或重启时消失;重连只能恢复仍由活跃 Agent 持有的项。立即发送有意弱于打断,而编辑也有意排除混合内容,直至编辑器能够保留每个块。 + +现在,协议会在每次变更时携带完整队列快照。队列预期保持较短,因此系统优先选择确定性恢复和多客户端收敛,而非增量变更协议。 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 60181a2ddb..7d841e0bf9 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -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/simplification/2026-07-17-one-send-one-turn.md -2026-07-17-one-send-one-turn.md: dcc6c0aa483a0e53205dbaeef4e2b903f5f6a215 -2026-07-17-one-send-one-turn.zh.md: 8c12481defe6608c13ee81132b432b4d8b17b681 +2026-07-17-one-send-one-turn.md: 3ae43f137206f25bdbc563875c17e24211f17d6b +2026-07-17-one-send-one-turn.zh.md: 5ccdb2192048ecf795415bcd427f967df6a609fb diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index dcc6c0aa48..3ae43f1372 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -16,7 +16,7 @@ This grouping changes behavior, not just the number of model calls. One ordinary The rule is simple: each successful `send()` creates one independent FIFO queue item. If that item runs, it is the only ordinary message in its turn. An item can be dropped before it starts, so the precise guarantee is at most one turn rather than exactly one; two sends are never silently combined. -Before enqueueing an item, `send()` checks the agent state and makes a detached, deeply frozen snapshot of the content and resolved source. After enqueueing it, `send()` publishes `agent/queued`. +Before enqueueing an item, `send()` checks the agent state and accepts an already identified, deeply frozen message. It mints an occurrence-local `InboxItemId` and publishes `agent/inbox/enqueue`; the pending occurrence remains addressable under the [addressable queue operations](../feature/2026-07-29-addressable-queue-operations.md) decision until the driver claims or discards it. If messages A and B are both processed, B's turn starts only after A records `turn/end` and A's durability checkpoint settles. B's request therefore sees whatever closed result A left in the same session log. A checkpoint error is reported, but settlement only releases this ordering barrier; it does not make a failed write durable. Broad `cancel()`, disposal, or a failure before `turn/start` can instead discard an unstarted item without opening an empty turn. @@ -40,6 +40,6 @@ The no-batching rule applies only to ordinary `send()`. Running `steer()` puts i ## Consequences -Ordinary turn boundaries are predictable: messages A and B stay separate, and B runs only after A has closed and reached its checkpoint. Callers still do not receive a per-send completion or cancellation handle; broad cancellation can discard the entire unstarted tail, while status and quiescence remain agent-wide observations. +Ordinary turn boundaries are predictable: messages A and B stay separate, and B runs only after A has closed and reached its checkpoint. Callers still do not receive a per-send completion handle; a pending occurrence can be removed through its live `InboxItemId`, broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations. The trade-off is more model requests and more checkpoints. A busy queue can take longer to drain and can grow under sustained producers. Ordinary-send batching returns only through an explicit, measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index 8c12481def..5ccdb21920 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -16,7 +16,7 @@ Status: implemented 规则很简单:一次成功的 `send()` 创建一个独立的 FIFO 队列项。该队列项如果运行,就是所在轮次中唯一的普通消息。队列项可能在启动前被丢弃,因此精确保证是最多一个轮次,而不是必定一个轮次;两次 send 绝不会被悄悄合并。 -队列项入队之前,`send()` 会检查 agent 状态,并为内容和解析后的来源创建一份脱离调用方对象、经过深度冻结的快照。队列项入队之后,`send()` 发布 `agent/queued`。 +队列项入队之前,`send()` 会检查 agent 状态,并接受已有标识且经过深度冻结的消息。它会铸造一个仅属于本次入队的 `InboxItemId`,并发布 `agent/inbox/enqueue`;根据[可寻址队列操作](../feature/2026-07-29-addressable-queue-operations.md)决策,在驱动器认领或丢弃该项之前,这次待处理入队始终可以被寻址。 如果消息 A、B 都进入处理,B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此,B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。广义 `cancel()`、dispose(资源释放)或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。 @@ -40,6 +40,6 @@ Status: implemented ## 后果 -普通轮次的边界可预测:消息 A、B 始终分开,B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成或取消句柄;广义取消可以丢弃整个尚未启动的队尾,状态和静止性也仍是面向整个 agent 的观察。 +普通轮次的边界可预测:消息 A、B 始终分开,B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成句柄;待处理项可通过其仍有效的 `InboxItemId` 移除,广义取消可以丢弃整个尚未启动的队尾,而状态与完全停稳仍是面向整个 agent 的观察。 代价是模型请求和检查点都会增加。繁忙队列可能需要更长时间才能清空;如果生产方持续提交消息,队列也可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts new file mode 100644 index 0000000000..be5e73f9a6 --- /dev/null +++ b/apps/web/tests/queue-actions.e2e.ts @@ -0,0 +1,117 @@ +// Keyless browser coverage for pending queue actions through the shipped Web +// composition and real HTTP/SSE wire. A replay override parks the active turn +// so two ordinary follow-ups remain addressable while the page edits one and +// removes one. The queue uses an existing recorded model +// call; this scenario owns only the user-visible mid-turn golden. +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterEach, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) +const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md') +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.' +const REMOVE = 'Queue item to remove' +const EDIT = 'Queue item to edit' +const EDITED = 'Edited queue item' + +describe('web e2e: queue row actions', () => { + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + let page: Page + let overrideDir: string | undefined + + afterEach(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + browser = undefined + const closing = scaffold + scaffold = undefined + await closing?.close().catch((error: unknown) => failures.push(error)) + if (overrideDir !== undefined) { + await rm(overrideDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + overrideDir = undefined + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'queue-actions teardown failed') + }) + + it.skipIf(MODE === 'record')('edits and removes exact pending occurrences', async () => { + overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-')) + const readyFile = join(overrideDir, '.hang-ready') + const overridePath = join(overrideDir, 'replay.override.json') + await writeFile(overridePath, JSON.stringify({ + patches: [{ at: 0, entry: { kind: 'hang', readyFile } }], + })) + + const sessionEvents: SessionEvent[] = [] + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + const tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions')) + + const input = page.locator('textarea').first() + const settled = scaffold.whenTurnSettled() + await input.fill(ACTIVE_PROMPT) + await input.press('Enter') + await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true) + + for (const text of [REMOVE, EDIT]) { + await input.fill(text) + await input.press('Enter') + } + await expect.poll( + () => page.getByRole('button', { name: '删除排队消息' }).count(), + { timeout: 10_000 }, + ).toBe(2) + + const editRow = page.getByText(EDIT, { exact: true }).locator('..') + await editRow.getByRole('button', { name: '编辑排队消息' }).click() + const editor = page.getByRole('textbox', { name: '编辑排队消息' }) + await editor.fill(EDITED) + const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE) + await page.getByRole('button', { name: '保存排队消息' }).click() + await page.getByText(EDITED, { exact: true }).waitFor() + + const removeRow = page.getByText(REMOVE, { exact: true }).locator('..') + await removeRow.getByRole('button', { name: '删除排队消息' }).click() + await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(sessionEvents.filter(event => event.type === 'user/message')).toHaveLength(1) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + + const editedRow = page.getByText(EDITED, { exact: true }).locator('..') + await editedRow.getByRole('button', { name: '删除排队消息' }).click() + await expect.poll(() => page.getByText(EDITED, { exact: true }).count()).toBe(0) + await page.getByRole('button', { name: 'Stop generating' }).click() + await settled + }, 120_000) + + it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['editing.expected.md', 'ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md new file mode 100644 index 0000000000..a057d65afd --- /dev/null +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -0,0 +1,40 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" + - tab "Waterfall" +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- paragraph: partial +- list: + - listitem: + - text: Queue item to remove + - button "编辑排队消息": + - img + - button "删除排队消息": + - img + - listitem: + - textbox "编辑排队消息": Edited queue item + - button "保存排队消息": + - img + - button "取消编辑": + - img +- textbox "Message the agent" +- button "Add attachment": + - img +- text: Danger Full Access +- combobox "Access mode": + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md new file mode 100644 index 0000000000..096f98dd8a --- /dev/null +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -0,0 +1,34 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" + - tab "Waterfall" +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- paragraph: partial +- list: + - listitem: + - text: Edited queue item + - button "编辑排队消息": + - img + - button "删除排队消息": + - img +- textbox "Message the agent" +- button "Add attachment": + - img +- text: Danger Full Access +- combobox "Access mode": + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index dc1bc657ad..f5e2942860 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -1,8 +1,8 @@ -// Web e2e scenario: mid-turn steering, end to end. The composer locks while a -// turn runs, so the product UI has no steering gesture yet — the steer is -// POSTed from the page itself over the same same-origin /api transport the -// client uses (TODO(web-steer-composer): drive this through a composer -// gesture once one exists). Everything downstream is product: the gateway +// Web e2e scenario: mid-turn steering, end to end. The product composer +// deliberately exposes Queue only, so the steer is POSTed from the page +// itself over the same same-origin /api transport the client uses. +// TODO(web-steer-ui): Drive this through a dedicated steering interaction +// once one exists. Everything downstream is product: the gateway // routes mode:'steer' to Agent.steer, the loop drains it at the step // boundary into a durable steering/message event, the SSE mux pushes it, and // the transcript renders the badged interjection bubble. The question @@ -122,6 +122,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // blocks, alone. The DOM is stable here (no further SSE frames can // arrive until the question is answered), making this state capturable. expect(await page.getByText('插话').count()).toBe(0) + expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) + expect(await page.getByRole('button', { name: '编辑排队消息' }).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE) } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 276251910c..d1c73bee76 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -36,7 +36,8 @@ "tests/sidebar-scrollbar.e2e.ts", "tests/code-mode-round.e2e.ts", "tests/cordis-tool-round.e2e.ts", - "tests/message-actions.e2e.ts" + "tests/message-actions.e2e.ts", + "tests/queue-actions.e2e.ts" ], "references": [ { diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index c296e10fb1..92e13a56c0 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 2ae982eba49b6dbd2365496915f9917071167813 -architecture.zh.md: abaef961504ff64dbcd1e8e8ba9bd002406fa7f4 +architecture.md: 9237063da9ede73be014457ebd85a1b5c59ba96f +architecture.zh.md: 2fe6e4b8b406c1010531b2709000ceffd10fb793 diff --git a/docs/architecture.md b/docs/architecture.md index 2ae982eba4..9237063da9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,8 +77,8 @@ choose declarative identity and fresh/resume path -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: - wait for a queued message - claim message -> emit agent/status(running) if starting an interval + wait for queued occurrence + claim (edit/remove/promote end) -> emit agent/status(running) if starting an interval open the next-step acceptance window -> agent/prompt-submit blocked or failed prompt -> close the window without opening a turn diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index abaef96150..2fe6e4b8b4 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -77,8 +77,8 @@ choose declarative identity and fresh/resume path -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: - wait for a queued message - claim message -> emit agent/status(running) if starting an interval + wait for queued occurrence + claim (edit/remove/promote end) -> emit agent/status(running) if starting an interval open the next-step acceptance window -> agent/prompt-submit blocked or failed prompt -> close the window without opening a turn diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 967241fbf6..b023555e72 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:440`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -108,18 +108,16 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, * boundary, or steering drained between steps. Fires after the item leaves * its FIFO and before it becomes a durable message. * @param agent - the agent whose inbox item was claimed. - * @param message - the claimed message. - * @param placement - the FIFO that claimed this occurrence; together with - * `message.id`, it matches the earliest outstanding enqueue in that FIFO. + * @param item - the exact claimed occurrence. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/dequeue'( this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement, ): void +'agent/inbox/dequeue'(this: Scoped, agent: Agent, item: InboxItem): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -133,16 +131,16 @@ Pending inbox items were dropped without delivering them, so every enqueue occur * emits this after `agent/cancel-requested` when applicable and before * aborting the active work. Fires once per drop with every dropped item. * @param agent - the agent whose inbox items were dropped. - * @param messages - the discarded messages in FIFO order (queued then steering); never empty. + * @param items - the discarded occurrences in FIFO order (queued then steering); never empty. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/discard'(this: Scoped, agent: Agent, messages: UserMessage[]): void +'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItem[]): void ``` -Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -154,17 +152,38 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time * acceptance-time routing result; listeners must not reconstruct it from * later agent or session state. * @param agent - the owning agent. - * @param message - accepted content, source, and correlation identity. - * @param placement - resolved queued or steering placement. + * @param item - accepted occurrence, message, and resolved placement. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement): void +'agent/inbox/enqueue'(this: Scoped, agent: Agent, item: InboxItem): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/update` — emit + +A still-pending inbox item changed content or position. The item id and placement remain stable; edit carries the replacement message, while promote makes this occurrence first in its current FIFO. + +```ts cordis-catalog +/** + * A still-pending inbox item changed content or position. The item id and + * placement remain stable; edit carries the replacement message, while + * promote makes this occurrence first in its current FIFO. + * @param agent - the owning agent. + * @param item - the complete post-update occurrence. + * @param action - the applied non-terminal operation. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/update'( this: Scoped, agent: Agent, item: InboxItem, action: 'edit' | 'promote', ): void +``` + +Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -187,7 +206,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -211,7 +230,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:339`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -241,7 +260,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:398`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -263,7 +282,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:339`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -288,7 +307,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:387`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -308,7 +327,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -332,7 +351,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -358,7 +377,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:373`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:413`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index db31daaf06..847bfdd8fc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:215`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:216`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 9f6970d88d..5d2725f049 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: b9df539136c2661537775ba9a425bdf7ef1fd958 -core.zh.md: 1c75e8484dd1184077fe0194b2b6088230d1bbf5 +core.md: 10c138877d25bd0c74af02a64245a48876a81255 +core.zh.md: b32849bf0737f98102266fa21b7335857229e546 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b9df539136..10c138877d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -430,6 +430,33 @@ type SendTarget = 'next-turn' | 'next-step' type InboxPlacement = 'queued' | 'steering' ``` +`InboxItemId` is a process-local branded string minted for each accepted FIFO occurrence. It is intentionally distinct from `MessageId`: sending the same immutable message twice creates two independently addressable pending items. + +```ts type-equiv +/** One independently addressable accepted occurrence in an agent inbox. */ +interface InboxItem { + /** Agent-loop-minted occurrence identity. */ + readonly id: InboxItemId + /** Identified message delivered by the caller. */ + readonly message: UserMessage + /** Acceptance-time FIFO classification. */ + readonly placement: InboxPlacement +} +``` + +```ts type-equiv +/** A user-requested mutation of one still-pending inbox item. */ +type InboxAction = + | { readonly kind: 'edit'; readonly content: ContentBlock[] } + | { readonly kind: 'remove' } + | { readonly kind: 'promote' } +``` + +```ts type-equiv +/** Result of applying an inbox action at the synchronous ownership boundary. */ +type InboxActionResult = 'applied' | 'not-found' +``` + ```ts type-equiv /** * Options for the unified {@link Agent.send} primitive over the @@ -453,7 +480,7 @@ interface SendOptions { } ``` -The fixed-preset aliases own `target` and `wakeup`; their already identified `UserMessage` carries role, content, and provenance. Its `MessageId` remains stable across that message's `agent/inbox/*` events without being returned by the delivery methods. Injection bypasses the FIFOs and never appears on those events. +The fixed-preset aliases own `target` and `wakeup`; their already identified `UserMessage` carries role, content, and provenance. Its `MessageId` remains stable when an edit replaces the message content, while the enclosing `InboxItemId` identifies one accepted occurrence across `agent/inbox/enqueue`, `agent/inbox/update`, and its terminal dequeue or discard. Injection bypasses the FIFOs and never appears on those events. ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -518,6 +545,18 @@ interface Agent { */ send(message: UserMessage, options: SendOptions): void + /** + * Mutate one still-pending inbox occurrence synchronously. Editing preserves + * the message identity and queue position; removal publishes its terminal + * discard; promotion moves it to the front of its current FIFO and makes a + * queued item waking. A driver-claimed item is no longer pending and returns + * `not-found`. + * @param id - independently addressable inbox occurrence. + * @param action - edit, remove, or promote operation. + * @returns whether the pending occurrence was found and updated. + */ + updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 1c75e8484d..b32849bf07 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -438,6 +438,33 @@ type SendTarget = 'next-turn' | 'next-step' type InboxPlacement = 'queued' | 'steering' ``` +`InboxItemId` 是为每次获准进入 FIFO 的项铸造的进程本地品牌字符串。它有意区别于 `MessageId`:同一条不可变消息发送两次,会创建两个可独立寻址的待处理项。 + +```ts type-equiv +/** One independently addressable accepted occurrence in an agent inbox. */ +interface InboxItem { + /** Agent-loop-minted occurrence identity. */ + readonly id: InboxItemId + /** Identified message delivered by the caller. */ + readonly message: UserMessage + /** Acceptance-time FIFO classification. */ + readonly placement: InboxPlacement +} +``` + +```ts type-equiv +/** A user-requested mutation of one still-pending inbox item. */ +type InboxAction = + | { readonly kind: 'edit'; readonly content: ContentBlock[] } + | { readonly kind: 'remove' } + | { readonly kind: 'promote' } +``` + +```ts type-equiv +/** Result of applying an inbox action at the synchronous ownership boundary. */ +type InboxActionResult = 'applied' | 'not-found' +``` + ```ts type-equiv /** * Options for the unified {@link Agent.send} primitive over the @@ -461,7 +488,7 @@ interface SendOptions { } ``` -固定预设的别名方法自带 `target` 与 `wakeup`;其已有标识的 `UserMessage` 会携带角色、内容与 provenance。投递方法不会返回其 `MessageId`,但该 id 在这条消息的各个 `agent/inbox/*` 事件中保持稳定。注入绕过两个 FIFO,从不出现在这些事件中。 +固定预设的别名方法自带 `target` 与 `wakeup`;其已有标识的 `UserMessage` 会携带角色、内容与 provenance。编辑替换消息内容时,其 `MessageId` 保持稳定;外层 `InboxItemId` 则在 `agent/inbox/enqueue`、`agent/inbox/update` 及终态 dequeue 或 discard 之间标识同一次入队。注入绕过两个 FIFO,从不出现在这些事件中。 ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -526,6 +553,18 @@ interface Agent { */ send(message: UserMessage, options: SendOptions): void + /** + * Mutate one still-pending inbox occurrence synchronously. Editing preserves + * the message identity and queue position; removal publishes its terminal + * discard; promotion moves it to the front of its current FIFO and makes a + * queued item waking. A driver-claimed item is no longer pending and returns + * `not-found`. + * @param id - independently addressable inbox occurrence. + * @param action - edit, remove, or promote operation. + * @returns whether the pending occurrence was found and updated. + */ + updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 71238305e5..82ea842c9d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:358`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:440`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:398`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:366`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:413`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index db2234317b..70ebfae020 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -87,8 +87,8 @@ describe('ACP prompt lifecycle', () => { const sessionId = await newSession(harness) const agent = harness.ctx.agents.get(SessionId(sessionId))! let inserted = false - harness.ctx.on('agent/inbox/enqueue', (subject, message) => { - if (subject !== agent || message.source.kind !== 'user' || inserted) return + harness.ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject !== agent || item.message.source.kind !== 'user' || inserted) return inserted = true const source = { kind: 'plugin', plugin: 'test' } as const agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } }) diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index a8e561ba33..b58718134d 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -12,7 +12,7 @@ export type { WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionModels, + InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, GoalsApi, GoalRef, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cab616aabd..35b404bebc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1051,6 +1051,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { ) return ok(request, { accepted: true as const }) }, + updateQueue: request => err(request, { + code: 'queue-item-not-found', + message: 'fixture has no pending queue item', + details: { itemId: request.payload.itemId }, + }), cancel: (request) => { const replay = replays.get(request.payload.sessionId) if (replay !== undefined) { @@ -1487,6 +1492,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.models': return this.api.sessions.models(request) case 'session.selectModel': return this.api.sessions.selectModel(request) case 'session.prompt': return this.api.sessions.prompt(request) + case 'session.updateQueue': return this.api.sessions.updateQueue(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 0aa2cc3f82..7d26b1c526 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -17,7 +17,7 @@ export type { ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionModels, + InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 6a876c8ed4..650c206f34 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -62,6 +62,7 @@ export class FakeApiClient implements IApiClient { => Promise> = payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -97,6 +98,7 @@ export class FakeApiClient implements IApiClient { selectModel: (payload: ModelTarget & { sessionId: SessionId }) => this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), + updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 429ae8f0a9..e608ac1bb8 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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/runtime/README.md -README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816 -README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9 +README.md: fbef8ddbb1b46d2c2ce5b4226c59a79b965c57c4 +README.zh.md: 597b0140266e2cc351e646cd2439b09fbf9ddb7b diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 25eb60e2c9..fbef8ddbb1 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -16,6 +16,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. +## Pending queue projection + +`ConversationSnapshot.queue` is the Host's authoritative transient inbox snapshot. Each row carries its `InboxItemId`, complete editable text when every content block is text, a flattened preview, and the accepted queued-or-steering placement. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove/promote operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. + ## Code Mode sub-dispatch index `ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the surface `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index e3085f9175..597b014026 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -16,6 +16,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 +## 待处理队列投影 + +`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 inbox 快照。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本、扁平化预览,以及接受时确定的 queued 或 steering 放置方式。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除/前移操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。 + ## Code Mode 子调用索引 `ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。 diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index 31335c388c..34003eaa2c 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -8,7 +8,9 @@ * dispatch) stay on the class, invisible out here. */ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { + InboxItemId, QueueAction, RpcResult, SessionId, +} from '@deepseek-ai/dsh-client-connection/client' import type { ConversationSnapshot } from '../sessions/conversation.ts' import type { ObservableSnapshot } from './store.ts' @@ -36,6 +38,13 @@ export interface ISession { * @returns acceptance, or the business error (also mirrored into snapshot.promptError). */ prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> + /** + * Apply one mutation to a still-pending queue occurrence. + * @param itemId - agent-owned inbox occurrence identity. + * @param action - edit, remove, or promote operation. + * @returns acceptance, or a business/transport error. + */ + updateQueue(itemId: InboxItemId, action: QueueAction): Promise> /** * Cancel the running turn. * @returns acceptance, or the business error. diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index f5f0717236..a2f8eba658 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -7,7 +7,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { - RpcError, SessionId, ToolCallView, ToolResultView, + InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' @@ -185,10 +185,13 @@ export interface RunningToolCall { } -/** One queued-message row mirrored from `session/queued` frames (key: the enqueueing prompt's rpcId when wire-sourced). */ +/** One independently addressable row from the transient queue snapshot. */ export interface QueuedMessage { - readonly key: string + readonly id: InboxItemId readonly preview: string + /** Complete editable text; null when the message contains non-text blocks. */ + readonly text: string | null + readonly placement: 'queued' | 'steering' } /** In-progress assistant output (chunk accumulator product). */ @@ -246,7 +249,7 @@ export interface ConversationSnapshot { */ codeDispatches: ReadonlyMap pending: readonly PendingInteraction[] - /** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */ + /** Authoritative transient inbox snapshot, replaced after every host-side change. */ queue: readonly QueuedMessage[] running: boolean /** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */ diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 396c0be6e7..ff68e21921 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -351,14 +351,14 @@ export class SessionManager { // them so last-wins cannot pin a phantom value over recomputed truth. this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) this.notifier.markDirty() - // New mux-generation baseline: buffered session/queued frames belong to + // New mux-generation baseline: buffered session/queue frames belong to // the previous generation and the host is about to resend the live // snapshot — drop them, or every reconnect appends a duplicate batch // (and enough reconnects push real approval/question frames past the // cap). Same re-baseline signal Session uses for its own mirror. const buffered = this.pendingBuffers.get(frame.sessionId) if (buffered !== undefined) { - const kept = buffered.filter(item => item.payload.type !== 'session/queued') + const kept = buffered.filter(item => item.payload.type !== 'session/queue') if (kept.length !== buffered.length) { if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId) else this.pendingBuffers.set(frame.sessionId, kept) @@ -383,7 +383,7 @@ export class SessionManager { } const session = this.sessions.get(frame.sessionId) if (session === undefined) { - // Approval/question/queued frames never hit history: buffer for replay on + // Approval/question/queue frames never hit history: buffer for replay on // instantiation; everything else drops (not instantiated — history fully // backfills on open). switch (frame.type) { @@ -391,8 +391,12 @@ export class SessionManager { case 'approval/resolved': case 'question/requested': case 'question/resolved': - case 'session/queued': { + case 'session/queue': { const buffer = this.pendingBuffers.get(frame.sessionId) ?? [] + const prior = frame.type === 'session/queue' + ? buffer.findIndex(item => item.payload.type === 'session/queue') + : -1 + if (prior !== -1) buffer.splice(prior, 1) buffer.push(envelope) if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP) this.pendingBuffers.set(frame.sessionId, buffer) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0f5d39ac8e..582d51cd54 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -4,8 +4,8 @@ import type { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { - HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, - SessionId, ToolEventView, + HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError, + RpcId, RpcResult, SessionId, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. @@ -48,14 +48,6 @@ export interface SessionOptions { /** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */ const QUEUE_PREVIEW_CHARS = 200 -/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */ -interface QueuedEntry { - row: QueuedMessage - steering: boolean - /** JSON-serialized MessageSource (steering retirement matches by source, the host-mirror precedent). */ - sourceJson: string -} - /** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */ function queuePreviewOf(content: readonly ContentBlock[]): string { const flat = content @@ -65,6 +57,12 @@ function queuePreviewOf(content: readonly ContentBlock[]): string { return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat } +/** Recover complete composer text only when editing cannot discard non-text blocks. */ +function queueTextOf(content: readonly ContentBlock[]): string | null { + if (!content.every(block => block.type === 'text')) return null + return content.map(block => block.text).join('') +} + /** * Owns a session's event window, derived conversation state, and observable * snapshot. React bindings remain outside this data layer. Features see only @@ -102,9 +100,8 @@ export class Session implements SessionFace { private callsCache: { rev: number; value: RunningToolCall[] } | null = null private pendingRev = 0 private pendingCache: { rev: number; value: PendingInteraction[] } | null = null - /** Inbox mirror (session/queued frames + mux-open baseline). Queue frames never hit history, - * so this is stream-only state: reconnect clears it and the fresh baseline re-populates. */ - private queued: QueuedEntry[] = [] + /** Authoritative stream-only inbox snapshot; pending work never hits history. */ + private queued: QueuedMessage[] = [] private queueRev = 0 private queueCache: { rev: number; value: QueuedMessage[] } | null = null private frozenRev = 0 @@ -234,6 +231,15 @@ export class Session implements SessionFace { return result } + /** Apply one operation to a still-pending queue occurrence. */ + async updateQueue(itemId: InboxItemId, action: QueueAction): Promise> { + try { + return (await this.api.sessions.updateQueue({ sessionId: this.sessionId, itemId, action })).result + } catch (error) { + return transportError(error) + } + } + /** * Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot). * @returns the cancel result. @@ -374,20 +380,16 @@ export class Session implements SessionFace { handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void { switch (frame.type) { case 'session/event': { - this.retireQueued(frame.event) this.acceptLiveEvent(frame.event, frame.view) return } - case 'session/queued': { - const message = frame.message - // Row key: the enqueueing prompt's rpcId when it rode this wire (the - // provisional-echo reconciliation key); otherwise the frame envelope id. - const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}` - this.queued.push({ - row: { key, preview: queuePreviewOf(message.content) }, - steering: frame.steering, - sourceJson: JSON.stringify(message.source), - }) + case 'session/queue': { + this.queued = frame.items.map(item => ({ + id: item.id, + preview: queuePreviewOf(item.message.content), + text: queueTextOf(item.message.content), + placement: item.placement, + })) this.queueRev++ this.notifier.markDirty() return @@ -440,15 +442,6 @@ export class Session implements SessionFace { * @param running - the new running state. */ handleRunning(running: boolean): void { - // Leave-running sweep (host queuedMirror precedent): discard paths (cancel, - // terminal steering drop) have no per-entry frame, so ANY not-running signal - // with a nonempty mirror clears it — checked before the equality return so a - // stale replay on an already-idle session still sweeps. - if (!running && this.queued.length > 0) { - this.queued = [] - this.queueRev++ - this.notifier.markDirty() - } // Turn-start conversion: a blank session never runs, so the first // running:true proves another端's first message landed (设计稿 2.2). if (running && this.blankBit) { @@ -613,27 +606,6 @@ export class Session implements SessionFace { } } - /** Consumption-event retirement, mirroring the host queuedMirror rules: a message-triggered - * turn/start claims the oldest non-steering entry; a steering/message drains the oldest - * steering entry with the same source (loop-authored steering matches nothing and drops none). */ - private retireQueued(event: SessionEvent): void { - if (this.queued.length === 0) return - let index = -1 - if (event.type === 'turn/start') { - if (event.data.trigger.kind !== 'message') return - index = this.queued.findIndex(entry => !entry.steering) - } else if (event.type === 'steering/message') { - const source = JSON.stringify(event.data.message.source) - index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source) - } else { - return - } - if (index < 0) return - this.queued.splice(index, 1) - this.queueRev++ - this.notifier.markDirty() - } - /** Per-event side effects (right column of the §A.9 dispatch table): * chunk accumulation / partial clear on finalize / openCalls add-remove. */ private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void { @@ -813,7 +785,7 @@ export class Session implements SessionFace { this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) } } if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { - this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) } + this.queueCache = { rev: this.queueRev, value: this.queued } } const partial = this.partial?.toPartial() ?? null return { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 0a1de3f7e1..e1ac0edd03 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -80,6 +80,7 @@ export class FakeApiClient implements IApiClient { Promise> = payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = @@ -116,6 +117,7 @@ export class FakeApiClient implements IApiClient { selectModel: (payload: { provider: string; model: string }) => this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), + updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 7a734ef393..02fab86728 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -1,32 +1,43 @@ /** - * Queue mirror semantics (web input-triggers queue cut 1): session/queued - * intake, host-rule retirement (message turn/start claims oldest non-steering; - * steering/message drains by source), leave-running sweep, reconnect reset, - * pre-instantiation buffering, and snapshot reference stability. + * Queue snapshot semantics: authoritative replacement after every host-side + * change, reconnect re-baselining, pre-instantiation buffering, editable-text + * projection, and snapshot reference stability. */ import { describe, expect, it } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { + InboxItemId, MuxFrame, RpcId, SessionId, +} from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient } from './fake-api.ts' -import { ev } from './event-script.ts' const SID = 'fk-q1' as SessionId -const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }] +const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }] const rid = (id: string): RpcId => id as RpcId +const iid = (id: string): InboxItemId => id as InboxItemId -/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */ -function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame { +interface QueueFixture { + id: string + body: string + placement?: 'queued' | 'steering' + content?: ContentBlock[] +} + +/** Build one authoritative queue snapshot. */ +function queueFrame(items: QueueFixture[]): MuxFrame { return { - type: 'session/queued', + type: 'session/queue', sessionId: SID, - message: createUserMessage({ - content: text(body), - source: { kind: 'user', rpcId: rid(rpcId) } as never, - }), - steering, + items: items.map(item => ({ + id: iid(item.id), + message: createUserMessage({ + content: item.content ?? text(item.body), + source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never, + }), + placement: item.placement ?? 'queued', + })), } } @@ -34,201 +45,133 @@ function makeSession(): Session { return new Session(SID, new FakeApiClient()) } -describe('queue intake', () => { - it('lands a queued frame as a row keyed by the source rpcId with a flat preview', () => { +describe('queue snapshot intake', () => { + it('projects stable ids, flat previews, complete text, and placement', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-1'), queuedFrame('第一条 排队\n消息', 'p-1')) - const queue = session.getSnapshot().queue - expect(queue).toEqual([{ key: 'p-1', preview: '第一条 排队 消息' }]) + session.handleMuxEnvelope(rid('env-1'), queueFrame([ + { id: 'q-1', body: '第一条 排队\n消息' }, + { id: 'q-2', body: '插话', placement: 'steering' }, + ])) + expect(session.getSnapshot().queue).toEqual([ + { id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息', placement: 'queued' }, + { id: 'q-2', preview: '插话', text: '插话', placement: 'steering' }, + ]) }) - it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => { + it('marks mixed-content messages non-editable while retaining their preview', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-2'), { - type: 'session/queued', - sessionId: SID, - message: createUserMessage({ - content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], - source: { kind: 'plugin', plugin: 'loop' }, - }), - steering: false, - }) - expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }]) + session.handleMuxEnvelope(rid('env-2'), queueFrame([{ + id: 'q-image', + body: '', + content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], + }])) + expect(session.getSnapshot().queue).toEqual([ + { id: 'q-image', preview: 'hi [image]', text: null, placement: 'queued' }, + ]) }) - it('caps the preview at 200 code points with an ellipsis', () => { + it('caps previews at 200 code points and preserves the full editable text', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap')) - const preview = session.getSnapshot().queue[0]?.preview ?? '' - expect(Array.from(preview)).toHaveLength(201) // 200 + … - expect(preview.endsWith('…')).toBe(true) + const body = '长'.repeat(201) + session.handleMuxEnvelope(rid('env-3'), queueFrame([{ id: 'q-cap', body }])) + const row = session.getSnapshot().queue[0] + expect(Array.from(row?.preview ?? '')).toHaveLength(201) + expect(row?.preview.endsWith('…')).toBe(true) + expect(row?.text).toBe(body) + }) + + it('replaces content, order, and membership from each authoritative frame', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('env-4'), queueFrame([ + { id: 'q-1', body: 'one' }, + { id: 'q-2', body: 'two' }, + ])) + session.handleMuxEnvelope(rid('env-5'), queueFrame([ + { id: 'q-2', body: 'two edited' }, + ])) + expect(session.getSnapshot().queue).toEqual([ + { id: 'q-2', preview: 'two edited', text: 'two edited', placement: 'queued' }, + ]) + session.handleMuxEnvelope(rid('env-6'), queueFrame([])) + expect(session.getSnapshot().queue).toEqual([]) }) it('keeps the queue array reference stable across unrelated snapshot swaps', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-4'), queuedFrame('稳定', 'p-s')) + session.handleMuxEnvelope(rid('env-7'), queueFrame([{ id: 'q-stable', body: '稳定' }])) const before = session.getSnapshot().queue - session.handleAgentError('unrelated') // dirties the snapshot without touching the queue + session.handleAgentError('unrelated') expect(session.getSnapshot().queue).toBe(before) }) }) -describe('queue retirement (host queuedMirror rules)', () => { - it('a message-triggered turn/start claims the oldest non-steering row', () => { - const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('先', 'p-1')) - session.handleMuxEnvelope(rid('e2'), queuedFrame('后', 'p-2')) - session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: ev.turnStart(0, 0) }) - expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-2']) - }) +describe('queue operation transport', () => { + it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { + const api = new FakeApiClient() + const session = new Session(SID, api) + session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }])) + const before = session.getSnapshot().queue - it('an injection-triggered turn/start claims nothing', () => { - const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1')) - const injection = { - ...ev.turnStart(0, 0), - data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } }, - } as never - session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection }) - expect(session.getSnapshot().queue).toHaveLength(1) - }) - - it('steering/message drains the source-matched steering row only', () => { - const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering - session.handleMuxEnvelope(rid('e3'), queuedFrame('插话', 'p-2', true)) - // Loop-authored steering (different source) must not consume the user entry. - const foreignSteering = { - seq: 0, time: 1, - type: 'steering/message', surfaceOp: 'append', - data: { - turn: 0, - message: createUserMessage({ - content: text('loop'), - source: { kind: 'plugin', plugin: 'loop' }, - }), - }, - } as never - session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering }) - expect(session.getSnapshot().queue).toHaveLength(2) - const matchedSteering = { - seq: 1, time: 2, - type: 'steering/message', surfaceOp: 'append', - data: { - turn: 0, - message: createUserMessage({ - content: text('插话'), - source: { kind: 'user', rpcId: rid('p-2') }, - }), - }, - } as never - session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering }) - expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1']) - }) - - it('a leave-running flip sweeps the whole mirror (cancel/terminal-drop cover)', () => { - const session = makeSession() - session.handleRunning(true) - session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1')) - session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2')) - session.handleRunning(false) - expect(session.getSnapshot().queue).toEqual([]) - }) - - it('a stale not-running relay on an idle session still sweeps replayed rows', () => { - const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('孤儿', 'p-1')) - session.handleRunning(false) // running already false: equality path must not skip the sweep - expect(session.getSnapshot().queue).toEqual([]) + await expect(session.updateQueue(iid('q-op'), { kind: 'promote' })) + .resolves.toEqual({ ok: true, value: { accepted: true } }) + expect(api.callsOf('session.updateQueue')).toEqual([{ + sessionId: SID, + itemId: 'q-op', + action: { kind: 'promote' }, + }]) + expect(session.getSnapshot().queue).toBe(before) }) }) describe('queue reconnect semantics', () => { - it('session/subscribed re-baselines the mirror: stale rows drop, the following snapshot lands fresh', () => { + it('session/subscribed clears stale state before the fresh snapshot lands', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('旧连接', 'p-old')) - // New mux generation: subscribed arrives first on the same stream... + session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-old', body: '旧连接' }])) session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 }) expect(session.getSnapshot().queue).toEqual([]) - // ...then the queue snapshot replays the live inbox. - session.handleMuxEnvelope(rid('e3'), queuedFrame('新基线', 'p-new')) - expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-new']) + session.handleMuxEnvelope(rid('e3'), queueFrame([{ id: 'q-new', body: '新基线' }])) + expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) - it('resync must NOT clear the mirror (regression: onConnected races the mux baseline)', async () => { + it('resync does not clear a baseline that raced ahead of the host connection signal', async () => { const session = makeSession() - // Reconnect ordering that broke: mux opened first and already delivered - // the fresh generation's baseline; host stream (and with it onConnected → - // resync) lands after. The host never resends — clearing here left the - // dock empty until the next enqueue. session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 }) - session.handleMuxEnvelope(rid('e2'), queuedFrame('新基线', 'p-fresh')) + session.handleMuxEnvelope(rid('e2'), queueFrame([{ id: 'q-fresh', body: '新基线' }])) await session.resync() - expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh']) + expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh']) }) - it('replayed steering retires without a replayed turn/start', () => { + it('running-status changes never guess at queue retirement', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 }) - session.handleMuxEnvelope(rid('e2'), queuedFrame('重连插话', 'p-steer', true)) - const committed = { - seq: 6, time: 2, - type: 'steering/message', surfaceOp: 'append', - data: { - turn: 1, - message: createUserMessage({ - content: text('重连插话'), - source: { kind: 'user', rpcId: rid('p-steer') }, - }), - }, - } as never - session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed }) - expect(session.getSnapshot().queue).toEqual([]) + session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-live', body: '保留' }])) + session.handleRunning(true) + session.handleRunning(false) + expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live']) }) }) -describe('manager buffering of queued frames', () => { - it('buffers session/queued for uninstantiated sessions and replays before the running sync', () => { - const api = new FakeApiClient() - const manager = new SessionManager(api) - manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queuedFrame('预热', 'p-b1') }) - // Instantiation replays the buffer; no summary exists, so no running sweep runs. - const session = manager.get(SID) - expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-b1']) - // The buffer is consumed: a second get must not double-replay. - expect(manager.get(SID).getSnapshot().queue).toHaveLength(1) +describe('manager buffering of queue snapshots', () => { + it('replays only the latest snapshot for an uninstantiated session', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) }) + manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) }) + expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) - it('a not-running list summary sweeps replayed rows at instantiation', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(ok([{ sessionId: SID, updatedAt: 1, running: false }])) - const manager = new SessionManager(api) - await manager.refreshList() - manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queuedFrame('该扫掉', 'p-b2') }) - expect(manager.get(SID).getSnapshot().queue).toEqual([]) - }) - - it('subscribed re-baselines the uninstantiated buffer: stale queued frames drop, non-queue frames survive (regression: reconnect duplication)', () => { - const api = new FakeApiClient() - const manager = new SessionManager(api) - // Generation 1 baseline lands while the session is uninstantiated, along - // with a pending approval (never re-derivable from history). - manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queuedFrame('第一代', 'p-g1') }) + it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) }) manager.handleMuxEnvelope({ rpcId: rid('g1b'), payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' }, }) - // Reconnect: generation 2 replays subscribed + the SAME live queue entry. - manager.handleMuxEnvelope({ rpcId: rid('g2a'), payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 } }) - manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queuedFrame('第一代', 'p-g1') }) + manager.handleMuxEnvelope({ + rpcId: rid('g2a'), + payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 }, + }) + manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queueFrame([{ id: 'q-g2', body: '第二代' }]) }) const snapshot = manager.get(SID).getSnapshot() - // One queue row (no duplicate batch); the approval survived the re-baseline. - expect(snapshot.queue.map(r => r.key)).toEqual(['p-g1']) - expect(snapshot.pending.map(p => p.kind)).toEqual(['approval']) + expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2']) + expect(snapshot.pending.map(pending => pending.kind)).toEqual(['approval']) }) }) - -/** ok wrapper with a typed items payload (the shared helper pins value to never[]). */ -function ok(items: { sessionId: SessionId; updatedAt: number; running: boolean }[]) { - return { rpcId: rid(`ok-${items.length}`), result: { ok: true as const, value: { items: items as never[] } } } -} diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 5ec4652aef..c9deb546ae 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -84,6 +84,14 @@ export class FixtureSession implements SessionFace { throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`) } + /** + * Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it. + * @returns never — always throws. + */ + updateQueue(): never { + throw new Error(`test session "${this.sessionId}": updateQueue is not stubbed — supply it on the fixture's session face`) + } + /** * Fail-loud stub; supply `cancel` on the fixture's session face to exercise it. * @returns never — always throws. diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9b93feae8b..77714a67a3 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -127,6 +127,8 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi async load(virtualId: string) { if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length) + // The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph. + this.addWatchFile(fileId) const source = await readFile(fileId) const { code, exports: cssExports } = transform({ filename: fileId, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index c9f0551797..4105ad863d 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: e2148cfca658196540e3800912dccd0568ae8d0e -README.zh.md: 45f05ce2e03e015701e85f2853a4a656511058a9 +README.md: 1d5c3704a86ad11ce35b2bd2d404391301400016 +README.zh.md: 33a1f5187c0ae51ab18833766856fda33ba1ffde diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e2148cfca6..1d5c3704a8 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,3 +38,5 @@ None; this package neither assembles nor sends a provider request. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. +- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control; protocol-level promotion remains separate from the Web interaction. +- **Web exposes pending Queue only** — QueueDock omits pending steering until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 45f05ce2e0..33a1f5187c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,3 +38,5 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 +- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件;协议层的前移操作与 Web 交互保持分离。 +- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,QueueDock 不展示待处理的 steering。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。 diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 4454662cd6..f88ea80633 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -6,6 +6,7 @@ * (machine.ts) is package-private and never exported. */ import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InboxItemId } from '@deepseek-ai/dsh-client-connection/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, @@ -99,11 +100,12 @@ export interface ComposerKeyboard { dismissPopup(): void } -/** One queued-message row projected from the session/queued frames (T9 supplies the store). */ +/** One independently addressable row projected from the transient queue snapshot. */ export interface QueuedMessage { - /** Stable row key: the enqueueing prompt's rpcId. */ - readonly key: string + readonly id: InboxItemId readonly preview: string + readonly text: string | null + readonly placement: 'queued' | 'steering' } /** Guard union of the scoped consume-token event, checked by the machine. */ diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index adc0c42b48..4c05c2cbca 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -1,30 +1,114 @@ -/* Neutral stacked strip above the input (queue rows are informational, not a warn state). */ +/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */ .dock { - margin: 6px 0; - padding: 8px 12px; - border: 1px solid var(--dsw-alias-separator-primary); - border-radius: 10px; - background: var(--dsw-alias-bg-base); + box-sizing: border-box; + flex: none; + width: 100%; + max-width: 776px; + /* Eat InputBar's 6px top padding and tuck the panel 2px under the card; + the later composer sibling paints its surface and shadow over this edge. */ + margin: 0 auto -10px; + padding: 2px 12px; } -.title { - font-size: 12px; - font-weight: 500; - color: var(--dsw-alias-label-secondary); +.panel { + position: relative; + overflow: hidden; + width: 100%; + padding-top: 2px; + border-radius: 14px 14px 0 0; + background: var(--dsw-specific-tip); +} + +.panel::after { + position: absolute; + inset: 0; + border: 1px solid var(--dsw-alias-border-l1); + border-bottom: none; + border-radius: inherit; + content: ''; + pointer-events: none; } .list { - margin: 4px 0 0; + margin: 0; padding: 0; list-style: none; } .row { - overflow: hidden; - font-size: 12px; - line-height: 20px; - color: var(--dsw-alias-label-primary); - white-space: nowrap; - text-overflow: ellipsis; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 36px; + padding: 4px 5px 4px 12px; + border-radius: 8px; +} + +.preview, +.editor { + flex: 1 1 auto; + min-width: 0; + font: var(--dsw-font-xs-13); + font-family: Inter, var(--dsw-font-family); +} + +.preview { + overflow: hidden; + color: var(--dsw-alias-label-primary-dimmed); + text-overflow: ellipsis; + white-space: nowrap; + word-break: break-word; +} + +.editor { + box-sizing: border-box; + height: 28px; + padding: 0 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + outline: none; + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-primary); +} + +.editor:focus { + border-color: var(--dsw-alias-state-business-primary); +} + +.actions { + display: flex; + flex: none; + align-items: center; + gap: 10px; +} + +.action { + display: grid; + flex: none; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.action:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.action:focus-visible { + outline: 2px solid var(--dsw-alias-label-tertiary); + outline-offset: -2px; +} + +.action:disabled { + cursor: default; + opacity: 0.45; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index f5b5047c7b..858133a0d6 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -1,48 +1,188 @@ -// Read-only queue dock entry (design v4 queue cut 1): renders the session's -// inbox mirror (session/queued frames + connect baseline) as one stacked -// strip above the input. No per-row actions — the host inbox has no -// addressable entries yet (queue cut 2 ledger). +// Queue dock entry: renders the authoritative transient inbox snapshot and +// addresses per-row mutations through the session-scoped conversation face. // // The 'conversation.input.dock' SlotMap declaration lives in // ../contract/slots.ts beside the other input-region slots. import type { Context } from 'cordis' +import { useEffect, useMemo, useState } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type {} from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client' +import { + IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' import css from './QueueDock.module.css' +/** Queue operations injected by the session-scoped registration. */ +export interface QueueDockInjected { + updateQueue: (itemId: InboxItemId, action: QueueAction) => Promise + notify: (level: 'info' | 'error', text: string) => void +} + /** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ -export type QueueDockProps = PropsRuntime<'conversation.input.dock'> +export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected /** Queue strip: one preview line per queued message; renders null when the queue is empty. */ -export function QueueDock({ useSession }: QueueDockProps) { - const queue = useSession(s => s.queue) +export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { + const inbox = useSession(s => s.queue) + // TODO(web-steer-ui): Give pending steering its own interaction before + // exposing it; QueueDock owns only independent queued turns. + const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox]) + const [editing, setEditing] = useState<{ id: InboxItemId; text: string } | null>(null) + const [busy, setBusy] = useState(null) + + useEffect(() => { + if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null) + }, [editing, queue]) + if (queue.length === 0) return null + + const applyAction = async ( + itemId: InboxItemId, + action: QueueAction, + failure: string, + ): Promise => { + setBusy(itemId) + try { + await updateQueue(itemId, action) + return true + } catch { + notify('error', failure) + return false + } finally { + setBusy(current => current === itemId ? null : current) + } + } + + const saveEdit = async (): Promise => { + if (editing === null || editing.text.trim() === '') return + if (await applyAction( + editing.id, + { kind: 'edit', content: [{ type: 'text', text: editing.text }] }, + '编辑失败:这条消息可能已经开始发送。', + )) setEditing(null) + } + return (
-
已排队 {queue.length} 条
-
    - {queue.map(row => ( -
  • {row.preview}
  • - ))} -
+
+
    + {queue.map(row => ( +
  • + {editing?.id === row.id + ? ( + { setEditing({ id: row.id, text: event.currentTarget.value }) }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + setEditing(null) + return + } + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault() + void saveEdit() + } + }} + /> + ) + : {row.preview}} +
    + {editing?.id === row.id + ? ( + <> + + + + ) + : ( + <> + + + + )} +
    +
  • + ))} +
+
) } /** - * The dock entry as a plain registrant plugin (bash posture). - * `inject: ['conversation']` is the ordering seam: the conversation service - * mounts after ui-conversation's slot registrations, so the - * 'conversation.input.dock' declaration is on the ledger by then. + * The dock entry as a plain registrant plugin. The conversation service is the + * ordering and action seam; session scopes provide the exact queue owner. */ export const queueDockEntry = { name: 'conversation-queue-dock', - inject: ['slots', 'conversation'], + inject: ['slots', 'conversation', 'sessions'], /** * Register the queue strip into the input dock (list entry, order 0). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.input.dock', id: 'queue', order: 0 }, QueueDock) + ctx.slots.register({ + name: 'conversation.input.dock', + id: 'queue', + order: 0, + inject: (sessionId: SessionId): QueueDockInjected => { + const actx = ctx.sessions.scope(sessionId) + if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`) + const conversation = actx.get('conversation') + if (conversation === undefined) throw new Error('queue dock: conversation service unavailable') + return { + updateQueue: (itemId, action) => conversation.updateQueue(itemId, action), + notify: (level, text) => { conversation.input.for(actx).notify(level, text) }, + } + }, + }, QueueDock) }, } diff --git a/packages/client/ui-conversation/src/client/queue/store.ts b/packages/client/ui-conversation/src/client/queue/store.ts index 6f084ea39d..536523465b 100644 --- a/packages/client/ui-conversation/src/client/queue/store.ts +++ b/packages/client/ui-conversation/src/client/queue/store.ts @@ -11,8 +11,8 @@ import type { QueuedMessage } from '../input/contract.ts' /** * Project a session's queue rows as a bare observable (subscribe/getSnapshot). * The wiring layer (T5) overlays this onto InputState.queue; the runtime - * QueuedMessage and the input-contract QueuedMessage are structurally the - * same frozen shape ({key, preview}). + * QueuedMessage and the input-contract QueuedMessage are structurally + * identical. * @param session - the resident session face. * @returns the queue read face (snapshot reference stable while the queue is unchanged). */ diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 7b119b15ff..31da0b167b 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -13,6 +13,7 @@ import type { Context } from 'cordis' // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client' import type { InputService } from './input/contract.ts' /** @@ -30,6 +31,13 @@ export interface IConversation { * @returns completion; business failures reject (and land in promptError). */ send(text: string, mode: 'queue' | 'steer'): Promise + /** + * Apply one operation to a pending queue occurrence. + * @param itemId - agent-owned inbox occurrence identity. + * @param action - edit, remove, or promote operation. + * @returns completion; business failures reject. + */ + updateQueue(itemId: InboxItemId, action: QueueAction): Promise /** * Cancel the scoped session's in-flight turn. * @returns completion; failures reject as in send. @@ -71,6 +79,15 @@ export class ConversationService extends Service implements IConversation { if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) } + /** Apply one operation to a pending queue occurrence. */ + async updateQueue(itemId: InboxItemId, action: QueueAction): Promise { + const session = this.scopedSession('updateQueue') + const result = await session.updateQueue(itemId, action) + if (!result.ok) { + throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`) + } + } + /** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */ async cancel(): Promise { const session = this.scopedSession('cancel') diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index c63d3628e5..9e83d13ccc 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -1,20 +1,27 @@ // @vitest-environment jsdom /** - * QueueDock rendering (web input-triggers queue cut 1): empty queue renders - * nothing, rows render one preview line each keyed by rpcId, and the strip - * follows queue changes through the useSession selector. + * QueueDock rendering and operations: authoritative rows, inline editing, + * removal, failure notices, and live retirement. */ -import { afterEach, describe, expect, it } from 'vitest' -import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useSyncExternalStore } from 'react' -import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, QueuedMessage, SessionId, SessionListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { InboxItemId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { InputState } from '../src/client/input/contract.ts' -import { QueueDock, queueDockEntry } from '../src/client/queue/QueueDock.tsx' +import { QueueDock, queueDockEntry, type QueueDockInjected } from '../src/client/queue/QueueDock.tsx' afterEach(cleanup) const SID = 's1' as SessionId +const iid = (id: string): InboxItemId => id as InboxItemId + +function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage { + return { id: iid(id), preview, text, placement: 'queued' } +} function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { @@ -24,31 +31,30 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { } } -/** Minimal live source backing the useSession stub (queue swaps notify subscribers). */ +/** Minimal live source backing the useSession stub. */ function liveSession(initial: ConversationSnapshot) { let snapshot = initial const listeners = new Set<() => void>() - const useSession: SnapshotSelectorHook = sel => + const useSession: SnapshotSelectorHook = selector => useSyncExternalStore( - (fn) => { - listeners.add(fn) - return () => listeners.delete(fn) + (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) }, - () => sel(snapshot), + () => selector(snapshot), ) return { useSession, push(next: ConversationSnapshot): void { snapshot = next - for (const fn of [...listeners]) fn() + for (const listener of [...listeners]) listener() }, } } -/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */ const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] } -function kitFor(snapshot: ConversationSnapshot) { +function kitFor(snapshot: ConversationSnapshot, injected: Partial = {}) { return { sessionId: SID, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, @@ -58,6 +64,9 @@ function kitFor(snapshot: ConversationSnapshot) { inputActions: { setDraft: () => {}, submit: () => {} } as never, session: snapshot, input: INPUT_STATE, + updateQueue: vi.fn(() => Promise.resolve()), + notify: vi.fn(), + ...injected, } } @@ -69,20 +78,131 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) - it('renders one preview row per queued message with the count strip', () => { + it('hides pending steering until it has a dedicated Web interaction', () => { + const steering = { ...row('i-steer', 'steer separately'), placement: 'steering' as const } + const snap = snapshotWith([steering]) + const source = liveSession(snap) + const { container } = render() + expect(container.innerHTML).toBe('') + + act(() => { source.push(snapshotWith([steering, row('i-queue', 'queue visibly')])) }) + expect(container.textContent).toContain('queue visibly') + expect(container.textContent).not.toContain('steer separately') + expect(container.querySelectorAll('button')).toHaveLength(2) + }) + + it('renders active actions and disables editing for mixed-content rows', () => { const snap = snapshotWith([ - { key: 'p-1', preview: '第一条排队消息' }, - { key: 'p-2', preview: 'second queued line' }, + row('i-1', '第一条排队消息'), + row('i-2', null, 'image [image]'), ]) const source = liveSession(snap) const { container } = render() - expect(container.textContent).toContain('已排队 2 条') - const rows = [...container.querySelectorAll('li')] - expect(rows.map(r => r.textContent)).toEqual(['第一条排队消息', 'second queued line']) + expect([...container.querySelectorAll('li')].map(item => item.textContent)) + .toEqual(['第一条排队消息', 'image [image]']) + expect(container.querySelectorAll('button')).toHaveLength(4) + expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2) + expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2) + expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0) + expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false) + expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true) + expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title')) + .toBe('包含非文本内容,暂不支持编辑') }) - it('follows queue changes: retirement empties the strip back to null', () => { - const snap = snapshotWith([{ key: 'p-1', preview: '在场' }]) + it('edits text inline with save and cancel controls, then saves with the same item identity', async () => { + const snap = snapshotWith([row('i-edit', 'before')]) + const source = liveSession(snap) + const updateQueue = vi.fn(() => Promise.resolve()) + const { getByLabelText, queryByLabelText } = render( + , + ) + + fireEvent.click(getByLabelText('编辑排队消息')) + const editor = getByLabelText('编辑排队消息') as HTMLInputElement + expect(getByLabelText('保存排队消息')).toBeTruthy() + expect(getByLabelText('取消编辑')).toBeTruthy() + expect(queryByLabelText('删除排队消息')).toBeNull() + fireEvent.change(editor, { target: { value: 'after' } }) + fireEvent.keyDown(editor, { key: 'Enter' }) + + await waitFor(() => { + expect(updateQueue).toHaveBeenCalledWith(iid('i-edit'), { + kind: 'edit', + content: [{ type: 'text', text: 'after' }], + }) + }) + }) + + it('cancels an edit by button or Escape without mutating the queue', () => { + const snap = snapshotWith([row('i-edit', 'before')]) + const source = liveSession(snap) + const updateQueue = vi.fn(() => Promise.resolve()) + const { getByLabelText, getByText } = render( + , + ) + + fireEvent.click(getByLabelText('编辑排队消息')) + fireEvent.change(getByLabelText('编辑排队消息'), { target: { value: 'abandoned' } }) + fireEvent.click(getByLabelText('取消编辑')) + expect(getByText('before')).toBeTruthy() + + fireEvent.click(getByLabelText('编辑排队消息')) + fireEvent.keyDown(getByLabelText('编辑排队消息'), { key: 'Escape' }) + expect(getByText('before')).toBeTruthy() + expect(updateQueue).not.toHaveBeenCalled() + }) + + it('keeps editing during IME composition and disables a blank save', () => { + const snap = snapshotWith([row('i-edit', 'before')]) + const source = liveSession(snap) + const updateQueue = vi.fn(() => Promise.resolve()) + const { getByLabelText } = render( + , + ) + + fireEvent.click(getByLabelText('编辑排队消息')) + const editor = getByLabelText('编辑排队消息') + fireEvent.change(editor, { target: { value: ' ' } }) + expect(getByLabelText('保存排队消息')).toHaveProperty('disabled', true) + fireEvent.change(editor, { target: { value: '输入中' } }) + fireEvent.keyDown(editor, { key: 'Enter', isComposing: true }) + expect(updateQueue).not.toHaveBeenCalled() + expect(getByLabelText('编辑排队消息')).toBeTruthy() + }) + + it('removes the addressed row', async () => { + const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')]) + const source = liveSession(snap) + const updateQueue = vi.fn(() => Promise.resolve()) + const { getAllByLabelText } = render( + , + ) + + fireEvent.click(getAllByLabelText('删除排队消息')[0]!) + await waitFor(() => { + expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' }) + }) + }) + + it('keeps the row and surfaces a notice when an operation loses the claim race', async () => { + const snap = snapshotWith([row('i-race', 'pending')]) + const source = liveSession(snap) + const notify = vi.fn() + const updateQueue = vi.fn(() => Promise.reject(new Error('not found'))) + const { getByLabelText, getByText } = render( + , + ) + + fireEvent.click(getByLabelText('删除排队消息')) + await waitFor(() => { + expect(notify).toHaveBeenCalledWith('error', '删除失败:这条消息可能已经开始发送。') + }) + expect(getByText('pending')).toBeTruthy() + }) + + it('follows authoritative retirement back to null', () => { + const snap = snapshotWith([row('i-1', '在场')]) const source = liveSession(snap) const { container } = render() expect(container.textContent).toContain('在场') @@ -90,11 +210,9 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) - it('ships the registrant plugin shape (list entry into conversation.input.dock)', () => { - // Registration itself runs under T5's slot declaration; here we pin the - // frozen registration surface so the wiring layer can mount it verbatim. + it('ships the session-scoped registrant plugin shape', () => { expect(queueDockEntry.name).toBe('conversation-queue-dock') - expect(queueDockEntry.inject).toEqual(['slots', 'conversation']) + expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions']) expect(typeof queueDockEntry.apply).toBe('function') }) }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 4897fe5769..209cdee212 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -12,11 +12,12 @@ import { InputHub } from '../src/client/input/hub.ts' async function bench() { const runtime = await SlotTestRuntime.create() const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) + const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const loadOlder = vi.fn(() => Promise.resolve()) await runtime.sessions.add({ id: 's1', - session: { prompt, cancel, loadOlder }, + session: { prompt, updateQueue, cancel, loadOlder }, }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. @@ -26,16 +27,18 @@ async function bench() { await fiber.await() const root = runtime.ctx.get('conversation') as ConversationService const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService - return { runtime, root, scoped, prompt, cancel, loadOlder } + return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder } } describe('ConversationService', () => { it('routes operations through the public Session binding', async () => { const b = await bench() await b.scoped.send('hello', 'steer') + await b.scoped.updateQueue('item-1' as never, { kind: 'remove' }) await b.scoped.cancel() await b.scoped.loadOlder() expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer') + expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' }) expect(b.cancel).toHaveBeenCalledOnce() expect(b.loadOlder).toHaveBeenCalledOnce() await b.runtime.dispose() diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index ef2abf1a45..2b87fad68d 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -49,6 +49,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { session.append('user/message', input, { surfaceOp: 'append' }) }, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 3ff6b9b310..af921c6bfb 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -184,6 +184,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session.append('user/message', input, { surfaceOp: 'append' }) }, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7d9b087b8f..e744c93687 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1108,24 +1108,31 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/inbox/dequeue', mode: 'emit', - signature: '\'agent/inbox/dequeue\'( this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement, ): void', - jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param message - the claimed message.\n * @param placement - the FIFO that claimed this occurrence; together with\n * `message.id`, it matches the earliest outstanding enqueue in that FIFO.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/dequeue\'(this: Scoped, agent: Agent, item: InboxItem): void', + jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param item - the exact claimed occurrence.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.', }, { name: 'agent/inbox/discard', mode: 'emit', - signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, messages: UserMessage[]): void', - jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,\n * emits this after `agent/cancel-requested` when applicable and before\n * aborting the active work. Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, items: InboxItem[]): void', + jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,\n * emits this after `agent/cancel-requested` when applicable and before\n * aborting the active work. Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param items - the discarded occurrences in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Pending inbox items were dropped without delivering them, so every enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.', }, { name: 'agent/inbox/enqueue', mode: 'emit', - signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement): void', - jsDoc: '/**\n * An item entered the queued or steering inbox. `placement` is the\n * acceptance-time routing result; listeners must not reconstruct it from\n * later agent or session state.\n * @param agent - the owning agent.\n * @param message - accepted content, source, and correlation identity.\n * @param placement - resolved queued or steering placement.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, item: InboxItem): void', + jsDoc: '/**\n * An item entered the queued or steering inbox. `placement` is the\n * acceptance-time routing result; listeners must not reconstruct it from\n * later agent or session state.\n * @param agent - the owning agent.\n * @param item - accepted occurrence, message, and resolved placement.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'An item entered the queued or steering inbox.', }, + { + name: 'agent/inbox/update', + mode: 'emit', + signature: '\'agent/inbox/update\'( this: Scoped, agent: Agent, item: InboxItem, action: \'edit\' | \'promote\', ): void', + jsDoc: '/**\n * A still-pending inbox item changed content or position. The item id and\n * placement remain stable; edit carries the replacement message, while\n * promote makes this occurrence first in its current FIFO.\n * @param agent - the owning agent.\n * @param item - the complete post-update occurrence.\n * @param action - the applied non-terminal operation.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'A still-pending inbox item changed content or position.', + }, { name: 'agent/prompt-submit', mode: 'waterfall', @@ -1433,7 +1440,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', }, { name: 'AgentCancelCause', @@ -1827,6 +1834,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GoalView', declaration: 'export interface GoalView extends GoalSnapshot {\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n readonly activation: GoalActivation;\n}', }, + { + name: 'InboxAction', + declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'promote\';\n};', + }, + { + name: 'InboxActionResult', + declaration: 'export type InboxActionResult = \'applied\' | \'not-found\';', + }, + { + name: 'InboxItemId', + declaration: 'export type InboxItemId = Branded<\'InboxItemId\'>;', + }, { name: 'InvariantFailure', declaration: 'export type InvariantFailure = (message: string) => never;', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 75edc1357f..f7cbc7b387 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 6bb8b12af69f54c2a75cd672e4d3802887808c76 -README.zh.md: f9eb8aa3cdead427a88492e35c00eab80ba12f91 +README.md: f38fc5232cab49cdd6311cb24de9b83e62bc19a6 +README.zh.md: 4f81bfd182e739ba7645e364a873cc461b2aefe5 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6bb8b12af6..f38fc5232c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -55,7 +55,9 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue` with the same placement, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. +The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. + +Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous pending-item boundary: edit freezes replacement content without changing message identity or position, remove publishes discard, and promote moves the occurrence to the head of its queued or steering FIFO; promoting queued work also makes it waking. Edit and promote publish `agent/inbox/update`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update returns `not-found`; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. ### Loop lifecycle (`agent.ts`) diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index f9eb8aa3cd..4f81bfd182 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -55,7 +55,9 @@ interface Config { 实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 -统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue`,并携带相同的路由归类;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 +统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 + +每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步待处理项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard;前移会把该项移至其 queued 或 steering FIFO 的队首,其中 queued 工作还会变为可唤醒。编辑和前移会发布 `agent/inbox/update`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新会返回 `not-found`;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 ### 循环生命周期(`agent.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 949e9801c4..5ac0389702 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,13 +8,18 @@ */ import type { Context } from 'cordis' -import { agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import { randomUUID } from 'node:crypto' +import { agentCarrier, assembleContextFor, emitAgentEvent, InboxItemId } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { Agent, CancelOptions, AgentInterruptReason, + InboxAction, + InboxActionResult, + InboxItem, + InboxItemId as InboxItemIdType, InboxPlacement, AgentOptions, AgentStatus, @@ -55,9 +60,9 @@ type StepOutcome = */ export class ReactLoopAgent implements Agent { /** Prompts awaiting individual turns. */ - private queued: { message: UserMessage; wakeup: boolean }[] = [] + private queued: { item: InboxItem; wakeup: boolean }[] = [] /** Input taken into the session log at step boundaries. */ - private outbox: { message: UserMessage; steering: boolean }[] = [] + private outbox: { message: UserMessage; steering: boolean; item?: InboxItem }[] = [] /** Whether observers see a running interval; consecutive turns share it. */ private busy = false @@ -115,16 +120,77 @@ export class ReactLoopAgent implements Agent { } const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued' + const item: InboxItem = Object.freeze({ + id: InboxItemId(randomUUID()), + message, + placement, + }) if (placement === 'steering') { - this.outbox.push({ message, steering: true }) + this.outbox.push({ message, steering: true, item }) } else { - this.queued.push({ message, wakeup }) + this.queued.push({ item, wakeup }) } // Preserve the routing decision for every send in this synchronous caller // stack, while installing quiescence ownership before enqueue observers // can cancel or dispose. if (placement === 'queued' && wakeup) this.scheduleKick() - emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message, placement) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item) + } + + /** Apply one synchronous mutation to a still-pending inbox occurrence. */ + updateInbox(id: InboxItemIdType, action: InboxAction): InboxActionResult { + const queuedIndex = this.queued.findIndex(candidate => candidate.item.id === id) + const outboxIndex = queuedIndex === -1 + ? this.outbox.findIndex(candidate => candidate.item?.id === id) + : -1 + if (queuedIndex === -1 && outboxIndex === -1) return 'not-found' + + const pending = queuedIndex === -1 ? this.outbox[outboxIndex] : this.queued[queuedIndex] + if (pending === undefined || pending.item === undefined) { + throw new Error(`agent "${this.id}" inbox index changed during synchronous update`) + } + + switch (action.kind) { + case 'edit': { + const item: InboxItem = Object.freeze({ + ...pending.item, + message: freezeMessage({ ...pending.item.message, content: action.content }), + }) + if (queuedIndex !== -1) { + const queued = this.queued[queuedIndex] + if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during edit`) + this.queued[queuedIndex] = { ...queued, item } + } else { + const outbox = this.outbox[outboxIndex] + if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during edit`) + this.outbox[outboxIndex] = { ...outbox, message: item.message, item } + } + emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', item, 'edit') + return 'applied' + } + case 'remove': { + if (queuedIndex !== -1) this.queued.splice(queuedIndex, 1) + else this.outbox.splice(outboxIndex, 1) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item]) + return 'applied' + } + case 'promote': { + if (queuedIndex !== -1) { + const queued = this.queued.splice(queuedIndex, 1)[0] + if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during promotion`) + this.queued.unshift({ item: queued.item, wakeup: true }) + this.scheduleKick() + } else { + const outbox = this.outbox.splice(outboxIndex, 1)[0] + if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during promotion`) + this.outbox.unshift(outbox) + } + emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', pending.item, 'promote') + return 'applied' + } + default: + return assertNever(action) + } } /** Queue one ordinary prompt turn and wake the driver. */ @@ -169,9 +235,9 @@ export class ReactLoopAgent implements Agent { if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) } if (!options.keepInbox) { - const discarded = this.queued.map(item => item.message) + const discarded = this.queued.map(item => item.item) for (const item of this.outbox) { - if (item.steering) discarded.push(item.message) + if (item.steering && item.item !== undefined) discarded.push(item.item) } // Clear before abort observers run: replacement work belongs to the next turn. this.queued.length = 0 @@ -222,7 +288,8 @@ export class ReactLoopAgent implements Agent { // The some() guard above proves the queue is non-empty; the non-null // assertion expresses that invariant. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { message } = this.queued.shift()! + const { item } = this.queued.shift()! + const { message } = item const inheritedOutboxLength = this.outbox.length const admission = new AbortController() @@ -293,7 +360,7 @@ export class ReactLoopAgent implements Agent { // Published only after the abort owner and pending done are installed: a // dequeue listener that cancels or disposes must find live cancellation // and quiescence ownership, not the previous activity's settled state. - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message, 'queued') + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item) } /** @@ -643,7 +710,8 @@ export class ReactLoopAgent implements Agent { for (const item of this.outbox.splice(0, limit)) { if (item.steering) { steered = true - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.message, 'steering') + if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item) this.session.append( 'steering/message', { turn, message: item.message }, diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 9ac2405187..82e1ced2a5 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -4,7 +4,7 @@ import LlmService, { createUserMessage, CallId, MessageSource, ProviderRequestId import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent, type InboxPlacement } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { ReactLoopAgent } from '../src/agent.ts' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -53,6 +53,79 @@ function send(agent: Agent, text: string) { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } +function inboxText(item: InboxItem): string { + return item.message.content + .flatMap(block => block.type === 'text' ? [block.text] : []) + .join('') +} + +describe('addressable inbox operations', () => { + it('edits in place, removes exactly one item, and promotes the next independent turn', async () => { + const adapter = new MockAdapter([ + textResponse('first reply'), + textResponse('promoted reply'), + textResponse('edited reply'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' }) + const admission = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('agent/prompt-submit', async (_subject, message, _signal, next) => { + if (message.content[0]?.type === 'text' && message.content[0].text === 'first') { + admission.resolve(undefined) + await release.promise + } + return next() + }) + + const pending: InboxItem[] = [] + const updates: { id: string; action: string; text: string }[] = [] + const discards: string[][] = [] + ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject === agent && inboxText(item) !== 'first') pending.push(item) + }) + ctx.on('agent/inbox/update', (subject, item, action) => { + if (subject === agent) updates.push({ id: item.id, action, text: inboxText(item) }) + }) + ctx.on('agent/inbox/discard', (subject, items) => { + if (subject === agent) discards.push(items.map(item => item.id)) + }) + + send(agent, 'first') + await admission.promise + send(agent, 'remove me') + send(agent, 'edit me') + send(agent, 'promote me') + expect(pending.map(inboxText)).toEqual(['remove me', 'edit me', 'promote me']) + + const remove = pending[0]! + const edit = pending[1]! + const promote = pending[2]! + expect(agent.updateInbox(edit.id, { + kind: 'edit', + content: [{ type: 'text', text: 'edited' }], + })).toBe('applied') + expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied') + expect(agent.updateInbox(promote.id, { kind: 'promote' })).toBe('applied') + expect(updates).toEqual([ + { id: edit.id, action: 'edit', text: 'edited' }, + { id: promote.id, action: 'promote', text: 'promote me' }, + ]) + expect(discards).toEqual([[remove.id]]) + + const idle = waitForIdle(ctx, agent) + release.resolve(undefined) + await idle + expect(agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.type === 'user/message' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') + : '')) + .toEqual(['first', 'promote me', 'edited']) + expect(agent.updateInbox(promote.id, { kind: 'remove' })).toBe('not-found') + }) +}) + describe('assistant replay provenance', () => { it('records adapter replay state with the assembled assistant content', async () => { const response = textResponse('unchanged') @@ -502,10 +575,10 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const queuedSources: MessageSource[] = [] const queuedShapes: string[][] = [] const placements: InboxPlacement[] = [] - ctx.on('agent/inbox/enqueue', (_agent, message, placement) => { - queuedSources.push(message.source) - queuedShapes.push(Object.keys(message).sort()) - placements.push(placement) + ctx.on('agent/inbox/enqueue', (_agent, item) => { + queuedSources.push(item.message.source) + queuedShapes.push(Object.keys(item.message).sort()) + placements.push(item.placement) }) send(agent, 'go') // no explicit source → default {kind:'user'} must be visible diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 205c1a5752..faa23a2657 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -86,8 +86,9 @@ describe('agent/prompt-submit', () => { const entered = Promise.withResolvers() const decision = Promise.withResolvers() const observed: UserMessage[] = [] - ctx.on('agent/inbox/enqueue', (subject, message) => { + ctx.on('agent/inbox/enqueue', (subject, item) => { if (subject !== agent) return + const message = item.message expect(Object.isFrozen(message)).toBe(true) expect(Object.isFrozen(message.content)).toBe(true) expect(Object.isFrozen(message.content[0])).toBe(true) @@ -97,8 +98,8 @@ describe('agent/prompt-submit', () => { if (block?.type === 'text') block.text = 'listener mutation' }).toThrow() }) - ctx.on('agent/inbox/enqueue', (subject, message) => { - if (subject === agent) observed.push(message) + ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject === agent) observed.push(item.message) }) ctx.on('agent/prompt-submit', async () => { entered.resolve(undefined) @@ -240,8 +241,8 @@ describe('agent/prompt-submit', () => { entered.resolve(undefined) return decision.promise }) - ctx.on('agent/inbox/enqueue', (subject, _message, placement) => { - if (subject === agent) placements.push(placement) + ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject === agent) placements.push(item.placement) }) const idle = waitForIdle(ctx, agent) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 59dcdac249..3d8c91c59d 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 9ca79f28506b133a555bd7d1e984386c715fd9d6 -README.zh.md: 165f71f1b395bdf0c229e2c4b1a30e89347b6be1 +README.md: e71d3454d22fa829af9617083c5e68586970c70a +README.zh.md: 76bb63afa6cf0f3690094e4c020efd7dc68505ce diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 9ca79f2850..e71d3454d2 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -60,7 +60,8 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. The agent publishes or queues the complete value as-is without minting or replacing its identity. The message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry it so callers can correlate a queued item with its lifecycle; enqueue and dequeue also carry the resolved `queued | steering` placement so repeated message identities retire from the correct FIFO. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. Each accepted FIFO occurrence receives its own `InboxItemId`, even when callers reuse a `MessageId`; `agent/inbox/enqueue`/`update` and the terminal `dequeue` or `discard` carry that complete `InboxItem`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.updateInbox(itemId, action)` — synchronously edits, removes, or promotes one still-pending occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, placement, and FIFO position while replacing frozen content. Remove emits the occurrence's terminal discard. Promote moves it to the front of its current FIFO and makes an ordinary queued item waking. A claimed item has crossed the ownership boundary and returns `not-found`. - `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. - `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 165f71f1b3..76bb63afa6 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -60,7 +60,8 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 每个插件面向的 handle: -- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。agent 会原样发布或排队完整值,不会生成或替换其标识。该消息的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件会携带完整消息,调用方可据此把排队项与其生命周期关联;入队与出队事件还会携带解析出的 `queued | steering` 路由归类,使重复出现的消息标识能在正确的 FIFO 中完成结算。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 +- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。每次获准进入 FIFO 的项都会获得独立的 `InboxItemId`,即使调用方复用了同一个 `MessageId`;`agent/inbox/enqueue`/`update` 及终态 `dequeue` 或 `discard` 都会携带这一完整 `InboxItem`。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 +- `agent.updateInbox(itemId, action)`:同步编辑、移除或前移一个仍处于待处理状态的项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源、放置方式与 FIFO 位置。移除会发出该项的终态 discard。前移会把它移至当前 FIFO 的队首,并使普通 queued 项能够唤醒驱动器。已被认领的项已经跨越所有权边界,因此返回 `not-found`。 - `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 - `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。 - `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。 diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index f40a5da441..9113030eef 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/core/agent/src/brand.ts b/packages/core/agent/src/brand.ts new file mode 100644 index 0000000000..58d50259c1 --- /dev/null +++ b/packages/core/agent/src/brand.ts @@ -0,0 +1,23 @@ +/** + * dsh-agent's owned branded ids for live inbox occurrences. + * + * @module @deepseek-ai/dsh-agent/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** + * Identifies one accepted occurrence in an agent inbox. Re-sending the same + * message creates a distinct item id, so pending work remains independently + * addressable. + */ +export type InboxItemId = Branded<'InboxItemId'> + +/** + * Brand a string as an {@link InboxItemId}. + * @param id - the agent-loop-minted occurrence identifier. + * @returns the same string, branded; no validation is performed. + */ +export function InboxItemId(id: string): InboxItemId { + return id as InboxItemId +} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 17b115a40c..89db587166 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -15,6 +15,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' +export * from './brand.ts' export * from './llm-target.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 28b4ca4c71..242bfd00e4 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -9,6 +9,7 @@ import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { InboxItemId } from './brand.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { @@ -39,6 +40,25 @@ export type SendTarget = 'next-turn' | 'next-step' /** Resolved inbox placement reported when an accepted message is enqueued. */ export type InboxPlacement = 'queued' | 'steering' +/** One independently addressable accepted occurrence in an agent inbox. */ +export interface InboxItem { + /** Agent-loop-minted occurrence identity. */ + readonly id: InboxItemId + /** Identified message delivered by the caller. */ + readonly message: UserMessage + /** Acceptance-time FIFO classification. */ + readonly placement: InboxPlacement +} + +/** A user-requested mutation of one still-pending inbox item. */ +export type InboxAction = + | { readonly kind: 'edit'; readonly content: ContentBlock[] } + | { readonly kind: 'remove' } + | { readonly kind: 'promote' } + +/** Result of applying an inbox action at the synchronous ownership boundary. */ +export type InboxActionResult = 'applied' | 'not-found' + /** * Options for the unified {@link Agent.send} primitive over the * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} @@ -156,6 +176,18 @@ export interface Agent { */ send(message: UserMessage, options: SendOptions): void + /** + * Mutate one still-pending inbox occurrence synchronously. Editing preserves + * the message identity and queue position; removal publishes its terminal + * discard; promotion moves it to the front of its current FIFO and makes a + * queued item waking. A driver-claimed item is no longer pending and returns + * `not-found`. + * @param id - independently addressable inbox occurrence. + * @param action - edit, remove, or promote operation. + * @returns whether the pending occurrence was found and updated. + */ + updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the @@ -239,29 +271,37 @@ declare module 'cordis' { * acceptance-time routing result; listeners must not reconstruct it from * later agent or session state. * @param agent - the owning agent. - * @param message - accepted content, source, and correlation identity. - * @param placement - resolved queued or steering placement. + * @param item - accepted occurrence, message, and resolved placement. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement): void + 'agent/inbox/enqueue'(this: Scoped, agent: Agent, item: InboxItem): void + /** + * A still-pending inbox item changed content or position. The item id and + * placement remain stable; edit carries the replacement message, while + * promote makes this occurrence first in its current FIFO. + * @param agent - the owning agent. + * @param item - the complete post-update occurrence. + * @param action - the applied non-terminal operation. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/update'( + this: Scoped, + agent: Agent, + item: InboxItem, + action: 'edit' | 'promote', + ): void /** * The driver claimed one item out of the inbox: a queued item at a turn * boundary, or steering drained between steps. Fires after the item leaves * its FIFO and before it becomes a durable message. * @param agent - the agent whose inbox item was claimed. - * @param message - the claimed message. - * @param placement - the FIFO that claimed this occurrence; together with - * `message.id`, it matches the earliest outstanding enqueue in that FIFO. + * @param item - the exact claimed occurrence. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/dequeue'( - this: Scoped, - agent: Agent, - message: UserMessage, - placement: InboxPlacement, - ): void + 'agent/inbox/dequeue'(this: Scoped, agent: Agent, item: InboxItem): void /** * Pending inbox items were dropped without delivering them, so every * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR @@ -269,11 +309,11 @@ declare module 'cordis' { * emits this after `agent/cancel-requested` when applicable and before * aborting the active work. Fires once per drop with every dropped item. * @param agent - the agent whose inbox items were dropped. - * @param messages - the discarded messages in FIFO order (queued then steering); never empty. + * @param items - the discarded occurrences in FIFO order (queued then steering); never empty. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/discard'(this: Scoped, agent: Agent, messages: UserMessage[]): void + 'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItem[]): void /** * Effective broad cancellation was requested, before queued/outbox work * is cleared or the active turn is aborted. This observe-only notification diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index bd560c7c99..0f54718ce4 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -24,6 +24,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { acceptsNextStep: false, ctx: new Context(), send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject: () => {}, diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index a55bdba34f..7744726465 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { type Agent } from '@deepseek-ai/dsh-agent' +import { InboxItemId, type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import { scopeTarget } from '@deepseek-ai/dsh-scope' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -46,11 +46,16 @@ describe('agent status invariants', () => { }) describe('agent inbox invariants', () => { - const info = () => freezeMessage({ - id: MessageId('m'), - role: 'user' as const, - content: [], - source: { kind: 'user' as const }, + let nextItem = 0 + const info = (placement: InboxPlacement = 'queued'): InboxItem => ({ + id: InboxItemId(`i-${nextItem++}`), + message: freezeMessage({ + id: MessageId('m'), + role: 'user' as const, + content: [], + source: { kind: 'user' as const }, + }), + placement, }) it('accepts a dequeue and a discard covered by prior enqueues', async () => { @@ -58,9 +63,9 @@ describe('agent inbox invariants', () => { const agent = mockAgent('i1') const at = scopeTarget(agent, agent) expect(() => { - ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'queued') - ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'steering') - ctx.emit(at, 'agent/inbox/dequeue', agent, info(), 'queued') + ctx.emit(at, 'agent/inbox/enqueue', agent, info()) + ctx.emit(at, 'agent/inbox/enqueue', agent, info('steering')) + ctx.emit(at, 'agent/inbox/dequeue', agent, info()) ctx.emit(at, 'agent/inbox/discard', agent, [info()]) }).not.toThrow() }) @@ -68,7 +73,7 @@ describe('agent inbox invariants', () => { it('rejects a dequeue with no outstanding item', async () => { const ctx = await setup() const agent = mockAgent('i2') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info(), 'queued') }) + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info()) }) .toThrow(/without a matching prior enqueue/) }) @@ -76,7 +81,7 @@ describe('agent inbox invariants', () => { const ctx = await setup() const agent = mockAgent('i3') const at = scopeTarget(agent, agent) - ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'queued') + ctx.emit(at, 'agent/inbox/enqueue', agent, info()) expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(), info()]) }) .toThrow(/dropped 2 items but only 1 were outstanding/) }) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index 89515bf3c2..fff3107f48 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -15,6 +15,7 @@ const scopedSubjectResolvers: Readonly args[0], 'agent/inbox/discard': args => args[0], 'agent/inbox/enqueue': args => args[0], + 'agent/inbox/update': args => args[0], 'agent/prompt-submit': args => args[0], 'agent/request': args => args[0], 'agent/request-error': args => args[0], diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 54f2e1e17b..4057d248d1 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -2,7 +2,7 @@ import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Events } from 'cordis' -import { type Agent } from '@deepseek-ai/dsh-agent' +import { InboxItemId, type Agent } from '@deepseek-ai/dsh-agent' import { scopeTarget } from '@deepseek-ai/dsh-scope' import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -44,12 +44,14 @@ describe('scoped-dispatch invariants', () => { content: [], source: { kind: 'user' }, }) + const item = { id: InboxItemId('i'), message, placement: 'queued' as const } const agentRows = { 'agent/created': [agent], 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], - 'agent/inbox/enqueue': [agent, message, 'queued'], - 'agent/inbox/dequeue': [agent, message, 'queued'], + 'agent/inbox/enqueue': [agent, item], + 'agent/inbox/update': [agent, item, 'edit'], + 'agent/inbox/dequeue': [agent, item], 'agent/inbox/discard': [agent, []], 'agent/cancel-requested': [agent, { kind: 'user' }], 'agent/session-start': [agent, 'startup'], diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 61734ada01..0a09a71f71 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -38,6 +38,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } get status() { return status }, get acceptsNextStep() { return status === 'running' }, send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject(input) { appendInjection(session, input) }, diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 31dbe9c4e6..fc66878800 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -307,10 +307,10 @@ export function apply(ctx: Context): void { requestDrive(state) } }) - ctx.on('agent/inbox/enqueue', (agent, info) => { + ctx.on('agent/inbox/enqueue', (agent, item) => { const state = stateFor(agent) const attempt = state.attempt - if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return + if (attempt !== undefined && sameQueued(item.message.content, item.message.source, attempt)) return state.competingQueued = true if (attempt?.phase === 'queued') attempt.stale = true }) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 6a11633384..5a3f0bdca2 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -287,7 +287,7 @@ describe('same-session goal driving', () => { it('pauses and drops a reserved round when cancellation lands before admission', async () => { const test = await harness([]) const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent === test.agent && info.source.kind === 'goal') { + if (agent === test.agent && info.message.source.kind === 'goal') { cancel() agent.cancel({ kind: 'user' }) } @@ -340,7 +340,7 @@ describe('same-session goal driving', () => { test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn let inserted = false test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return + if (agent !== test.agent || info.message.source.kind !== 'goal' || inserted) return inserted = true const lastStart = agent.session.events.findLast(event => event.type === 'turn/start') const turn = (lastStart?.data.turn ?? 0) + 1 @@ -363,7 +363,7 @@ describe('same-session goal driving', () => { const test = await harness([textResponse('human batch'), textResponse('later goal')]) let inserted = false test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return + if (agent !== test.agent || info.message.source.kind !== 'goal' || inserted) return inserted = true agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human joined the pending batch' }], source: { kind: 'user' } })) }) @@ -381,7 +381,7 @@ describe('same-session goal driving', () => { const test = await harness([textResponse('new revision')]) let edited = false test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.source.kind !== 'goal' || edited) return + if (agent !== test.agent || info.message.source.kind !== 'goal' || edited) return edited = true const current = test.ctx.goals.get(agent) if (current === undefined) throw new Error('missing goal during queued edit') @@ -661,7 +661,7 @@ describe('same-session goal driving', () => { const test = await harness([textResponse('retry after containment')]) let armed = true test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return + if (agent !== test.agent || info.message.source.kind !== 'goal' || !armed) return armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { throw new Error('admission projection failed') @@ -737,7 +737,7 @@ describe('same-session goal driving', () => { it('falls back to disarming when a cancelled reservation cannot be paused', async () => { const test = await harness([]) const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.source.kind !== 'goal') return + if (agent !== test.agent || info.message.source.kind !== 'goal') return cancel() vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => { throw new Error('pause failed') @@ -791,7 +791,7 @@ describe('same-session goal driving', () => { const test = await harness([]) let unloading: Promise | undefined test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) { + if (agent === test.agent && info.message.source.kind === 'goal' && unloading === undefined) { unloading = Promise.resolve(test.driver.dispose()) } }) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index c681c307e5..0e42cba851 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -48,6 +48,7 @@ function stubAgentForSession(session: Session): StubAgent { get status() { return status }, get acceptsNextStep() { return status === 'running' }, send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject(input) { diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 75c01295ce..82569e72d3 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -39,6 +39,7 @@ function liveAgent(ctx: Context, session: Session): Agent { get status() { return status }, get acceptsNextStep() { return false }, send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject(input: UserMessage) { diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 1293b8642b..443a0f616a 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -33,6 +33,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { get acceptsNextStep() { return status === 'running' }, ctx: new Context(), send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject(input) { diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 73b0845370..4c7571b7df 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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/host/apiproxy/README.md -README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74 -README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9 +README.md: 9ecc6282a8f249491d70331a4d978968b3585300 +README.zh.md: 4a49faff63d642a2a665767b45ab4ccaa801be43 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ca4471454f..9ecc6282a8 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -16,6 +16,8 @@ Session titles ride the generic projection pair like every other domain — the Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. +Pending agent input is a live control-plane contract, not session history. The gateway mirrors complete `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every change and reconnect. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content, remove discards it, and promote moves it to the front of its current FIFO while waking ordinary queued work. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The client never infers retirement from turn or status events. + Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 953539e119..4a49faff63 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -16,6 +16,8 @@ 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 +待处理 agent 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的完整 `InboxItem` 单次入队项,并在每次变更和重连时广播权威的 `session/queue` 快照。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,前移会把它移至当前 FIFO 的队首,并使普通 queued 工作能够唤醒驱动器。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。客户端绝不根据轮次或状态事件推断项已退役。 + Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f178bfefd0..fee32f4755 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -9,11 +9,11 @@ import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { - Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement, + Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxItem, } from '@deepseek-ai/dsh-agent' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' -import type { MessageId, MessageSource } from '@deepseek-ai/dsh-llm' +import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' @@ -511,38 +511,63 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * inbox event retires one matching occurrence, so repeated sends of the same * identified message remain visible until every occurrence is claimed. */ - const queuedMirror = new Map() + const queuedMirror = new Map() + const publishQueue = (sessionId: SessionId): void => { + const items = queuedMirror.get(sessionId) ?? [] + broadcast({ + type: 'session/queue', + sessionId, + items: items.map(item => ({ + id: item.id, + message: item.message, + placement: item.placement, + })), + }) + } ctx.effect(() => { - const retire = (agent: Agent, id: MessageId, placement?: InboxPlacement): void => { + const retire = (agent: Agent, item: InboxItem): void => { const entries = queuedMirror.get(agent.id) if (entries === undefined) return - const index = entries.findIndex(entry => - entry.message.id === id - && (placement === undefined || entry.steering === (placement === 'steering'))) + const index = entries.findIndex(entry => entry.id === item.id) if (index !== -1) entries.splice(index, 1) if (entries.length === 0) queuedMirror.delete(agent.id) + publishQueue(agent.id) } const disposers = [ - ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => { + ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => { let entries = queuedMirror.get(agent.id) if (entries === undefined) { entries = [] queuedMirror.set(agent.id, entries) } - const steering = placement === 'steering' - entries.push({ message, steering }) - broadcast({ - type: 'session/queued', - sessionId: agent.id, - message, - steering, - }) + entries.push(item) + publishQueue(agent.id) }), - ctx.on('agent/inbox/dequeue', (agent: Agent, message: UserMessage, placement) => { - retire(agent, message.id, placement) + ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem, action) => { + const entries = queuedMirror.get(agent.id) + if (entries === undefined) return + const index = entries.findIndex(entry => entry.id === item.id) + if (index === -1) return + entries.splice(index, 1) + if (action === 'promote') { + const first = entries.findIndex(entry => entry.placement === item.placement) + entries.splice(first === -1 ? entries.length : first, 0, item) + } else { + entries.splice(index, 0, item) + } + publishQueue(agent.id) }), - ctx.on('agent/inbox/discard', (agent: Agent, messages: UserMessage[]) => { - for (const message of messages) retire(agent, message.id) + ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => { + retire(agent, item) + }), + ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => { + const entries = queuedMirror.get(agent.id) + if (entries === undefined) return + const ids = new Set(items.map(item => item.id)) + const kept = entries.filter(entry => !ids.has(entry.id)) + if (kept.length === 0) queuedMirror.delete(agent.id) + else queuedMirror.set(agent.id, kept) + publishQueue(agent.id) }), ctx.on('session/disposed', (session: Session) => { queuedMirror.delete(session.id) @@ -1067,6 +1092,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { accepted: true as const }) }, + async updateQueue(request) { + const { sessionId, itemId, action } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + if (found.agent.updateInbox(itemId, action) === 'not-found') { + return err(request, { + code: 'queue-item-not-found', + message: 'queued item is no longer pending', + details: { itemId }, + }) + } + return ok(request, { accepted: true as const }) + }, + cancel(request) { const { sessionId } = request.payload const agent = ctx.agents.get(sessionId) @@ -1461,15 +1500,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // Queue snapshot baseline (pendingQuestions precedent): frames replayed // in arrival order per session; a reconnecting client rebuilds its // queue view from these alone. - for (const [sessionId, entries] of queuedMirror) { - for (const entry of entries) { - queue.push(frame({ - type: 'session/queued', - sessionId, - message: entry.message, - steering: entry.steering, - })) - } + for (const [sessionId, items] of queuedMirror) { + queue.push(frame({ + type: 'session/queue', + sessionId, + items: items.map(item => ({ + id: item.id, + message: item.message, + placement: item.placement, + })), + })) } // Per-session open-call table for result-view pairing. Bounded by the // per-turn call count: entries clear on turn/end; a table miss (stream diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 4e729b4c41..d9d73b441e 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -10,7 +10,9 @@ import type { HostFrame, MuxFrame } from './events.ts' import type { Wire } from './rpc.schema.ts' import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts' import { approvalRequestIdSchema } from './approvals.schema.ts' -import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts' +import { + contentBlockSchema, inboxItemIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema, +} from './sessions.schema.ts' import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' /** Question shape validated strictly against core dsh-user-interaction. */ @@ -42,7 +44,15 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ // and must fail loud here, not reach the composer. z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }), z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }), - z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema, steering: z.boolean() }), + z.object({ + type: z.literal('session/queue'), + sessionId: sessionIdSchema, + items: z.array(z.object({ + id: inboxItemIdSchema, + message: messageSchema, + placement: z.union([z.literal('queued'), z.literal('steering')]), + })), + }), // value stays wide: it already passed its unit's own schema on the host, // and deep-validating here would import every domain's schema into the carrier. z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 9f56fc1dd5..40e5c5a5ad 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -9,6 +9,7 @@ import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types' import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types' import type { Message } from '@deepseek-ai/dsh-llm/types' +import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' @@ -31,6 +32,16 @@ export type ToolEventView = | { for: 'call'; view: ToolCallView } | { for: 'result'; view: ToolResultView } +/** One pending inbox occurrence in an authoritative queue snapshot. */ +export interface QueuedInboxItem { + /** Agent-owned occurrence identity used by queue mutations. */ + id: InboxItemId + /** Complete pending message; it is not durable until the Agent claims it. */ + message: Message + /** Acceptance-time FIFO classification. */ + placement: 'queued' | 'steering' +} + /** Streaming face of the contract: the two SSE stream openers (mux + host). */ export interface EventsApi { /** @@ -62,18 +73,12 @@ export type MuxFrame = | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] } | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' } /** - * A message entered the addressed agent's inbox. A queued message is not - * model-visible, so there is no session event to carry it; this transient - * frame is the only wire signal. On stream open the - * host replays the current queue snapshot for every attached session (same - * refresh-recovery baseline as pending questions); queue clearing on cancel - * has no dedicated frame — clients fold it from the status flip. - * `steering` is the host's acceptance-time queue classification and remains - * authoritative in reconnect snapshots. `message.source` carries the prompt's rpcId - * when the message came over this wire (the client's provisional-echo - * reconciliation key). + * Complete transient inbox state after every enqueue, mutation, claim, or + * discard. Pending work is not model-visible and therefore has no durable + * session event; the whole snapshot makes edit, reorder, deletion, cancel, + * and reconnect converge through one authoritative signal. */ - | { type: 'session/queued'; sessionId: SessionId; message: Message; steering: boolean } + | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] } /** * One projection unit's finished value changed (session-projection RFC). * Live push state, never logged — replay recomputes on the host (the diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index b91ce54e0f..0372c8cc30 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -29,13 +29,13 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary, + ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary, } from './sessions.ts' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' -export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' +export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' @@ -56,6 +56,7 @@ export type { // ---- Errors and ids ---- export { RpcId, transportError } from './rpc.ts' export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts' +export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' // ---- Method registry and derived generics ---- export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 5394691248..d43824c088 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -24,6 +24,7 @@ export interface RpcMethodMap { 'session.models': SessionsApi['models'] 'session.selectModel': SessionsApi['selectModel'] 'session.prompt': SessionsApi['prompt'] + 'session.updateQueue': SessionsApi['updateQueue'] 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 58d3a17126..08cea82177 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -47,6 +47,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), + z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index fd99d015ee..7166c6d56b 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -9,6 +9,7 @@ import type { z as zCore } from 'zod' type ZodIssue = zCore.core.$ZodIssue import type { Branded } from '@deepseek-ai/dsh-brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' /** * Message correlation id: the initiator mints it on a request; a response @@ -44,6 +45,7 @@ export interface RpcErrorDetailsMap { 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } 'agent-busy': { reason: string } + 'queue-item-not-found': { itemId: InboxItemId } /** A known slash command reported a usage/state error; the message is the command's own text. */ 'command-error': {} /** A leading-/ prompt named no registered command; the message names the token. */ diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 81c42b8a56..f24f3442c0 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { @@ -19,6 +20,9 @@ import type { WorkspaceId } from './workspace.ts' /** SessionId: one brand cast after shape validation (the only cast point in this domain). */ export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType +/** InboxItemId: one brand cast after non-empty string validation. */ +export const inboxItemIdSchema = z.string().min(1) as unknown as z.ZodType + /** * WorkspaceId: the workspace domain's one brand cast. Hosted here rather * than in workspace.schema because session.create references it while @@ -202,6 +206,22 @@ export const sessionPromptValueSchema = z.object({ }).optional(), }) satisfies z.ZodType>> +/** session.updateQueue request payload. */ +export const sessionUpdateQueueRequestSchema = z.object({ + sessionId: sessionIdSchema, + itemId: inboxItemIdSchema, + action: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }), + z.object({ kind: z.literal('remove') }), + z.object({ kind: z.literal('promote') }), + ]), +}) as unknown as z.ZodType> + +/** session.updateQueue response value. */ +export const sessionUpdateQueueValueSchema = z.object({ + accepted: z.literal(true), +}) satisfies z.ZodType>> + /** session.cancel request payload. */ export const sessionCancelRequestSchema = z.object({ sessionId: sessionIdSchema, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index f952c7c542..60da926613 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -5,6 +5,7 @@ */ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' // The pure-type outlet: api/ is browser-importable, and the package root's // cordis Context merge (via dsh-agent) must not enter client aggregates. @@ -124,6 +125,12 @@ export interface SessionModels { failures: ModelCatalogFailure[] } +/** A client-requested mutation of one still-pending queue item. */ +export type QueueAction = + | { kind: 'edit'; content: ContentBlock[] } + | { kind: 'remove' } + | { kind: 'promote' } + /** Session list entry (v1 builds no index: list does readdir+stat). */ export interface SessionSummary { sessionId: SessionId @@ -219,6 +226,14 @@ export interface SessionsApi { prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): Promise> + /** + * Edits, removes, or promotes one pending inbox occurrence. Promotion means + * first in its current FIFO; a queued item is also made waking, so an idle + * agent starts it and a running agent takes it as the next independent turn. + */ + updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>): + Promise> + /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 8bcc6f7c3e..bf701a0409 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -25,6 +25,7 @@ import { sessionModelsValueSchema, sessionPromptValueSchema, sessionSelectModelValueSchema, + sessionUpdateQueueValueSchema, } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, @@ -67,6 +68,7 @@ export interface IApiClient { models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise>> selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> + updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> } host: { @@ -117,6 +119,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.models', payload, signal), selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), + updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index e1340aad5b..046a2e5eea 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -22,6 +22,7 @@ import { sessionModelsRequestSchema, sessionPromptRequestSchema, sessionSelectModelRequestSchema, + sessionUpdateQueueRequestSchema, } from '../api/sessions.schema.ts' import { hostCreateDirectoryRequestSchema, hostDescribeRequestSchema, @@ -69,6 +70,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) }, 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, + 'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index b555c5cef6..5db3e9884d 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -11,8 +11,8 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import AgentRegistry, {} from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent' +import type { Agent, InboxItem, InboxPlacement } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -256,84 +256,112 @@ function inboxMessage(id: string, text: string, rpcId?: string): UserMessage { }) } -describe('session/queued frames', () => { - it('forwards live enqueue events and replays the snapshot on a later mux open', async () => { +/** Build one addressable inbox occurrence around a frozen message. */ +function inboxItem(id: string, message: UserMessage, placement: InboxPlacement): InboxItem { + return { id: InboxItemId(id), message, placement } +} + +describe('session.updateQueue', () => { + it('routes an addressable action and reports a lost claim race', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const seen: unknown[] = [] + agent.updateInbox = (id, action) => { + seen.push({ id, action }) + return id === InboxItemId('present') ? 'applied' : 'not-found' + } + const api = createApiProxy(ctx, DEFAULTS) + + const applied = await api.sessions.updateQueue({ + rpcId: RpcId('q-apply'), + payload: { + sessionId: agent.id, + itemId: InboxItemId('present'), + action: { kind: 'promote' }, + }, + }) + expect(expectOk(applied)).toEqual({ accepted: true }) + const missing = await api.sessions.updateQueue({ + rpcId: RpcId('q-missing'), + payload: { + sessionId: agent.id, + itemId: InboxItemId('claimed'), + action: { kind: 'remove' }, + }, + }) + expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' }) + expect(seen).toEqual([ + { id: 'present', action: { kind: 'promote' } }, + { id: 'claimed', action: { kind: 'remove' } }, + ]) + }) +}) + +describe('session/queue frames', () => { + it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) const live = new AbortController() const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal) - // subscribed baseline + 2 queued frames + // subscribed baseline + 2 queue snapshots const liveCollected = collect(liveStream, 3, live) - const queued = inboxMessage('m-1', 'queued prompt') - const steering = inboxMessage('m-2', 'queued prompt') - ctx.emit('agent/inbox/enqueue', agent, queued, 'queued') - ctx.emit('agent/inbox/enqueue', agent, steering, 'steering') + const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued') + const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering') + ctx.emit('agent/inbox/enqueue', agent, queued) + ctx.emit('agent/inbox/enqueue', agent, steering) - const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued') + const liveFrames = (await liveCollected).filter(f => f.type === 'session/queue') expect(liveFrames).toEqual([ - { type: 'session/queued', sessionId: agent.id, message: queued, steering: false }, - { type: 'session/queued', sessionId: agent.id, message: steering, steering: true }, + { type: 'session/queue', sessionId: agent.id, items: [queued] }, + { type: 'session/queue', sessionId: agent.id, items: [queued, steering] }, ]) - // A fresh mux connection replays the still-pending entries as its baseline. + // A fresh mux connection replays only the current authoritative snapshot. const replay = new AbortController() const replayFrames = await collect( - api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 3, replay) - expect(replayFrames.filter(f => f.type === 'session/queued')).toEqual(liveFrames) + api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay) + expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[1]]) }) - it('retires mirror entries on their terminal dequeue', async () => { + it('publishes edit and promotion in the authoritative order', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) - const queued = inboxMessage('m-3', 'x') - const steering = inboxMessage('m-4', 'x', 'r-1') - ctx.emit('agent/inbox/enqueue', agent, queued, 'queued') - ctx.emit('agent/inbox/enqueue', agent, steering, 'steering') - ctx.emit('agent/inbox/dequeue', agent, queued, 'queued') - ctx.emit('agent/inbox/dequeue', agent, steering, 'steering') - const abort = new AbortController() - const frames = await collect( - api.events.mux({ rpcId: RpcId('t-mux-after'), payload: {} }, abort.signal), 1, abort) - expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0) - }) + const collected = collect( + api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 6, abort) + const first = inboxItem('i-a', inboxMessage('m-a', 'a'), 'queued') + const second = inboxItem('i-b', inboxMessage('m-b', 'b'), 'queued') + const edited = inboxItem('i-b', inboxMessage('m-b', 'b edited'), 'queued') + ctx.emit('agent/inbox/enqueue', agent, first) + ctx.emit('agent/inbox/enqueue', agent, second) + ctx.emit('agent/inbox/update', agent, edited, 'edit') + ctx.emit('agent/inbox/update', agent, edited, 'promote') + ctx.emit('agent/inbox/dequeue', agent, edited) - it('retires the matching placement when one message identity is queued and steering', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const repeated = inboxMessage('m-repeat', 'same prompt') - ctx.emit('agent/inbox/enqueue', agent, repeated, 'queued') - ctx.emit('agent/inbox/enqueue', agent, repeated, 'steering') - ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'), 'queued') - ctx.emit('agent/inbox/dequeue', agent, repeated, 'steering') - - const abort = new AbortController() - const frames = await collect( - api.events.mux({ rpcId: RpcId('t-mux-repeat'), payload: {} }, abort.signal), 2, abort) - expect(frames.filter(f => f.type === 'session/queued')).toEqual([ - { type: 'session/queued', sessionId: agent.id, message: repeated, steering: false }, + const frames = (await collected).filter(frame => frame.type === 'session/queue') + expect(frames.map(frame => frame.items)).toEqual([ + [first], + [first, second], + [first, edited], + [edited, first], + [first], ]) }) - it('retires mirror entries on a batch discard (cancel path)', async () => { + it('publishes an empty snapshot after terminal discard', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) - const doomed = inboxMessage('m-5', 'doomed') - const survivor = inboxMessage('m-6', 'survivor') - ctx.emit('agent/inbox/enqueue', agent, doomed, 'queued') - ctx.emit('agent/inbox/enqueue', agent, survivor, 'queued') + const doomed = inboxItem('i-doomed', inboxMessage('m-5', 'doomed'), 'queued') + ctx.emit('agent/inbox/enqueue', agent, doomed) ctx.emit('agent/inbox/discard', agent, [doomed]) const abort = new AbortController() const frames = await collect( - api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 2, abort) - const remaining = frames.filter(f => f.type === 'session/queued') - expect(remaining).toHaveLength(1) - expect(remaining[0]).toMatchObject({ message: survivor }) + api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 1, abort) + expect(frames.filter(frame => frame.type === 'session/queue')).toHaveLength(0) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 3968cba5c7..f520f8766a 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -51,6 +51,7 @@ function stubAgent(session: Session): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index d807557d00..1e648c8552 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -47,6 +47,7 @@ function scriptedApi(overrides: { selected: { provider: r.payload.provider, model: r.payload.model }, }), prompt: r => ok(r, { accepted: true as const }), + updateQueue: r => ok(r, { accepted: true as const }), cancel: r => ok(r, { accepted: true as const }), ...overrides.sessions, }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d7528072f7..f4c41fbeab 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -70,6 +70,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async prompt(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, + async updateQueue(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } + }, async cancel(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index fd1f107a80..7315325bd7 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -11,6 +11,7 @@ import { sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema, sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema, sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema, + sessionUpdateQueueRequestSchema, sessionUpdateQueueValueSchema, } from '../src/api/sessions.schema.ts' import { hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema, @@ -68,6 +69,7 @@ describe('rpcErrorSchema', () => { details: { provider: 'p', model: 'm' }, }).code).toBe('model-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') + expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') @@ -219,7 +221,19 @@ describe('sessions domain schemas', () => { expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' }) expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow() expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') + expect(sessionUpdateQueueRequestSchema.parse({ + sessionId: 's1', + itemId: 'i1', + action: { kind: 'edit', content: [{ type: 'text', text: 'next' }] }, + }).action.kind).toBe('edit') + expect(sessionUpdateQueueRequestSchema.parse({ + sessionId: 's1', itemId: 'i1', action: { kind: 'remove' }, + }).action.kind).toBe('remove') + expect(() => sessionUpdateQueueRequestSchema.parse({ + sessionId: 's1', itemId: '', action: { kind: 'promote' }, + })).toThrow() expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true) + expect(sessionUpdateQueueValueSchema.parse({ accepted: true }).accepted).toBe(true) expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 }) }) }) @@ -363,8 +377,10 @@ describe('events frame schemas', () => { { type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' }, { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] }, { type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' }, - { type: 'session/queued', sessionId: 's', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, steering: false }, - { type: 'session/queued', sessionId: 's', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, steering: true }, + { type: 'session/queue', sessionId: 's', items: [ + { id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, placement: 'queued' }, + { id: 'i2', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, placement: 'steering' }, + ] }, { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] @@ -382,10 +398,10 @@ describe('events frame schemas', () => { expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow() }) - it('rejects a queued frame missing its members', () => { - expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: 'x', steering: false })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } } })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: {} }, steering: false })).toThrow() + it('rejects a queue snapshot with malformed items', () => { + expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow() + expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {}, placement: 'queued' }] })).toThrow() + expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} }, placement: 'later' }] })).toThrow() }) it('accepts every host frame branch', () => { diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 153e29842d..ad517c4314 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -249,7 +249,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -292,7 +292,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index d045dc4d26..c3fb33c75c 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 905708fbb1..301ea798a6 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -32,6 +32,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index 85d0deefb8..f6ad1084c6 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index ec754aa96c..a09fcac714 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 5348af69b6..61b8fe57f1 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -47,6 +47,7 @@ function agentForCwd(cwd: string): Agent { status: 'idle', acceptsNextStep: false, send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject(input) { diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 590f752956..fd661d0583 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -29,6 +29,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: (): 'not-found' => 'not-found', cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index d1ba37e61d..9b789af178 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1232,9 +1232,9 @@ export function createTuiChat( }, { prepend: true }) // Installed before followup(): an enqueue listener can synchronously // cancel and discard before followup() returns its id. - const detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => { + const detachDiscard = ctx.on('agent/inbox/discard', (subject, items) => { if (subject !== agent) return - for (const message of messages) discarded.add(message.id) + for (const item of items) discarded.add(item.message.id) if (discarded.has(acceptedId)) cleanup() }) // followup() accepts any typed input and contains listener failures; @@ -1448,13 +1448,13 @@ export function createTuiChat( const settlePendingSteering = (id: MessageId): void => { if (pendingSteering.delete(id)) refreshStatus() } - const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, message) => { - if (subject === agent) settlePendingSteering(message.id) + const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, item) => { + if (subject === agent) settlePendingSteering(item.message.id) }) - const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, messages) => { + const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, items) => { if (subject !== agent) return let changed = false - for (const message of messages) changed = pendingSteering.delete(message.id) || changed + for (const item of items) changed = pendingSteering.delete(item.message.id) || changed if (changed) refreshStatus() }) const disposeStatus = ctx.on('agent/status', (subject, status) => { diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 87282276f8..13b8c2ac1e 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -216,6 +216,7 @@ export async function createTuiTestHarness 'not-found', followup(input) { sent.push(input.content) sentMessages.push(input) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e918f9b299..669c75c6ce 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4,7 +4,10 @@ import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { + agentEvents, assembleContextFor, InboxItemId, type Agent, type InboxItem, + type InboxPlacement, +} from '@deepseek-ai/dsh-agent' import { createUserMessage, createToolResultMessage, ReasoningEffortId, @@ -51,6 +54,13 @@ const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = { render: () => [], } +let nextInboxItem = 0 + +/** Wrap one test message in the production inbox occurrence envelope. */ +function inboxItem(message: InboxItem['message'], placement: InboxPlacement): InboxItem { + return { id: InboxItemId(`tui-item-${nextInboxItem++}`), message, placement } +} + class FakeTerminal implements Terminal { columns = 88 rows = 32 @@ -1654,12 +1664,12 @@ describe('pi-tui chat lifecycle and transcript', () => { const drainSteering = (text: string): void => { const id = result.agent.steeredIds.shift() if (id !== undefined) { - result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({ + result.ctx.emit('agent/inbox/dequeue', result.agent, inboxItem(freezeMessage({ id, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' }, - }), 'steering') + }), 'steering')) } result.session.append('steering/message', { turn: 1, @@ -1673,12 +1683,12 @@ describe('pi-tui chat lifecycle and transcript', () => { // A steering queue for a different agent never touches this status line. const other = { ...result.agent, id: SessionId('other') } as Agent result.terminal.output = '' - result.ctx.emit('agent/inbox/enqueue', other, freezeMessage({ + result.ctx.emit('agent/inbox/enqueue', other, inboxItem(freezeMessage({ id: MessageId('stub'), role: 'user', content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, - }), 'queued') + }), 'queued')) await tick() expect(result.terminal.output).not.toContain('queued') @@ -1751,26 +1761,26 @@ describe('pi-tui chat lifecycle and transcript', () => { })) // Another agent's dequeue/discard, and ones naming no pending id, leave // the badge alone. - result.ctx.emit('agent/inbox/dequeue', other, discarded[0]!, 'steering') - result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({ + result.ctx.emit('agent/inbox/dequeue', other, inboxItem(discarded[0]!, 'steering')) + result.ctx.emit('agent/inbox/dequeue', result.agent, inboxItem(freezeMessage({ id: MessageId('never-queued'), role: 'user', content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, - }), 'steering') - result.ctx.emit('agent/inbox/discard', other, discarded) + }), 'steering')) + result.ctx.emit('agent/inbox/discard', other, discarded.map(message => inboxItem(message, 'steering'))) result.ctx.emit('agent/inbox/discard', result.agent, [ - freezeMessage({ + inboxItem(freezeMessage({ id: MessageId('never-queued'), role: 'user', content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, - }), + }), 'steering'), ]) await tick() expect(result.terminal.output).toContain('2 queued') result.terminal.output = '' - result.ctx.emit('agent/inbox/discard', result.agent, discarded) + result.ctx.emit('agent/inbox/discard', result.agent, discarded.map(message => inboxItem(message, 'steering'))) await tick() expect(result.terminal.output).not.toContain('queued') @@ -2086,12 +2096,12 @@ describe('pi-tui chat lifecycle and transcript', () => { it('tracks steering drains without a running status line', async () => { const result = await setup() const source = { kind: 'user' as const } - result.ctx.emit('agent/inbox/enqueue', result.agent, freezeMessage({ + result.ctx.emit('agent/inbox/enqueue', result.agent, inboxItem(freezeMessage({ id: MessageId('stub'), role: 'user', content: [{ type: 'text', text: 'early' }], source, - }), 'steering') + }), 'steering')) result.session.append('steering/message', { turn: 1, message: createUserMessage({ @@ -2699,7 +2709,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // no armed listener, and an unrelated admission is untouched. The leak // regression: a listener installed after its cleanup already ran would // survive every future cleanup. - result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages[0]!]) + result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages[0]!, 'queued')]) const unrelated = await agentEvents(result.ctx, result.agent).waterfall( 'agent/prompt-submit', createUserMessage({ content: [{ type: 'text', text: 'unrelated' }], @@ -2743,9 +2753,9 @@ describe('pi-tui chat lifecycle and transcript', () => { content: structuredClone(input.content), source: structuredClone(input.source), }) - result.ctx.emit('agent/inbox/enqueue', foreign, message, 'queued') - result.ctx.emit('agent/inbox/enqueue', result.agent, message, 'queued') - result.ctx.emit('agent/inbox/discard', result.agent, [message]) + result.ctx.emit('agent/inbox/enqueue', foreign, inboxItem(message, 'queued')) + result.ctx.emit('agent/inbox/enqueue', result.agent, inboxItem(message, 'queued')) + result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(message, 'queued')]) return message.id } @@ -2827,16 +2837,16 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(passthrough.kind === 'allow' && passthrough.additionalContexts).toBeUndefined() // A foreign agent's discard leaves the wrapper armed. const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent - result.ctx.emit('agent/inbox/discard', foreign, [result.agent.sentMessages.at(-1)!]) + result.ctx.emit('agent/inbox/discard', foreign, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')]) // An unrelated discard for this agent also leaves the wrapper armed. - result.ctx.emit('agent/inbox/discard', result.agent, [createUserMessage({ + result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(createUserMessage({ content: [{ type: 'text', text: 'unrelated discard' }], source: { kind: 'user' }, - })]) - result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!]) + }), 'queued')]) + result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')]) await tick() // Idempotent: a repeat discard after cleanup is a no-op. - result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!]) + result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')]) const afterDiscard = await agentEvents(result.ctx, result.agent).waterfall( 'agent/prompt-submit', result.agent.sentMessages.at(-1)!, new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }), @@ -4715,7 +4725,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() }) @@ -4740,7 +4750,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -4775,14 +4785,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -4813,7 +4823,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -4857,7 +4867,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 379346134a..d70964bdba 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -1,16 +1,19 @@ /** - * Pins the client-bundle purity gate (tsdown preset resolveId classifier), - * the build-time mirror of the module-edge rules: platform module-table - * entries stay external, inline-safe wire layers inline, and every other - * @deepseek-ai value import — including a bare plugin-package name and a - * cross-plugin /client subpath — must fail the build loudly (cross-plugin - * collaboration goes through cordis services, never module imports). + * Pins shared client-bundle preset contracts: the module-edge purity gate and + * the physical watch dependencies hidden behind virtual CSS Modules. */ -import { describe, expect, it } from 'vitest' +import { fileURLToPath } from 'node:url' +import { describe, expect, it, vi } from 'vitest' import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts' type ResolveId = (source: string) => null | { id: string; external: boolean } +interface CssModulePlugin { + name: string + resolveId?: (source: string, importer: string | undefined) => null | string + load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise +} + function purityResolveId(): ResolveId { // libEntry is spelled at every call site (no default) so the // package-invariants text check can see the invariant entry per package. @@ -21,6 +24,16 @@ function purityResolveId(): ResolveId { return gate.resolveId as ResolveId } +function cssModulePlugin(): CssModulePlugin { + const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) + const plugins = (configs[1] as { plugins: CssModulePlugin[] }).plugins + const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline') + if (plugin?.resolveId === undefined || plugin.load === undefined) { + throw new Error('CSS Modules plugin missing from client config') + } + return plugin +} + describe('client bundle purity gate', () => { const resolveId = purityResolveId() @@ -60,3 +73,24 @@ describe('client bundle purity gate', () => { expect(dshClientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client']) }) }) + +describe('client bundle CSS Modules watch graph', () => { + it('registers the physical stylesheet read behind a virtual module', async () => { + const plugin = cssModulePlugin() + const importer = fileURLToPath(new URL( + '../packages/client/ui-conversation/src/client/queue/QueueDock.tsx', + import.meta.url, + )) + const stylesheet = fileURLToPath(new URL( + '../packages/client/ui-conversation/src/client/queue/QueueDock.module.css', + import.meta.url, + )) + const virtualId = plugin.resolveId?.('./QueueDock.module.css', importer) + if (virtualId === null || virtualId === undefined) throw new Error('CSS Modules import was not resolved') + const addWatchFile = vi.fn() + + await plugin.load?.call({ addWatchFile }, virtualId) + + expect(addWatchFile).toHaveBeenCalledExactlyOnceWith(stylesheet) + }) +}) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0f3270f8ee..88ec348abc 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -35,6 +35,7 @@ export const LINK_MAP: Record = { ContinuationDecision: 'core.md', ContinuationStop: 'core.md', GenerateOptions: 'core.md', + InboxItem: 'core.md', InboxPlacement: 'core.md', MessageId: 'core.md', HookContext: 'core.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5821cd0a01..88838d3d58 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -96,6 +96,21 @@ "symbol": "InboxPlacement", "source": "packages/core/agent/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "InboxItem", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "InboxAction", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "InboxActionResult", + "source": "packages/core/agent/src/types.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "SendOptions", diff --git a/tsconfig.host.json b/tsconfig.host.json index 2287d21a9a..78da3c5fdc 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -24,6 +24,7 @@ "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", + "apps/web/tests/queue-actions.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", From b71fbe770df2f69e994b4ea65ad3cf61188f1f98 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 30 Jul 2026 00:08:59 +0800 Subject: [PATCH 21/31] fix: ci --- .../2026-07-29-web-message-icon-actions-and-clock.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml index e0fc203ce6..de8869a96a 100644 --- a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md -2026-07-29-web-message-icon-actions-and-clock.md: e0072458e4c0d3e37998b5564ad14ce17aa41515 -2026-07-29-web-message-icon-actions-and-clock.zh.md: 1cc25a9656e7a100d78dd3b6b3675ca490f455f9 +2026-07-29-web-message-icon-actions-and-clock.md: e79662056792c3ab413468ad038dec40455be767 +2026-07-29-web-message-icon-actions-and-clock.zh.md: 72d3b4e0cda19438f2f46fd402b3b76de3726ae5 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 31a714a655..f4419555ee 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 5e24e4aad5154430fa48c80eb439694005df7c6f -README.zh.md: 89a34041e156e137d966bdafbc92d86477df166e +README.md: 09adb3fd7504b6e79402e3eb220ec474d76c182f +README.zh.md: d92f2ca72764faae767f0f4892037af2c574d8de From 77479f5ab0dbdd6741deb7181183dee4b3243279 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 00:55:02 +0800 Subject: [PATCH 22/31] test(queue): close CI coverage gaps --- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../test-runtime/tests/runtime.spec.tsx | 1 + packages/core/agent-loop/src/agent.ts | 8 +++ .../tests/contract-regressions.spec.ts | 57 +++++++++++++++++++ packages/core/agent-loop/tests/loop.spec.ts | 14 +++++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 7 ++- 6 files changed, 87 insertions(+), 2 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 042753f1a7..d4dde75fa0 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n } | {\n readonly kind: 'promote';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 00faae9905..c5084112eb 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -467,6 +467,7 @@ describe('fixture session face', () => { await runtime.sessions.add({ id: 's1' }) const bare = runtime.sessions.behavior('s1') expect(() => bare.prompt()).toThrow(/prompt is not stubbed/) + expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/) expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 5ac0389702..50c5618d6b 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -146,10 +146,12 @@ export class ReactLoopAgent implements Agent { if (queuedIndex === -1 && outboxIndex === -1) return 'not-found' const pending = queuedIndex === -1 ? this.outbox[outboxIndex] : this.queued[queuedIndex] + /* v8 ignore next 2 -- indices are derived from these arrays in this synchronous method. */ if (pending === undefined || pending.item === undefined) { throw new Error(`agent "${this.id}" inbox index changed during synchronous update`) } + /* v8 ignore next -- InboxAction is a closed discriminated union; all variants are covered below. */ switch (action.kind) { case 'edit': { const item: InboxItem = Object.freeze({ @@ -158,10 +160,12 @@ export class ReactLoopAgent implements Agent { }) if (queuedIndex !== -1) { const queued = this.queued[queuedIndex] + /* v8 ignore next -- the index was resolved from this array without an async boundary. */ if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during edit`) this.queued[queuedIndex] = { ...queued, item } } else { const outbox = this.outbox[outboxIndex] + /* v8 ignore next -- the index was resolved from this array without an async boundary. */ if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during edit`) this.outbox[outboxIndex] = { ...outbox, message: item.message, item } } @@ -177,11 +181,13 @@ export class ReactLoopAgent implements Agent { case 'promote': { if (queuedIndex !== -1) { const queued = this.queued.splice(queuedIndex, 1)[0] + /* v8 ignore next -- the index was resolved from this array without an async boundary. */ if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during promotion`) this.queued.unshift({ item: queued.item, wakeup: true }) this.scheduleKick() } else { const outbox = this.outbox.splice(outboxIndex, 1)[0] + /* v8 ignore next -- the index was resolved from this array without an async boundary. */ if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during promotion`) this.outbox.unshift(outbox) } @@ -189,6 +195,7 @@ export class ReactLoopAgent implements Agent { return 'applied' } default: + /* v8 ignore next -- InboxAction is a closed discriminated union. */ return assertNever(action) } } @@ -710,6 +717,7 @@ export class ReactLoopAgent implements Agent { for (const item of this.outbox.splice(0, limit)) { if (item.steering) { steered = true + /* v8 ignore next -- only inbox-backed steer entries carry steering:true. */ if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item) this.session.append( diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 82e1ced2a5..0332980bce 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -124,6 +124,63 @@ describe('addressable inbox operations', () => { .toEqual(['first', 'promote me', 'edited']) expect(agent.updateInbox(promote.id, { kind: 'remove' })).toBe('not-found') }) + + it('edits, removes, and promotes steering occurrences before admission commits', async () => { + const adapter = new MockAdapter([textResponse('done')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('steering-inbox-actions'), { provider: 'mock', model: 'mock' }) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers<{ kind: 'allow' }>() + ctx.on('agent/prompt-submit', async () => { + entered.resolve(undefined) + return decision.promise + }) + + const pending: InboxItem[] = [] + const updates: { id: string; action: string; text: string }[] = [] + const discards: string[][] = [] + ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject === agent && item.placement === 'steering') pending.push(item) + }) + ctx.on('agent/inbox/update', (subject, item, action) => { + if (subject === agent) updates.push({ id: item.id, action, text: inboxText(item) }) + }) + ctx.on('agent/inbox/discard', (subject, items) => { + if (subject === agent) discards.push(items.map(item => item.id)) + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'admitted prompt') + await entered.promise + agent.steer(createUserMessage({ content: [{ type: 'text', text: 'remove me' }], source: { kind: 'user' } })) + agent.steer(createUserMessage({ content: [{ type: 'text', text: 'edit me' }], source: { kind: 'user' } })) + agent.steer(createUserMessage({ content: [{ type: 'text', text: 'promote me' }], source: { kind: 'user' } })) + expect(pending.map(inboxText)).toEqual(['remove me', 'edit me', 'promote me']) + + const remove = pending[0]! + const edit = pending[1]! + const promote = pending[2]! + expect(agent.updateInbox(edit.id, { + kind: 'edit', + content: [{ type: 'text', text: 'edited' }], + })).toBe('applied') + expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied') + expect(agent.updateInbox(promote.id, { kind: 'promote' })).toBe('applied') + expect(updates).toEqual([ + { id: edit.id, action: 'edit', text: 'edited' }, + { id: promote.id, action: 'promote', text: 'promote me' }, + ]) + expect(discards).toEqual([[remove.id]]) + + decision.resolve({ kind: 'allow' }) + await idle + expect(agent.session.events + .filter(event => event.type === 'steering/message') + .map(event => event.type === 'steering/message' + ? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') + : '')) + .toEqual(['promote me', 'edited']) + }) }) describe('assistant replay provenance', () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index e051642dfd..89578e853a 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -59,6 +59,20 @@ describe('agent loop', () => { }, ) + it('seeds a valid AgentOptions.maxTokens into the first model request', async () => { + const adapter = new MockAdapter([textResponse('bounded')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create( + SessionId('valid-max-tokens'), + { provider: 'mock', model: 'mock', maxTokens: 256 }, + ) + + send(agent, 'use the configured output limit') + await waitForIdle(ctx, agent) + + expect(adapter.requests[0]?.maxTokens).toBe(256) + }) + it('runs a simple turn: queued message → model → idle, with ordered events', async () => { const adapter = new MockAdapter([textResponse('hello there')]) const ctx = await harness(adapter) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 60d36d3eda..f356beac0d 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -210,7 +210,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found') }) - it('covers create/prompt/cancel/describe passthrough', async () => { + it('covers create/prompt/updateQueue/cancel/describe passthrough', async () => { const c = client() expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true) @@ -233,6 +233,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' }) expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } }) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) + expect((await c.sessions.updateQueue({ + sessionId: 's' as never, + itemId: 'item-1' as never, + action: { kind: 'remove' }, + })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) expect((await c.host.describe({})).result.ok).toBe(true) }) From b7dbb25c081b18543f7dc5b5cbb9aebeed4a4f51 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:14:25 +0800 Subject: [PATCH 23/31] docs: clarify release tooling exclusion --- .../2026-07-28-experimental-plugin-package-group.i18n.yaml | 2 +- .../2026-07-28-experimental-plugin-package-group.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml index 69a3347039..27ec63f558 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md -2026-07-28-experimental-plugin-package-group.md: 3bc455eb2b676a1fb6d64117b7e9a7f390a6da83 +2026-07-28-experimental-plugin-package-group.md: 1ebae5dbb16d4c966f94ffde69fb0cb9bc163d80 2026-07-28-experimental-plugin-package-group.zh.md: f204ecd052de03d0cf347e2c770feb0ea33966c7 diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md index 3bc455eb2b..1ebae5dbb1 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md +++ b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md @@ -30,4 +30,4 @@ The pending `@deepseek-ai/dsh-tui-session-changes` `/diff` viewer and `/btw` plu ## Consequences -The path makes release exclusion and dependency blast radius visible while retaining the real plugin graph for team sharing. It gives up product-role colocation and creates path churn on promotion, while the npm name remains stable. The subtree rules, repository [current-owner/current-need rule](../../../../packages/AGENTS.md), and unchanged engineering gates limit junk-drawer growth. Because official release tooling does not yet exist, contributor policy enforces the exclusion; the directory is its required exclusion boundary when added. +The path makes release exclusion and dependency blast radius visible while retaining the real plugin graph for team sharing. It gives up product-role colocation and creates path churn on promotion, while the npm name remains stable. The subtree rules, repository [current-owner/current-need rule](../../../../packages/AGENTS.md), and unchanged engineering gates limit junk-drawer growth. Because official release tooling does not yet exist, contributor policy enforces the exclusion; when such tooling is added, the directory is its required exclusion boundary. From 4c15041fc9f3ee4ae775e8dbf1032c63223cec1b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:16:37 +0800 Subject: [PATCH 24/31] test(hooks): normalize matcher snapshot workdirs --- .../tests/snapshots/hook-cc-invalid-matcher/session.jsonl | 2 +- .../tests/snapshots/hook-codex-invalid-matcher/session.jsonl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl index e235d78b00..32b1461b7c 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","delegationDepth":0} +{"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"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl index 1dce3afec3..f4374b94a3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","delegationDepth":0} +{"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"}}} From 7ba061446d2c2e44df40376f6982ad75708a28a4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:27:52 +0800 Subject: [PATCH 25/31] docs(acp-snapshot): widen posix-only contract --- packages/support/acp-snapshot/README.i18n.yaml | 4 ++-- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- packages/support/acp-snapshot/src/suite.ts | 10 ++++------ 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 8742eb2d2a..363e0f268c 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -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/support/acp-snapshot/README.md -README.md: 5e777c3ce6b46f0e61c47f330566fe0acae41a9b -README.zh.md: 40801980600fb8d55210d2c59eeef4468aa9483f +README.md: 948c33a91977f078d16842c285011bf8f83623bd +README.zh.md: fb86bd4e236be1c79f66dc46fbaac4d7dfbf9977 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 5e777c3ce6..948c33a919 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -55,7 +55,7 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. -Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. +Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; examples include POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) and generated paths Windows cannot represent. The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 4080198060..fb86bd4e23 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -55,7 +55,7 @@ defineAcpSnapshotSuite({ 每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 -每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 +每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;示例包括 POSIX 进程语义(例如取消实时 bash 调用会终止脱离进程组)和 Windows 无法表示的生成路径。 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 650bb56ea4..1a8a49dac5 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -144,10 +144,9 @@ export interface Scenario { */ pinsNativeWindowsStdout?: boolean /** - * Whether the driven behavior needs POSIX process semantics the harness - * cannot exercise on Windows (e.g. cancelling a live bash tool call kills a - * detached process group). The scenario's run test is skipped on Windows; - * its fixtures stay guarded on every platform. + * Whether the scenario requires a non-Windows host, such as for POSIX process + * semantics or generated paths Windows cannot represent. The scenario's run + * test is skipped on Windows; its fixtures stay guarded on every platform. */ posixOnly?: boolean } @@ -962,8 +961,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { scenarioSuite('snapshot scenarios', () => { for (const scenario of scenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones - // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on - // Windows, where their process semantics cannot be driven. + // (sidecar-driven errors/cancel) are never re-recorded. `posixOnly` scenarios skip on Windows. it.skipIf(scenarioSkipped(scenario, RECORDING))(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => { const dir = join(snapshotsDir, scenario.name) const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript From 7ce330f0e4d0af6c5c37279c1e54e7e285177423 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 01:58:51 +0800 Subject: [PATCH 26/31] refactor(agent): scope queue actions to edit and remove --- ...send-and-coalesced-user-messages.i18n.yaml | 4 +- ...nified-send-and-coalesced-user-messages.md | 2 +- ...ied-send-and-coalesced-user-messages.zh.md | 2 +- ...-29-addressable-queue-operations.i18n.yaml | 4 +- ...2026-07-29-addressable-queue-operations.md | 20 +++---- ...6-07-29-addressable-queue-operations.zh.md | 20 +++---- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cordis-catalog/events.md | 42 +++++++------- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 13 ++--- docs/core-data-structures/core.zh.md | 13 ++--- docs/event-producer-consumer.md | 32 +++++------ packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../runtime/src/client/contract/session.ts | 2 +- .../src/client/sessions/conversation.ts | 1 - .../runtime/src/client/sessions/session.ts | 1 - .../client/runtime/tests/queue-store.spec.ts | 16 ++---- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/input/contract.ts | 1 - .../src/client/queue/QueueDock.tsx | 7 +-- .../ui-conversation/src/client/service.ts | 2 +- .../ui-conversation/tests/queue-dock.spec.tsx | 15 +---- .../cordis/tool-cordis/src/api-catalog.ts | 8 +-- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 48 +++------------- .../tests/contract-regressions.spec.ts | 57 ++++++------------- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/types.ts | 26 +++------ packages/core/scope/tests/invariant.spec.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 21 +++---- .../host/apiproxy/src/api/events.schema.ts | 1 - packages/host/apiproxy/src/api/events.ts | 11 ++-- .../host/apiproxy/src/api/sessions.schema.ts | 1 - packages/host/apiproxy/src/api/sessions.ts | 5 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 33 +++++------ .../host/apiproxy/tests/rpc-schemas.spec.ts | 9 ++- 49 files changed, 181 insertions(+), 294 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 54bfa56d7a..dfae9677c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md -2026-07-22-unified-send-and-coalesced-user-messages.md: 3b2187f9e9ae24f3a03c1418daf1c0aec255b314 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 02a21193ce63926b6f04a02cb7ea8a64fe6603cb +2026-07-22-unified-send-and-coalesced-user-messages.md: 4d0cbeff0c8a07362caa1ec18493267a9f0d2823 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 935a1a78a6bed451c1db646dec2ec5f4f5e87949 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index 3b2187f9e9..4d0cbeff0c 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -22,7 +22,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj **`send` does not return identity.** Callers already own the complete message and its opaque `MessageId`; creation and freezing are owned by the [identified immutable message decision](2026-07-28-identified-immutable-message-values.md), not by routing. -**Inbox lifecycle events carry occurrence identities.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/update` (a pending item was edited or promoted), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (pending items were dropped) carry an `InboxItem`: an occurrence-local `InboxItemId`, the accepted `UserMessage`, and the resolved `queued | steering` placement captured at acceptance. The occurrence identity lets observers and reconnect mirrors distinguish repeated sends of the same `MessageId` without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes one enqueue and exactly one terminal dequeue or discard; updates are non-terminal. The `dsh-agent` invariant companion asserts this FIFO conservation. +**Inbox lifecycle events carry occurrence identities.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/update` (a pending queued item was edited), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (pending items were dropped) carry an `InboxItem`: an occurrence-local `InboxItemId`, the accepted `UserMessage`, and the resolved `queued | steering` placement captured at acceptance. The occurrence identity lets observers and reconnect mirrors distinguish repeated sends of the same `MessageId` without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes one enqueue and exactly one terminal dequeue or discard; updates are non-terminal. The `dsh-agent` invariant companion asserts this FIFO conservation. **Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt. When a later prompt is admitted, retained outbox input enters its turn before that prompt, while input accepted during the current admission remains after the prompt. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate. diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 02a21193ce..935a1a78a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -22,7 +22,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` **`send` 不返回标识。** 调用方已经持有完整消息及其不透明的 `MessageId`;消息的创建与冻结由[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)负责,而不是由路由负责。 -**Inbox 生命周期事件携带单次入队标识。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/update`(待处理项被编辑或前移)、`agent/inbox/dequeue`(驱动器认领一个项)和 `agent/inbox/discard`(待处理项被丢弃)都会携带一个 `InboxItem`:仅属于本次入队的 `InboxItemId`、已接受的 `UserMessage`,以及生产方在接受消息时捕获的已解析 `queued | steering` 放置方式。单次入队标识让观察方和重连镜像能够区分同一 `MessageId` 的多次发送,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每次 FIFO 入队都会发布一个 enqueue,并且恰好发布一个终态 dequeue 或 discard;update 不是终态。`dsh-agent` 的不变量配套断言这种 FIFO 守恒。 +**Inbox 生命周期事件携带单次入队标识。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/update`(待处理的 queued 项被编辑)、`agent/inbox/dequeue`(驱动器认领一个项)和 `agent/inbox/discard`(待处理项被丢弃)都会携带一个 `InboxItem`:仅属于本次入队的 `InboxItemId`、已接受的 `UserMessage`,以及生产方在接受消息时捕获的已解析 `queued | steering` 放置方式。单次入队标识让观察方和重连镜像能够区分同一 `MessageId` 的多次发送,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每次 FIFO 入队都会发布一个 enqueue,并且恰好发布一个终态 dequeue 或 discard;update 不是终态。`dsh-agent` 的不变量配套断言这种 FIFO 守恒。 **准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词。后续提示词获准时,保留在 outbox 中的输入会先于该提示词进入其轮次,而当前准入期间接受的输入则留在提示词之后。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml index fe91b8ccda..dd2f02db0f 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md -2026-07-29-addressable-queue-operations.md: 7462b882dde0c3b25ddfb321ab339b6cd51bd170 -2026-07-29-addressable-queue-operations.zh.md: ac442421a1e21b2e09bb003ca9be1a0412374d7f +2026-07-29-addressable-queue-operations.md: 93b11dc590728a3c236971fbadf9c7c0187d943c +2026-07-29-addressable-queue-operations.zh.md: 3a8e46946ad8cbe122bc3d89c6b470654ea97baf diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md index 7462b882dd..93b11dc590 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -1,4 +1,4 @@ -# Agent Note: Address pending queue occurrences for edit, remove, and promotion +# Agent Note: Address pending queue occurrences for edit and removal Status: implemented @@ -6,19 +6,17 @@ English | [中文](2026-07-29-addressable-queue-operations.zh.md) ## Problem -The Web queue rendered pending messages but could not act on one row. `MessageId` was insufficient as an address because callers may enqueue the same immutable message more than once. The browser also inferred queue retirement from turn and status events, so a row operation racing with driver claim had no authoritative outcome. - -“Send now” introduced a separate semantic choice: it could mean reorder the next independent turn, interrupt the current turn as steering, or cancel current work. Only the first interpretation preserves the queue row’s original delivery contract. +The Web queue rendered pending messages but could not edit or delete one row. `MessageId` was insufficient as an address because callers may enqueue the same immutable message more than once. The browser also inferred queue retirement from turn and status events, so a row operation racing with driver claim had no authoritative outcome. ## Decision **Each accepted FIFO occurrence has its own identity.** AgentLoop mints an opaque `InboxItemId` and publishes an `InboxItem` containing that id, the identified `UserMessage`, and its acceptance-time `queued | steering` placement. Reusing one `MessageId` creates distinct inbox identities. Injection bypasses the FIFOs and receives no inbox identity. -**Mutation ends at driver claim.** `Agent.updateInbox(id, action)` synchronously searches the pending queued and steering FIFOs. Edit replaces frozen content while preserving `InboxItemId`, `MessageId`, source, placement, wake policy, and position. Remove emits the occurrence’s terminal discard. Promote moves it to the front of its current FIFO; an ordinary queued item also becomes waking. The driver removes an occurrence before prompt admission or steering drain, so a later mutation returns `not-found` and never rewrites durable history. +**Mutation ends at driver claim.** `Agent.updateInbox(id, action)` synchronously searches the pending queued FIFO. Edit replaces frozen content while preserving `InboxItemId`, `MessageId`, source, wake policy, and position. Remove emits the occurrence’s terminal discard. Steering and driver-claimed occurrences return `not-found`, so queue operations never rewrite active-turn input or durable history. -**The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every live mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from `turn/start`, `steering/message`, or status changes. +**The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror of queued occurrences. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every queued mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from durable turn events or status changes. -**Web actions preserve delivery kind.** QueueDock projects only `queued` occurrences; pending `steering` occurrences remain in the authoritative snapshot but wait for a dedicated Web interaction. It exposes edit and delete, but no send-now control. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. Protocol-level promotion remains available without being presented as a Web interaction; it never converts queued work into steering or cancels active work. +**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock exposes edit and delete, but no send-now control. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. ## Alternatives considered @@ -26,16 +24,16 @@ The Web queue rendered pending messages but could not act on one row. `MessageId **Apply optimistic browser mutations.** Rejected because driver claim and another client can win before the Host action. Waiting for the authoritative snapshot makes the ownership boundary visible and lets `queue-item-not-found` report a real race. -**Treat send-now as steering.** Rejected because it would change a queued independent turn into current-turn context, bypass ordinary prompt admission, and alter the one-send-one-turn guarantee. Promotion changes priority, not delivery semantics. +**Include pending steering in the queue mutation protocol.** Rejected because QueueDock has no steering interaction, and editing or deleting active-turn input would widen this feature beyond its current consumer. A dedicated steering interaction owns that delivery contract. -**Cancel the active turn before promotion.** Rejected because a row-local action must not destroy unrelated in-flight work. +**Expose a protocol-only promotion operation.** Rejected because no product interaction reorders Queue. A public operation without a current consumer would add ordering semantics and tests for speculative use. ## Verification -AgentLoop contract tests hold prompt admission while editing, removing, and promoting exact occurrences, then verify the resulting independent-turn order and terminal lifecycle events. Host schema and proxy tests cover authoritative snapshots, reconnect, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, queued-only Web projection, text-only editing, save and cancel affordances, removal, retirement races, disabled mixed-content editing, and the absent send-now control. Keyless browser scenarios drive the exposed edit and delete actions and keep accepted pending steering hidden until it becomes a durable transcript event through the built Web composition and real HTTP/SSE wire. +AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, reconnect, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios drive the exposed edit and delete actions through the built Web composition and real HTTP/SSE wire. ## Consequences -Pending work gains precise row operations without becoming durable session history. Occurrence identity is a live process-local capability and disappears at claim, cancellation, disposal, or restart; reconnect recovers only items still held by the live Agent. Send-now is intentionally weaker than interruption, and editing intentionally excludes mixed content until an editor can preserve every block. +Queued work gains precise row operations without becoming durable session history. Occurrence identity is a live process-local capability and disappears at claim, cancellation, disposal, or restart; reconnect recovers only queued items still held by the live Agent. Editing excludes mixed content until an editor can preserve every block, while pending steering remains outside this operation surface. The protocol now carries full queue snapshots on each change. Queues are expected to remain short, so deterministic recovery and multi-client convergence are preferred over an incremental mutation protocol. diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md index ac442421a1..3a8e46946a 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -1,4 +1,4 @@ -# Agent Note(agent 决策记录):为待处理队列项提供编辑、移除与前移操作 +# Agent Note(agent 决策记录):为待处理队列项提供编辑与移除操作 Status: implemented @@ -6,19 +6,17 @@ Status: implemented ## 问题 -Web 队列能够渲染待处理消息,但无法操作其中某一行。`MessageId` 不足以充当寻址标识,因为调用方可以多次将同一条不可变消息加入队列。浏览器还会根据轮次和状态事件推断队列项已退役,因此当行操作与驱动器认领发生竞态时,系统无法给出权威结果。 - -“立即发送”还引入了另一项语义选择:它可以表示重新排序下一个独立轮次、以 steering(中途引导)方式打断当前轮次,或取消当前工作。只有第一种解释能够保留该队列行原有的投递契约。 +Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行。`MessageId` 不足以充当寻址标识,因为调用方可以多次将同一条不可变消息加入队列。浏览器还会根据轮次和状态事件推断队列项已退役,因此当行操作与驱动器认领发生竞态时,系统无法给出权威结果。 ## 决策 **每次获准进入 FIFO 的项都有独立标识。** AgentLoop 会铸造不透明的 `InboxItemId`,并发布一个 `InboxItem`,其中包含该 id、已有标识的 `UserMessage`,以及接受时确定的 `queued | steering` 放置方式。复用同一个 `MessageId` 会创建不同的 inbox 标识。注入绕过 FIFO,因此不会获得 inbox 标识。 -**变更边界止于驱动器认领。** `Agent.updateInbox(id, action)` 会同步搜索仍处于待处理状态的 queued 和 steering FIFO。编辑会替换已冻结的内容,同时保留 `InboxItemId`、`MessageId`、来源、放置方式、唤醒策略和位置。移除会发出该次入队项的终态 discard。前移会把它移至当前 FIFO 的队首;普通 queued 项还会变为可唤醒。驱动器会在提示词接纳或排空 steering 之前移除该项,因此之后的变更会返回 `not-found`,绝不会改写持久历史。 +**变更边界止于驱动器认领。** `Agent.updateInbox(id, action)` 会同步搜索待处理的 queued FIFO。编辑会替换已冻结的内容,同时保留 `InboxItemId`、`MessageId`、来源、唤醒策略和位置。移除会发出该次入队项的终态 discard。steering(中途引导)项和已被驱动器认领的项会返回 `not-found`,因此队列操作绝不会改写活动轮次输入或持久历史。 -**实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 Host 镜像。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次实时变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据 `turn/start`、`steering/message` 或状态变化退役队列行。 +**实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 queued 入队项的 Host 镜像。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次 queued 变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据持久轮次事件或状态变化退役队列行。 -**Web 操作保持投递类型。** QueueDock 只投影 `queued` 入队项;待处理的 `steering` 入队项仍保留在权威快照中,等待 Web 提供专用交互。它只暴露编辑和删除,不提供立即发送控件。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。协议层仍保留前移操作,但不会把它呈现为 Web 交互;该操作绝不会把 queued 工作转换为 steering,也不会取消活动工作。 +**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 暴露编辑和删除,不提供立即发送控件。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。 ## 考虑过的替代方案 @@ -26,16 +24,16 @@ Web 队列能够渲染待处理消息,但无法操作其中某一行。`Messag **在浏览器中进行乐观变更。** 不予采纳,因为驱动器认领或另一个客户端可能先于 Host 操作完成。等待权威快照可以显式呈现所有权边界,并让 `queue-item-not-found` 报告真实竞态。 -**把立即发送视为 steering。** 不予采纳,因为这会把一个独立的排队轮次变成当前轮次的上下文,绕过普通提示词接纳,并改变单次 send 单轮次保证。前移只改变优先级,不改变投递语义。 +**将待处理 steering 纳入队列变更协议。** 不予采纳,因为 QueueDock 没有 steering 交互,而编辑或删除活动轮次输入会把此功能扩展到当前消费方之外。应由专用 steering 交互负责该投递契约。 -**前移前取消活动轮次。** 不予采纳,因为仅影响某一行的操作不应破坏无关的进行中工作。 +**暴露仅协议层的前移操作。** 不予采纳,因为当前没有产品交互会重新排序 Queue。公开一个没有当前消费方的操作,会为了推测性用途引入排序语义和测试。 ## 验证 -AgentLoop 契约测试会在编辑、移除和前移对应的精确入队项时阻塞提示词接纳,随后验证所得独立轮次顺序及终态生命周期事件。Host schema 与代理测试覆盖权威快照、重连、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、Web 仅投影 queued 项、仅文本编辑、保存与取消入口、移除、退役竞态、禁用混合内容编辑,以及不提供立即发送控件。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除,并使已接受的待处理 steering 在成为持久 transcript(文本记录)事件之前保持隐藏。 +AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、重连、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除。 ## 后果 -待处理工作获得精确的行操作,但不会因此成为持久会话历史。单次入队标识是进程本地的实时寻址凭据,会在认领、取消、dispose 或重启时消失;重连只能恢复仍由活跃 Agent 持有的项。立即发送有意弱于打断,而编辑也有意排除混合内容,直至编辑器能够保留每个块。 +queued 工作获得精确的行操作,但不会因此成为持久会话历史。单次入队标识是进程本地的实时寻址凭据,会在认领、取消、dispose 或重启时消失;重连只能恢复仍由活跃 Agent 持有的 queued 项。编辑会排除混合内容,直至编辑器能够保留每个块;待处理 steering 则不属于此操作接口。 现在,协议会在每次变更时携带完整队列快照。队列预期保持较短,因此系统优先选择确定性恢复和多客户端收敛,而非增量变更协议。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 4e25927711..dfff50708f 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 9a33a85e806e04dec3bfb73de8e4781c90f37677 -architecture.zh.md: 6fb91d6511c1213fa44da8a3ca17b100e1e29b95 +architecture.md: 1fd9bd128d1bcc0dd91d46131981ea4fc331bd74 +architecture.zh.md: 8521f09c6e415f9f8d1c0a44f7534b59c876decc diff --git a/docs/architecture.md b/docs/architecture.md index 9a33a85e80..1fd9bd128d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,7 +79,7 @@ choose declarative identity and fresh/resume path -> enable driving -> agent/session-start(source) -> start driver forever: wait for queued occurrence - claim (edit/remove/promote end) -> emit agent/status(running) if starting an interval + claim (edit/remove end) -> emit agent/status(running) if starting an interval open the next-step acceptance window -> agent/prompt-submit blocked or failed prompt -> close the window without opening a turn diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 6fb91d6511..8521f09c6e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -79,7 +79,7 @@ choose declarative identity and fresh/resume path -> enable driving -> agent/session-start(source) -> start driver forever: wait for queued occurrence - claim (edit/remove/promote end) -> emit agent/status(running) if starting an interval + claim (edit/remove end) -> emit agent/status(running) if starting an interval open the next-step acceptance window -> agent/prompt-submit blocked or failed prompt -> close the window without opening a turn diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index e6a52ce5a4..dafa342d5a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:329`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:253`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:443`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:433`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:307`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:297`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,29 +161,27 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) ### `agent/inbox/update` — emit -A still-pending inbox item changed content or position. The item id and placement remain stable; edit carries the replacement message, while promote makes this occurrence first in its current FIFO. +A still-pending queued item changed content. The item id, placement, and position remain stable while the event carries the replacement message. ```ts cordis-catalog /** - * A still-pending inbox item changed content or position. The item id and - * placement remain stable; edit carries the replacement message, while - * promote makes this occurrence first in its current FIFO. + * A still-pending queued item changed content. The item id, placement, and + * position remain stable while the event carries the replacement message. * @param agent - the owning agent. * @param item - the complete post-update occurrence. - * @param action - the applied non-terminal operation. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/update'( this: Scoped, agent: Agent, item: InboxItem, action: 'edit' | 'promote', ): void +'agent/inbox/update'(this: Scoped, agent: Agent, item: InboxItem): void ``` Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:287`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -206,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:356`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -230,7 +228,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:382`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:372`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -260,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:401`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:391`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -282,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -307,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:430`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -327,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -351,7 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -377,7 +375,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:416`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:406`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 93cb9ebf9a..6321c85127 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: a39560eecca4689186ce2d3fc183250f8698cb90 -core.zh.md: a5e3b80e11c97d64de1afd082ef02097eb61787c +core.md: dad533cee00646a40f57bd9097b2cceb8e9de9e2 +core.zh.md: 9e8afac0744fcf0df8c35dad5debce746d6614c6 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index a39560eecc..dad533cee0 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -445,11 +445,10 @@ interface InboxItem { ``` ```ts type-equiv -/** A user-requested mutation of one still-pending inbox item. */ +/** A user-requested mutation of one still-pending queued occurrence. */ type InboxAction = | { readonly kind: 'edit'; readonly content: ContentBlock[] } | { readonly kind: 'remove' } - | { readonly kind: 'promote' } ``` ```ts type-equiv @@ -549,13 +548,11 @@ interface Agent { send(message: UserMessage, options: SendOptions): void /** - * Mutate one still-pending inbox occurrence synchronously. Editing preserves + * Mutate one still-pending queued occurrence synchronously. Editing preserves * the message identity and queue position; removal publishes its terminal - * discard; promotion moves it to the front of its current FIFO and makes a - * queued item waking. A driver-claimed item is no longer pending and returns - * `not-found`. - * @param id - independently addressable inbox occurrence. - * @param action - edit, remove, or promote operation. + * discard. Steering occurrences and driver-claimed items return `not-found`. + * @param id - independently addressable queued occurrence. + * @param action - edit or remove operation. * @returns whether the pending occurrence was found and updated. */ updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index a5e3b80e11..9e8afac074 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -453,11 +453,10 @@ interface InboxItem { ``` ```ts type-equiv -/** A user-requested mutation of one still-pending inbox item. */ +/** A user-requested mutation of one still-pending queued occurrence. */ type InboxAction = | { readonly kind: 'edit'; readonly content: ContentBlock[] } | { readonly kind: 'remove' } - | { readonly kind: 'promote' } ``` ```ts type-equiv @@ -557,13 +556,11 @@ interface Agent { send(message: UserMessage, options: SendOptions): void /** - * Mutate one still-pending inbox occurrence synchronously. Editing preserves + * Mutate one still-pending queued occurrence synchronously. Editing preserves * the message identity and queue position; removal publishes its terminal - * discard; promotion moves it to the front of its current FIFO and makes a - * queued item waking. A driver-claimed item is no longer pending and returns - * `not-found`. - * @param id - independently addressable inbox occurrence. - * @param action - edit, remove, or promote operation. + * discard. Steering occurrences and driver-claimed items return `not-found`. + * @param id - independently addressable queued occurrence. + * @param action - edit or remove operation. * @returns whether the pending occurrence was found and updated. */ updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f4767e035d..b9538b89d5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:329`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:253`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:443`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:307`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:356`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:382`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:401`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:430`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:271`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:416`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:433`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:297`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:309`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:287`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:372`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:391`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:332`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index ae395367a8..fa4c87adb2 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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/runtime/README.md -README.md: f0b287d31b3aa9dddd2fde8d3f95bddd290bcf8d -README.zh.md: b2e9c29f0f254fd514f201fcedf6a9cb5e05f86c +README.md: 766d8516225cd46cb1a3a80c832d1cf55e816140 +README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index f0b287d31b..766d851622 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -18,7 +18,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## Pending queue projection -`ConversationSnapshot.queue` is the Host's authoritative transient inbox snapshot. Each row carries its `InboxItemId`, complete editable text when every content block is text, a flattened preview, and the accepted queued-or-steering placement. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove/promote operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. +`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. ## Code Mode sub-dispatch index diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index b2e9c29f0f..9b514afca9 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -18,7 +18,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 待处理队列投影 -`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 inbox 快照。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本、扁平化预览,以及接受时确定的 queued 或 steering 放置方式。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除/前移操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。 +`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。 ## Code Mode 子调用索引 diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index 4ceb2893bd..82bde108b4 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -41,7 +41,7 @@ export interface ISession { /** * Apply one mutation to a still-pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. - * @param action - edit, remove, or promote operation. + * @param action - edit or remove operation. * @returns acceptance, or a business/transport error. */ updateQueue(itemId: InboxItemId, action: QueueAction): Promise> diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 0b3b7fd414..f68a271c63 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -222,7 +222,6 @@ export interface QueuedMessage { readonly preview: string /** Complete editable text; null when the message contains non-text blocks. */ readonly text: string | null - readonly placement: 'queued' | 'steering' } /** In-progress assistant output (chunk accumulator product). */ diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 22abb6596a..6850e3e4c9 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -407,7 +407,6 @@ export class Session implements SessionFace { id: item.id, preview: queuePreviewOf(item.message.content), text: queueTextOf(item.message.content), - placement: item.placement, })) this.queueRev++ this.notifier.markDirty() diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 02fab86728..192885b66b 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -21,7 +21,6 @@ const iid = (id: string): InboxItemId => id as InboxItemId interface QueueFixture { id: string body: string - placement?: 'queued' | 'steering' content?: ContentBlock[] } @@ -36,7 +35,6 @@ function queueFrame(items: QueueFixture[]): MuxFrame { content: item.content ?? text(item.body), source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never, }), - placement: item.placement ?? 'queued', })), } } @@ -46,15 +44,13 @@ function makeSession(): Session { } describe('queue snapshot intake', () => { - it('projects stable ids, flat previews, complete text, and placement', () => { + it('projects stable ids, flat previews, and complete text', () => { const session = makeSession() session.handleMuxEnvelope(rid('env-1'), queueFrame([ { id: 'q-1', body: '第一条 排队\n消息' }, - { id: 'q-2', body: '插话', placement: 'steering' }, ])) expect(session.getSnapshot().queue).toEqual([ - { id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息', placement: 'queued' }, - { id: 'q-2', preview: '插话', text: '插话', placement: 'steering' }, + { id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' }, ]) }) @@ -66,7 +62,7 @@ describe('queue snapshot intake', () => { content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], }])) expect(session.getSnapshot().queue).toEqual([ - { id: 'q-image', preview: 'hi [image]', text: null, placement: 'queued' }, + { id: 'q-image', preview: 'hi [image]', text: null }, ]) }) @@ -90,7 +86,7 @@ describe('queue snapshot intake', () => { { id: 'q-2', body: 'two edited' }, ])) expect(session.getSnapshot().queue).toEqual([ - { id: 'q-2', preview: 'two edited', text: 'two edited', placement: 'queued' }, + { id: 'q-2', preview: 'two edited', text: 'two edited' }, ]) session.handleMuxEnvelope(rid('env-6'), queueFrame([])) expect(session.getSnapshot().queue).toEqual([]) @@ -112,12 +108,12 @@ describe('queue operation transport', () => { session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }])) const before = session.getSnapshot().queue - await expect(session.updateQueue(iid('q-op'), { kind: 'promote' })) + await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') })) .resolves.toEqual({ ok: true, value: { accepted: true } }) expect(api.callsOf('session.updateQueue')).toEqual([{ sessionId: SID, itemId: 'q-op', - action: { kind: 'promote' }, + action: { kind: 'edit', content: text('next') }, }]) expect(session.getSnapshot().queue).toBe(before) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 35084bce73..1a058830bf 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 5bf3077c748c6e9be2c0a54474dce06c42eb0b2d -README.zh.md: b4e1c9a23f1786c3dcba2063a9657a29445ed47f +README.md: a017de28e612474dd4ad94b75385e6320e682955 +README.zh.md: b352f6f745a1458d9f0555a5d7d269fe67b8e307 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5bf3077c74..a017de28e6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,5 +40,5 @@ None; this package neither assembles nor sends a provider request. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. -- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control; protocol-level promotion remains separate from the Web interaction. -- **Web exposes pending Queue only** — QueueDock omits pending steering until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay. +- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control. +- **Web exposes pending Queue only** — the Host omits pending steering from the Queue snapshot until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index b4e1c9a23f..b352f6f745 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,5 +40,5 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 -- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件;协议层的前移操作与 Web 交互保持分离。 -- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,QueueDock 不展示待处理的 steering。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。 +- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。 +- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。 diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index f88ea80633..18ef92664a 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -105,7 +105,6 @@ export interface QueuedMessage { readonly id: InboxItemId readonly preview: string readonly text: string | null - readonly placement: 'queued' | 'steering' } /** Guard union of the scoped consume-token event, checked by the machine. */ diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 858133a0d6..f069e6ea91 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -4,7 +4,7 @@ // The 'conversation.input.dock' SlotMap declaration lives in // ../contract/slots.ts beside the other input-region slots. import type { Context } from 'cordis' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client' @@ -24,10 +24,7 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock /** Queue strip: one preview line per queued message; renders null when the queue is empty. */ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { - const inbox = useSession(s => s.queue) - // TODO(web-steer-ui): Give pending steering its own interaction before - // exposing it; QueueDock owns only independent queued turns. - const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox]) + const queue = useSession(s => s.queue) const [editing, setEditing] = useState<{ id: InboxItemId; text: string } | null>(null) const [busy, setBusy] = useState(null) diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 31da0b167b..8d624de47e 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -34,7 +34,7 @@ export interface IConversation { /** * Apply one operation to a pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. - * @param action - edit, remove, or promote operation. + * @param action - edit or remove operation. * @returns completion; business failures reject. */ updateQueue(itemId: InboxItemId, action: QueueAction): Promise diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 9e83d13ccc..57ddc384f6 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -20,7 +20,7 @@ const SID = 's1' as SessionId const iid = (id: string): InboxItemId => id as InboxItemId function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage { - return { id: iid(id), preview, text, placement: 'queued' } + return { id: iid(id), preview, text } } function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { @@ -78,19 +78,6 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) - it('hides pending steering until it has a dedicated Web interaction', () => { - const steering = { ...row('i-steer', 'steer separately'), placement: 'steering' as const } - const snap = snapshotWith([steering]) - const source = liveSession(snap) - const { container } = render() - expect(container.innerHTML).toBe('') - - act(() => { source.push(snapshotWith([steering, row('i-queue', 'queue visibly')])) }) - expect(container.textContent).toContain('queue visibly') - expect(container.textContent).not.toContain('steer separately') - expect(container.querySelectorAll('button')).toHaveLength(2) - }) - it('renders active actions and disables editing for mixed-content rows', () => { const snap = snapshotWith([ row('i-1', '第一条排队消息'), diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 0083e7af2c..60f7d330d8 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1171,9 +1171,9 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/inbox/update', mode: 'emit', - signature: '\'agent/inbox/update\'( this: Scoped, agent: Agent, item: InboxItem, action: \'edit\' | \'promote\', ): void', - jsDoc: '/**\n * A still-pending inbox item changed content or position. The item id and\n * placement remain stable; edit carries the replacement message, while\n * promote makes this occurrence first in its current FIFO.\n * @param agent - the owning agent.\n * @param item - the complete post-update occurrence.\n * @param action - the applied non-terminal operation.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'A still-pending inbox item changed content or position.', + signature: '\'agent/inbox/update\'(this: Scoped, agent: Agent, item: InboxItem): void', + jsDoc: '/**\n * A still-pending queued item changed content. The item id, placement, and\n * position remain stable while the event carries the replacement message.\n * @param agent - the owning agent.\n * @param item - the complete post-update occurrence.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'A still-pending queued item changed content.', }, { name: 'agent/prompt-submit', @@ -1857,7 +1857,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InboxAction', - declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'promote\';\n};', + declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n};', }, { name: 'InboxActionResult', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index c3517d9868..3a82c693ca 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 86a5c0525cd0ca40cbeb7e4b1acbcefc417e811d -README.zh.md: b128876afb0931a758b472cbac0aa2423b4f4ab0 +README.md: a1617a1ef871f61157e0d70a06d055168170dced +README.zh.md: 6ba945a41e700331929dabb557802c14256921fb diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 86a5c0525c..a1617a1ef8 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -57,7 +57,7 @@ The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are pa The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. -Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous pending-item boundary: edit freezes replacement content without changing message identity or position, remove publishes discard, and promote moves the occurrence to the head of its queued or steering FIFO; promoting queued work also makes it waking. Edit and promote publish `agent/inbox/update`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update returns `not-found`; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. +Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. ### Loop lifecycle (`agent.ts`) diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index b128876afb..6ba945a41e 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -57,7 +57,7 @@ interface Config { 统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 -每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步待处理项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard;前移会把该项移至其 queued 或 steering FIFO 的队首,其中 queued 工作还会变为可唤醒。编辑和前移会发布 `agent/inbox/update`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新会返回 `not-found`;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 +每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 ### 循环生命周期(`agent.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 50c5618d6b..22d3f9e05e 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -137,19 +137,14 @@ export class ReactLoopAgent implements Agent { emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item) } - /** Apply one synchronous mutation to a still-pending inbox occurrence. */ + /** Apply one synchronous mutation to a still-pending queued occurrence. */ updateInbox(id: InboxItemIdType, action: InboxAction): InboxActionResult { const queuedIndex = this.queued.findIndex(candidate => candidate.item.id === id) - const outboxIndex = queuedIndex === -1 - ? this.outbox.findIndex(candidate => candidate.item?.id === id) - : -1 - if (queuedIndex === -1 && outboxIndex === -1) return 'not-found' + if (queuedIndex === -1) return 'not-found' - const pending = queuedIndex === -1 ? this.outbox[outboxIndex] : this.queued[queuedIndex] - /* v8 ignore next 2 -- indices are derived from these arrays in this synchronous method. */ - if (pending === undefined || pending.item === undefined) { - throw new Error(`agent "${this.id}" inbox index changed during synchronous update`) - } + const pending = this.queued[queuedIndex] + /* v8 ignore next -- the index was resolved from this array without an async boundary. */ + if (pending === undefined) throw new Error(`agent "${this.id}" queued item disappeared during update`) /* v8 ignore next -- InboxAction is a closed discriminated union; all variants are covered below. */ switch (action.kind) { @@ -158,42 +153,15 @@ export class ReactLoopAgent implements Agent { ...pending.item, message: freezeMessage({ ...pending.item.message, content: action.content }), }) - if (queuedIndex !== -1) { - const queued = this.queued[queuedIndex] - /* v8 ignore next -- the index was resolved from this array without an async boundary. */ - if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during edit`) - this.queued[queuedIndex] = { ...queued, item } - } else { - const outbox = this.outbox[outboxIndex] - /* v8 ignore next -- the index was resolved from this array without an async boundary. */ - if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during edit`) - this.outbox[outboxIndex] = { ...outbox, message: item.message, item } - } - emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', item, 'edit') + this.queued[queuedIndex] = { ...pending, item } + emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', item) return 'applied' } case 'remove': { - if (queuedIndex !== -1) this.queued.splice(queuedIndex, 1) - else this.outbox.splice(outboxIndex, 1) + this.queued.splice(queuedIndex, 1) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item]) return 'applied' } - case 'promote': { - if (queuedIndex !== -1) { - const queued = this.queued.splice(queuedIndex, 1)[0] - /* v8 ignore next -- the index was resolved from this array without an async boundary. */ - if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during promotion`) - this.queued.unshift({ item: queued.item, wakeup: true }) - this.scheduleKick() - } else { - const outbox = this.outbox.splice(outboxIndex, 1)[0] - /* v8 ignore next -- the index was resolved from this array without an async boundary. */ - if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during promotion`) - this.outbox.unshift(outbox) - } - emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', pending.item, 'promote') - return 'applied' - } default: /* v8 ignore next -- InboxAction is a closed discriminated union. */ return assertNever(action) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 0332980bce..31e6d7c2b1 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -60,10 +60,9 @@ function inboxText(item: InboxItem): string { } describe('addressable inbox operations', () => { - it('edits in place, removes exactly one item, and promotes the next independent turn', async () => { + it('edits in place and removes exactly one queued item', async () => { const adapter = new MockAdapter([ textResponse('first reply'), - textResponse('promoted reply'), textResponse('edited reply'), ]) const ctx = await harness(adapter) @@ -79,13 +78,13 @@ describe('addressable inbox operations', () => { }) const pending: InboxItem[] = [] - const updates: { id: string; action: string; text: string }[] = [] + const updates: { id: string; text: string }[] = [] const discards: string[][] = [] ctx.on('agent/inbox/enqueue', (subject, item) => { if (subject === agent && inboxText(item) !== 'first') pending.push(item) }) - ctx.on('agent/inbox/update', (subject, item, action) => { - if (subject === agent) updates.push({ id: item.id, action, text: inboxText(item) }) + ctx.on('agent/inbox/update', (subject, item) => { + if (subject === agent) updates.push({ id: item.id, text: inboxText(item) }) }) ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items.map(item => item.id)) @@ -95,22 +94,16 @@ describe('addressable inbox operations', () => { await admission.promise send(agent, 'remove me') send(agent, 'edit me') - send(agent, 'promote me') - expect(pending.map(inboxText)).toEqual(['remove me', 'edit me', 'promote me']) + expect(pending.map(inboxText)).toEqual(['remove me', 'edit me']) const remove = pending[0]! const edit = pending[1]! - const promote = pending[2]! expect(agent.updateInbox(edit.id, { kind: 'edit', content: [{ type: 'text', text: 'edited' }], })).toBe('applied') expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied') - expect(agent.updateInbox(promote.id, { kind: 'promote' })).toBe('applied') - expect(updates).toEqual([ - { id: edit.id, action: 'edit', text: 'edited' }, - { id: promote.id, action: 'promote', text: 'promote me' }, - ]) + expect(updates).toEqual([{ id: edit.id, text: 'edited' }]) expect(discards).toEqual([[remove.id]]) const idle = waitForIdle(ctx, agent) @@ -121,11 +114,11 @@ describe('addressable inbox operations', () => { .map(event => event.type === 'user/message' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') : '')) - .toEqual(['first', 'promote me', 'edited']) - expect(agent.updateInbox(promote.id, { kind: 'remove' })).toBe('not-found') + .toEqual(['first', 'edited']) + expect(agent.updateInbox(edit.id, { kind: 'remove' })).toBe('not-found') }) - it('edits, removes, and promotes steering occurrences before admission commits', async () => { + it('does not mutate steering occurrences', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('steering-inbox-actions'), { provider: 'mock', model: 'mock' }) @@ -137,40 +130,22 @@ describe('addressable inbox operations', () => { }) const pending: InboxItem[] = [] - const updates: { id: string; action: string; text: string }[] = [] - const discards: string[][] = [] ctx.on('agent/inbox/enqueue', (subject, item) => { if (subject === agent && item.placement === 'steering') pending.push(item) }) - ctx.on('agent/inbox/update', (subject, item, action) => { - if (subject === agent) updates.push({ id: item.id, action, text: inboxText(item) }) - }) - ctx.on('agent/inbox/discard', (subject, items) => { - if (subject === agent) discards.push(items.map(item => item.id)) - }) const idle = waitForIdle(ctx, agent) send(agent, 'admitted prompt') await entered.promise - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'remove me' }], source: { kind: 'user' } })) - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'edit me' }], source: { kind: 'user' } })) - agent.steer(createUserMessage({ content: [{ type: 'text', text: 'promote me' }], source: { kind: 'user' } })) - expect(pending.map(inboxText)).toEqual(['remove me', 'edit me', 'promote me']) + agent.steer(createUserMessage({ content: [{ type: 'text', text: 'keep me' }], source: { kind: 'user' } })) + expect(pending.map(inboxText)).toEqual(['keep me']) - const remove = pending[0]! - const edit = pending[1]! - const promote = pending[2]! - expect(agent.updateInbox(edit.id, { + const steering = pending[0]! + expect(agent.updateInbox(steering.id, { kind: 'edit', content: [{ type: 'text', text: 'edited' }], - })).toBe('applied') - expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied') - expect(agent.updateInbox(promote.id, { kind: 'promote' })).toBe('applied') - expect(updates).toEqual([ - { id: edit.id, action: 'edit', text: 'edited' }, - { id: promote.id, action: 'promote', text: 'promote me' }, - ]) - expect(discards).toEqual([[remove.id]]) + })).toBe('not-found') + expect(agent.updateInbox(steering.id, { kind: 'remove' })).toBe('not-found') decision.resolve({ kind: 'allow' }) await idle @@ -179,7 +154,7 @@ describe('addressable inbox operations', () => { .map(event => event.type === 'steering/message' ? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') : '')) - .toEqual(['promote me', 'edited']) + .toEqual(['keep me']) }) }) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 1ab6eb1172..78a933294a 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: e71d3454d22fa829af9617083c5e68586970c70a -README.zh.md: 2dac18e50ab6b4acfc899013313e5bb0069ab36c +README.md: 6bd5279ace93b6d2569833be6f102c854105c2eb +README.zh.md: cdbb0c0b70124e0037a0f7a7a03ddedf1e3b78d3 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index e71d3454d2..6bd5279ace 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -61,7 +61,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: - `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. Each accepted FIFO occurrence receives its own `InboxItemId`, even when callers reuse a `MessageId`; `agent/inbox/enqueue`/`update` and the terminal `dequeue` or `discard` carry that complete `InboxItem`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. -- `agent.updateInbox(itemId, action)` — synchronously edits, removes, or promotes one still-pending occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, placement, and FIFO position while replacing frozen content. Remove emits the occurrence's terminal discard. Promote moves it to the front of its current FIFO and makes an ordinary queued item waking. A claimed item has crossed the ownership boundary and returns `not-found`. +- `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`. - `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. - `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 2dac18e50a..cdbb0c0b70 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -61,7 +61,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 每个插件面向的 handle: - `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。每次获准进入 FIFO 的项都会获得独立的 `InboxItemId`,即使调用方复用了同一个 `MessageId`;`agent/inbox/enqueue`/`update` 及终态 `dequeue` 或 `discard` 都会携带这一完整 `InboxItem`。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 -- `agent.updateInbox(itemId, action)`:同步编辑、移除或前移一个仍处于待处理状态的项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源、放置方式与 FIFO 位置。移除会发出该项的终态 discard。前移会把它移至当前 FIFO 的队首,并使普通 queued 项能够唤醒驱动器。已被认领的项已经跨越所有权边界,因此返回 `not-found`。 +- `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`。 - `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 - `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。 - `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。 diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d32eb303bc..e1d978459d 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -50,11 +50,10 @@ export interface InboxItem { readonly placement: InboxPlacement } -/** A user-requested mutation of one still-pending inbox item. */ +/** A user-requested mutation of one still-pending queued occurrence. */ export type InboxAction = | { readonly kind: 'edit'; readonly content: ContentBlock[] } | { readonly kind: 'remove' } - | { readonly kind: 'promote' } /** Result of applying an inbox action at the synchronous ownership boundary. */ export type InboxActionResult = 'applied' | 'not-found' @@ -180,13 +179,11 @@ export interface Agent { send(message: UserMessage, options: SendOptions): void /** - * Mutate one still-pending inbox occurrence synchronously. Editing preserves + * Mutate one still-pending queued occurrence synchronously. Editing preserves * the message identity and queue position; removal publishes its terminal - * discard; promotion moves it to the front of its current FIFO and makes a - * queued item waking. A driver-claimed item is no longer pending and returns - * `not-found`. - * @param id - independently addressable inbox occurrence. - * @param action - edit, remove, or promote operation. + * discard. Steering occurrences and driver-claimed items return `not-found`. + * @param id - independently addressable queued occurrence. + * @param action - edit or remove operation. * @returns whether the pending occurrence was found and updated. */ updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult @@ -280,21 +277,14 @@ declare module 'cordis' { */ 'agent/inbox/enqueue'(this: Scoped, agent: Agent, item: InboxItem): void /** - * A still-pending inbox item changed content or position. The item id and - * placement remain stable; edit carries the replacement message, while - * promote makes this occurrence first in its current FIFO. + * A still-pending queued item changed content. The item id, placement, and + * position remain stable while the event carries the replacement message. * @param agent - the owning agent. * @param item - the complete post-update occurrence. - * @param action - the applied non-terminal operation. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/update'( - this: Scoped, - agent: Agent, - item: InboxItem, - action: 'edit' | 'promote', - ): void + 'agent/inbox/update'(this: Scoped, agent: Agent, item: InboxItem): void /** * The driver claimed one item out of the inbox: a queued item at a turn * boundary, or steering drained between steps. Fires after the item leaves diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 4057d248d1..a2b0b6bbe0 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -50,7 +50,7 @@ describe('scoped-dispatch invariants', () => { 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], 'agent/inbox/enqueue': [agent, item], - 'agent/inbox/update': [agent, item, 'edit'], + 'agent/inbox/update': [agent, item], 'agent/inbox/dequeue': [agent, item], 'agent/inbox/discard': [agent, []], 'agent/cancel-requested': [agent, { kind: 'user' }], diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 573bb8e2ab..ef2ab89c0b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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/host/apiproxy/README.md -README.md: c6f9ffae399b9d88b354b3312e36fe05b2d38a63 -README.zh.md: 59f31e521c5432dcf2ba64972b6b0d0e87988b23 +README.md: e4a29dc288d7279366c2a28ed43573792979aa92 +README.zh.md: 9f5eb0c4f84eb909d931dda8cbac764bf83aa2fe diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index c6f9ffae39..e4a29dc288 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -16,7 +16,7 @@ Session titles ride the generic projection pair like every other domain — the Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. -Pending agent input is a live control-plane contract, not session history. The gateway mirrors complete `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every change and reconnect. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content, remove discards it, and promote moves it to the front of its current FIFO while waking ordinary queued work. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The client never infers retirement from turn or status events. +Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The client never infers retirement from turn or status events. Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 59f31e521c..9f5eb0c4f8 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -16,7 +16,7 @@ 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 -待处理 agent 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的完整 `InboxItem` 单次入队项,并在每次变更和重连时广播权威的 `session/queue` 快照。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,前移会把它移至当前 FIFO 的队首,并使普通 queued 工作能够唤醒驱动器。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。客户端绝不根据轮次或状态事件推断项已退役。 +待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。客户端绝不根据轮次或状态事件推断项已退役。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 28cf49193e..3558949e5c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -508,9 +508,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) /** - * Per-session inbox occurrence mirror serving the mux-open queue snapshot + * Per-session queued-occurrence mirror serving the mux-open queue snapshot * (the same refresh-recovery baseline as pending questions). Each terminal - * inbox event retires one matching occurrence, so repeated sends of the same + * queue event retires one matching occurrence, so repeated sends of the same * identified message remain visible until every occurrence is claimed. */ const queuedMirror = new Map() @@ -522,7 +522,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro items: items.map(item => ({ id: item.id, message: item.message, - placement: item.placement, })), }) } @@ -531,12 +530,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const entries = queuedMirror.get(agent.id) if (entries === undefined) return const index = entries.findIndex(entry => entry.id === item.id) - if (index !== -1) entries.splice(index, 1) + if (index === -1) return + entries.splice(index, 1) if (entries.length === 0) queuedMirror.delete(agent.id) publishQueue(agent.id) } const disposers = [ ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => { + if (item.placement !== 'queued') return let entries = queuedMirror.get(agent.id) if (entries === undefined) { entries = [] @@ -545,18 +546,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro entries.push(item) publishQueue(agent.id) }), - ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem, action) => { + ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem) => { const entries = queuedMirror.get(agent.id) if (entries === undefined) return const index = entries.findIndex(entry => entry.id === item.id) if (index === -1) return - entries.splice(index, 1) - if (action === 'promote') { - const first = entries.findIndex(entry => entry.placement === item.placement) - entries.splice(first === -1 ? entries.length : first, 0, item) - } else { - entries.splice(index, 0, item) - } + entries.splice(index, 1, item) publishQueue(agent.id) }), ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => { @@ -567,6 +562,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (entries === undefined) return const ids = new Set(items.map(item => item.id)) const kept = entries.filter(entry => !ids.has(entry.id)) + if (kept.length === entries.length) return if (kept.length === 0) queuedMirror.delete(agent.id) else queuedMirror.set(agent.id, kept) publishQueue(agent.id) @@ -1540,7 +1536,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro items: items.map(item => ({ id: item.id, message: item.message, - placement: item.placement, })), })) } diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index d9d73b441e..4e17b1f403 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -50,7 +50,6 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ items: z.array(z.object({ id: inboxItemIdSchema, message: messageSchema, - placement: z.union([z.literal('queued'), z.literal('steering')]), })), }), // value stays wide: it already passed its unit's own schema on the host, diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 40e5c5a5ad..ad541b4e4d 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -32,14 +32,12 @@ export type ToolEventView = | { for: 'call'; view: ToolCallView } | { for: 'result'; view: ToolResultView } -/** One pending inbox occurrence in an authoritative queue snapshot. */ +/** One pending queued occurrence in an authoritative queue snapshot. */ export interface QueuedInboxItem { /** Agent-owned occurrence identity used by queue mutations. */ id: InboxItemId /** Complete pending message; it is not durable until the Agent claims it. */ message: Message - /** Acceptance-time FIFO classification. */ - placement: 'queued' | 'steering' } /** Streaming face of the contract: the two SSE stream openers (mux + host). */ @@ -73,10 +71,11 @@ export type MuxFrame = | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] } | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' } /** - * Complete transient inbox state after every enqueue, mutation, claim, or + * Complete transient queue state after every enqueue, mutation, claim, or * discard. Pending work is not model-visible and therefore has no durable - * session event; the whole snapshot makes edit, reorder, deletion, cancel, - * and reconnect converge through one authoritative signal. + * session event; the whole snapshot makes edit, deletion, cancel, and + * reconnect converge through one authoritative signal. Pending steering is + * outside this Web queue projection. */ | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] } /** diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 55b0847a13..e3744ec37a 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -225,7 +225,6 @@ export const sessionUpdateQueueRequestSchema = z.object({ action: z.discriminatedUnion('kind', [ z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }), z.object({ kind: z.literal('remove') }), - z.object({ kind: z.literal('promote') }), ]), }) as unknown as z.ZodType> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index ecff54174b..a57c3f4b73 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -129,7 +129,6 @@ export interface SessionModels { export type QueueAction = | { kind: 'edit'; content: ContentBlock[] } | { kind: 'remove' } - | { kind: 'promote' } /** Session list entry (v1 builds no index: list does readdir+stat). */ export interface SessionSummary { @@ -237,9 +236,7 @@ export interface SessionsApi { Promise> /** - * Edits, removes, or promotes one pending inbox occurrence. Promotion means - * first in its current FIFO; a queued item is also made waking, so an idle - * agent starts it and a running agent takes it as the next independent turn. + * Edits or removes one pending queued occurrence. */ updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>): Promise> diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 9212f7dd04..3d7602d296 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -295,7 +295,7 @@ describe('session.updateQueue', () => { payload: { sessionId: agent.id, itemId: InboxItemId('present'), - action: { kind: 'promote' }, + action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] }, }, }) expect(expectOk(applied)).toEqual({ accepted: true }) @@ -309,7 +309,7 @@ describe('session.updateQueue', () => { }) expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' }) expect(seen).toEqual([ - { id: 'present', action: { kind: 'promote' } }, + { id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } }, { id: 'claimed', action: { kind: 'remove' } }, ]) }) @@ -322,8 +322,8 @@ describe('session/queue frames', () => { const agent = stubAgent(ctx) const live = new AbortController() const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal) - // subscribed baseline + 2 queue snapshots - const liveCollected = collect(liveStream, 3, live) + // subscribed baseline + one queued snapshot; pending steering stays off this wire. + const liveCollected = collect(liveStream, 2, live) const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued') const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering') @@ -332,40 +332,41 @@ describe('session/queue frames', () => { const liveFrames = (await liveCollected).filter(f => f.type === 'session/queue') expect(liveFrames).toEqual([ - { type: 'session/queue', sessionId: agent.id, items: [queued] }, - { type: 'session/queue', sessionId: agent.id, items: [queued, steering] }, + { + type: 'session/queue', + sessionId: agent.id, + items: [{ id: queued.id, message: queued.message }], + }, ]) // A fresh mux connection replays only the current authoritative snapshot. const replay = new AbortController() const replayFrames = await collect( api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay) - expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[1]]) + expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[0]]) }) - it('publishes edit and promotion in the authoritative order', async () => { + it('publishes edits in place in the authoritative order', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) const abort = new AbortController() const collected = collect( - api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 6, abort) + api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 5, abort) const first = inboxItem('i-a', inboxMessage('m-a', 'a'), 'queued') const second = inboxItem('i-b', inboxMessage('m-b', 'b'), 'queued') const edited = inboxItem('i-b', inboxMessage('m-b', 'b edited'), 'queued') ctx.emit('agent/inbox/enqueue', agent, first) ctx.emit('agent/inbox/enqueue', agent, second) - ctx.emit('agent/inbox/update', agent, edited, 'edit') - ctx.emit('agent/inbox/update', agent, edited, 'promote') + ctx.emit('agent/inbox/update', agent, edited) ctx.emit('agent/inbox/dequeue', agent, edited) const frames = (await collected).filter(frame => frame.type === 'session/queue') expect(frames.map(frame => frame.items)).toEqual([ - [first], - [first, second], - [first, edited], - [edited, first], - [first], + [{ id: first.id, message: first.message }], + [{ id: first.id, message: first.message }, { id: second.id, message: second.message }], + [{ id: first.id, message: first.message }, { id: edited.id, message: edited.message }], + [{ id: first.id, message: first.message }], ]) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 7b4a50a428..80cd0baf4c 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -232,7 +232,7 @@ describe('sessions domain schemas', () => { sessionId: 's1', itemId: 'i1', action: { kind: 'remove' }, }).action.kind).toBe('remove') expect(() => sessionUpdateQueueRequestSchema.parse({ - sessionId: 's1', itemId: '', action: { kind: 'promote' }, + sessionId: 's1', itemId: 'i1', action: { kind: 'promote' }, })).toThrow() expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true) expect(sessionUpdateQueueValueSchema.parse({ accepted: true }).accepted).toBe(true) @@ -380,8 +380,7 @@ describe('events frame schemas', () => { { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] }, { type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' }, { type: 'session/queue', sessionId: 's', items: [ - { id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, placement: 'queued' }, - { id: 'i2', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, placement: 'steering' }, + { id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } }, ] }, { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, @@ -402,8 +401,8 @@ describe('events frame schemas', () => { it('rejects a queue snapshot with malformed items', () => { expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {}, placement: 'queued' }] })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} }, placement: 'later' }] })).toThrow() + expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {} }] })).toThrow() + expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} } }] })).toThrow() }) it('accepts every host frame branch', () => { From 112b0fbad1a80de70c6b9bec6fdebf0e4ac884af Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 04:12:38 +0800 Subject: [PATCH 27/31] fix(web): address queue review feedback --- ...-29-addressable-queue-operations.i18n.yaml | 4 +- ...2026-07-29-addressable-queue-operations.md | 10 ++- ...6-07-29-addressable-queue-operations.zh.md | 10 ++- .../src/client/contract/queue.ts | 13 +++ .../src/client/input/contract.ts | 8 +- .../src/client/queue/QueueDock.tsx | 10 +-- .../ui-conversation/src/client/service.ts | 6 +- .../ui-conversation/tests/queue-dock.spec.tsx | 4 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 86 +++++++++++++------ .../apiproxy/tests/api-proxy-commands.spec.ts | 48 ++++++++++- 13 files changed, 154 insertions(+), 53 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/contract/queue.ts diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml index dd2f02db0f..85befa1383 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md -2026-07-29-addressable-queue-operations.md: 93b11dc590728a3c236971fbadf9c7c0187d943c -2026-07-29-addressable-queue-operations.zh.md: 3a8e46946ad8cbe122bc3d89c6b470654ea97baf +2026-07-29-addressable-queue-operations.md: 78a7d346163bb7e5e76c989c6e93576b4a6cee64 +2026-07-29-addressable-queue-operations.zh.md: 050b9755ad4ebe70e2bdcafb711ef279331e27af diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md index 93b11dc590..78a7d34616 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -14,9 +14,11 @@ The Web queue rendered pending messages but could not edit or delete one row. `M **Mutation ends at driver claim.** `Agent.updateInbox(id, action)` synchronously searches the pending queued FIFO. Edit replaces frozen content while preserving `InboxItemId`, `MessageId`, source, wake policy, and position. Remove emits the occurrence’s terminal discard. Steering and driver-claimed occurrences return `not-found`, so queue operations never rewrite active-turn input or durable history. -**The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror of queued occurrences. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every queued mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from durable turn events or status changes. +**The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror of queued occurrences. A synchronously re-entrant update or terminal event may reach the mirror before its outer enqueue listener; the mirror retains that unseen outcome for the current dispatch and folds it into the enqueue, so listener registration order cannot publish stale content or a ghost row. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every queued mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from durable turn events or status changes. -**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock exposes edit and delete, but no send-now control. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. +**Queue addresses require a live Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. + +**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock exposes edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. ## Alternatives considered @@ -28,9 +30,11 @@ The Web queue rendered pending messages but could not edit or delete one row. `M **Expose a protocol-only promotion operation.** Rejected because no product interaction reorders Queue. A public operation without a current consumer would add ordering semantics and tests for speculative use. +**Resume a cold Agent for a queue operation.** Rejected because durable session identity does not preserve the process-local inbox capability. Resuming can only produce `not-found` after creating unrelated live state. + ## Verification -AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, reconnect, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios drive the exposed edit and delete actions through the built Web composition and real HTTP/SSE wire. +AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios drive the exposed edit and delete actions through the built Web composition and real HTTP/SSE wire. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md index 3a8e46946a..050b9755ad 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -14,9 +14,11 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 **变更边界止于驱动器认领。** `Agent.updateInbox(id, action)` 会同步搜索待处理的 queued FIFO。编辑会替换已冻结的内容,同时保留 `InboxItemId`、`MessageId`、来源、唤醒策略和位置。移除会发出该次入队项的终态 discard。steering(中途引导)项和已被驱动器认领的项会返回 `not-found`,因此队列操作绝不会改写活动轮次输入或持久历史。 -**实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 queued 入队项的 Host 镜像。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次 queued 变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据持久轮次事件或状态变化退役队列行。 +**实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 queued 入队项的 Host 镜像。同步可重入的 update 或终态事件可能先于外层 enqueue 监听器到达镜像;镜像会在当前分发期间保留这一尚不可见的结果,并在处理 enqueue 时把它合并进去,因此监听器注册顺序不会导致系统发布陈旧内容或不存在的行。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次 queued 变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据持久轮次事件或状态变化退役队列行。 -**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 暴露编辑和删除,不提供立即发送控件。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。 +**Queue 寻址要求 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 + +**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 暴露编辑和删除,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。 ## 考虑过的替代方案 @@ -28,9 +30,11 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 **暴露仅协议层的前移操作。** 不予采纳,因为当前没有产品交互会重新排序 Queue。公开一个没有当前消费方的操作,会为了推测性用途引入排序语义和测试。 +**为队列操作恢复冷 Agent。** 不予采纳,因为持久会话标识不会保留进程本地的 inbox 寻址凭据。恢复只能在创建无关的实时状态后得到 `not-found`。 + ## 验证 -AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、重连、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除。 +AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除。 ## 后果 diff --git a/packages/client/ui-conversation/src/client/contract/queue.ts b/packages/client/ui-conversation/src/client/contract/queue.ts new file mode 100644 index 0000000000..084cf13ade --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/queue.ts @@ -0,0 +1,13 @@ +/** Queue contracts derived from the runtime session face and snapshot. */ +import type { + ConversationSnapshot, SessionFace, +} from '@deepseek-ai/dsh-client-runtime/client' + +/** One address accepted by the runtime session's queue mutation verb. */ +export type QueueItemId = Parameters[0] + +/** One mutation accepted by the runtime session's queue mutation verb. */ +export type QueueAction = Parameters[1] + +/** One row projected by the runtime session's authoritative queue snapshot. */ +export type QueueRow = ConversationSnapshot['queue'][number] diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 18ef92664a..317adc0ec5 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -6,11 +6,11 @@ * (machine.ts) is package-private and never exported. */ import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { InboxItemId } from '@deepseek-ai/dsh-client-connection/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { QueueRow } from '../contract/queue.ts' /** * The scoped-event application verbs: the hub's bail listeners call these, @@ -101,11 +101,7 @@ export interface ComposerKeyboard { } /** One independently addressable row projected from the transient queue snapshot. */ -export interface QueuedMessage { - readonly id: InboxItemId - readonly preview: string - readonly text: string | null -} +export type QueuedMessage = QueueRow /** Guard union of the scoped consume-token event, checked by the machine. */ export type ConsumeTokenGuard = ConsumeTokenRequest['guard'] diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index f069e6ea91..99b91f301d 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -7,15 +7,15 @@ import type { Context } from 'cordis' import { useEffect, useState } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client' import { IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { QueueAction, QueueItemId } from '../contract/queue.ts' import css from './QueueDock.module.css' /** Queue operations injected by the session-scoped registration. */ export interface QueueDockInjected { - updateQueue: (itemId: InboxItemId, action: QueueAction) => Promise + updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise notify: (level: 'info' | 'error', text: string) => void } @@ -25,8 +25,8 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock /** Queue strip: one preview line per queued message; renders null when the queue is empty. */ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { const queue = useSession(s => s.queue) - const [editing, setEditing] = useState<{ id: InboxItemId; text: string } | null>(null) - const [busy, setBusy] = useState(null) + const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) + const [busy, setBusy] = useState(null) useEffect(() => { if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null) @@ -35,7 +35,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { if (queue.length === 0) return null const applyAction = async ( - itemId: InboxItemId, + itemId: QueueItemId, action: QueueAction, failure: string, ): Promise => { diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 8d624de47e..939073d22a 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -13,7 +13,7 @@ import type { Context } from 'cordis' // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client' +import type { QueueAction, QueueItemId } from './contract/queue.ts' import type { InputService } from './input/contract.ts' /** @@ -37,7 +37,7 @@ export interface IConversation { * @param action - edit or remove operation. * @returns completion; business failures reject. */ - updateQueue(itemId: InboxItemId, action: QueueAction): Promise + updateQueue(itemId: QueueItemId, action: QueueAction): Promise /** * Cancel the scoped session's in-flight turn. * @returns completion; failures reject as in send. @@ -80,7 +80,7 @@ export class ConversationService extends Service implements IConversation { } /** Apply one operation to a pending queue occurrence. */ - async updateQueue(itemId: InboxItemId, action: QueueAction): Promise { + async updateQueue(itemId: QueueItemId, action: QueueAction): Promise { const session = this.scopedSession('updateQueue') const result = await session.updateQueue(itemId, action) if (!result.ok) { diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 57ddc384f6..8d0614d2e9 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -9,15 +9,15 @@ import { useSyncExternalStore } from 'react' import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState, } from '@deepseek-ai/dsh-client-runtime/client' -import type { InboxItemId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { QueueItemId } from '../src/client/contract/queue.ts' import type { InputState } from '../src/client/input/contract.ts' import { QueueDock, queueDockEntry, type QueueDockInjected } from '../src/client/queue/QueueDock.tsx' afterEach(cleanup) const SID = 's1' as SessionId -const iid = (id: string): InboxItemId => id as InboxItemId +const iid = (id: string): QueueItemId => id as QueueItemId function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage { return { id: iid(id), preview, text } diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index ef2ab89c0b..4664ba28df 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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/host/apiproxy/README.md -README.md: e4a29dc288d7279366c2a28ed43573792979aa92 -README.zh.md: 9f5eb0c4f84eb909d931dda8cbac764bf83aa2fe +README.md: 7129842a0cc89f5fa10c0bceec7cf0997ac71a99 +README.zh.md: 8765627236ea7af9e5cd3b7029133181ef499905 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index e4a29dc288..7129842a0c 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -16,7 +16,7 @@ Session titles ride the generic projection pair like every other domain — the Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. -Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The client never infers retirement from turn or status events. +Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 9f5eb0c4f8..8765627236 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -16,7 +16,7 @@ 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 -待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。客户端绝不根据轮次或状态事件推断项已退役。 +待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3558949e5c..e7c1d96acf 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { - Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxItem, + Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxItem, InboxItemId, } from '@deepseek-ai/dsh-agent' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' @@ -514,6 +514,35 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * identified message remain visible until every occurrence is claimed. */ const queuedMirror = new Map() + type UnseenQueueEvent = + | { readonly kind: 'update'; readonly item: InboxItem } + | { readonly kind: 'terminal' } + const unseenQueueEvents = new Map>() + const rememberUnseen = (sessionId: SessionId, itemId: InboxItemId, event: UnseenQueueEvent): void => { + let events = unseenQueueEvents.get(sessionId) + if (events === undefined) { + events = new Map() + unseenQueueEvents.set(sessionId, events) + } + events.set(itemId, event) + // Only synchronous re-entrancy may deliver a mutation before its outer + // enqueue observer. Drop unmatched protocol-invalid observations instead + // of retaining process-local ids indefinitely. + queueMicrotask(() => { + const current = unseenQueueEvents.get(sessionId) + if (current?.get(itemId) !== event) return + current.delete(itemId) + if (current.size === 0) unseenQueueEvents.delete(sessionId) + }) + } + const takeUnseen = (sessionId: SessionId, itemId: InboxItemId): UnseenQueueEvent | undefined => { + const events = unseenQueueEvents.get(sessionId) + const event = events?.get(itemId) + if (event === undefined) return undefined + events?.delete(itemId) + if (events?.size === 0) unseenQueueEvents.delete(sessionId) + return event + } const publishQueue = (sessionId: SessionId): void => { const items = queuedMirror.get(sessionId) ?? [] broadcast({ @@ -526,49 +555,59 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } ctx.effect(() => { - const retire = (agent: Agent, item: InboxItem): void => { + const retire = (agent: Agent, item: InboxItem): boolean => { const entries = queuedMirror.get(agent.id) - if (entries === undefined) return + if (entries === undefined) { + rememberUnseen(agent.id, item.id, { kind: 'terminal' }) + return false + } const index = entries.findIndex(entry => entry.id === item.id) - if (index === -1) return + if (index === -1) { + rememberUnseen(agent.id, item.id, { kind: 'terminal' }) + return false + } entries.splice(index, 1) if (entries.length === 0) queuedMirror.delete(agent.id) - publishQueue(agent.id) + return true } const disposers = [ ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => { if (item.placement !== 'queued') return + const unseen = takeUnseen(agent.id, item.id) + if (unseen?.kind === 'terminal') return let entries = queuedMirror.get(agent.id) if (entries === undefined) { entries = [] queuedMirror.set(agent.id, entries) } - entries.push(item) + entries.push(unseen?.kind === 'update' ? unseen.item : item) publishQueue(agent.id) }), ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem) => { const entries = queuedMirror.get(agent.id) - if (entries === undefined) return + if (entries === undefined) { + rememberUnseen(agent.id, item.id, { kind: 'update', item }) + return + } const index = entries.findIndex(entry => entry.id === item.id) - if (index === -1) return + if (index === -1) { + rememberUnseen(agent.id, item.id, { kind: 'update', item }) + return + } entries.splice(index, 1, item) publishQueue(agent.id) }), ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => { - retire(agent, item) + if (retire(agent, item)) publishQueue(agent.id) }), ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => { - const entries = queuedMirror.get(agent.id) - if (entries === undefined) return - const ids = new Set(items.map(item => item.id)) - const kept = entries.filter(entry => !ids.has(entry.id)) - if (kept.length === entries.length) return - if (kept.length === 0) queuedMirror.delete(agent.id) - else queuedMirror.set(agent.id, kept) - publishQueue(agent.id) + let changed = false + for (const item of items) changed = retire(agent, item) || changed + if (changed) publishQueue(agent.id) }), ctx.on('session/disposed', (session: Session) => { queuedMirror.delete(session.id) + unseenQueueEvents.delete(session.id) }), ] return () => { for (const dispose of disposers) dispose() } @@ -1120,18 +1159,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { accepted: true as const }) }, - async updateQueue(request) { + updateQueue(request) { const { sessionId, itemId, action } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - if (found.agent.updateInbox(itemId, action) === 'not-found') { - return err(request, { + const agent = ctx.agents.get(sessionId) + if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') { + return Promise.resolve(err(request, { code: 'queue-item-not-found', message: 'queued item is no longer pending', details: { itemId }, - }) + })) } - return ok(request, { accepted: true as const }) + return Promise.resolve(ok(request, { accepted: true as const })) }, cancel(request) { diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 3d7602d296..6df71258be 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -9,7 +9,7 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm' * open-time queue snapshot. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent' import type { Agent, InboxItem, InboxPlacement } from '@deepseek-ai/dsh-agent' @@ -313,9 +313,55 @@ describe('session.updateQueue', () => { { id: 'claimed', action: { kind: 'remove' } }, ]) }) + + it('rejects a stale occurrence without resuming a cold agent', async () => { + const ctx = await harness() + const resume = vi.spyOn(ctx.agents, 'resume') + const api = createApiProxy(ctx, DEFAULTS) + const response = await api.sessions.updateQueue({ + rpcId: RpcId('q-cold'), + payload: { + sessionId: 'cold-session' as SessionId, + itemId: InboxItemId('stale-item'), + action: { kind: 'remove' }, + }, + }) + + expect(expectErr(response)).toMatchObject({ code: 'queue-item-not-found' }) + expect(resume).not.toHaveBeenCalled() + }) }) describe('session/queue frames', () => { + it('folds nested mutations observed before their outer enqueue', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const original = inboxItem('i-edit', inboxMessage('m-edit', 'before'), 'queued') + const edited = inboxItem('i-edit', inboxMessage('m-edit', 'after'), 'queued') + const removed = inboxItem('i-remove', inboxMessage('m-remove', 'remove me'), 'queued') + ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject !== agent) return + if (item.id === original.id) ctx.emit('agent/inbox/update', agent, edited) + if (item.id === removed.id) ctx.emit('agent/inbox/discard', agent, [removed]) + }) + const api = createApiProxy(ctx, DEFAULTS) + const live = new AbortController() + const collected = collect( + api.events.mux({ rpcId: RpcId('t-mux-reentrant'), payload: {} }, live.signal), 2, live) + + ctx.emit('agent/inbox/enqueue', agent, original) + ctx.emit('agent/inbox/enqueue', agent, removed) + + const liveFrames = (await collected).filter(frame => frame.type === 'session/queue') + expect(liveFrames.map(frame => frame.items)).toEqual([ + [{ id: edited.id, message: edited.message }], + ]) + const replay = new AbortController() + const replayFrames = await collect( + api.events.mux({ rpcId: RpcId('t-mux-reentrant-replay'), payload: {} }, replay.signal), 2, replay) + expect(replayFrames.filter(frame => frame.type === 'session/queue')).toEqual(liveFrames) + }) + it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) From ff570ea04c7580385db02ed68c31dd26da9235bf Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 04:20:20 +0800 Subject: [PATCH 28/31] test(agent): refresh inbox action snapshot --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index d4dde75fa0..ea8dad9a96 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n } | {\n readonly kind: 'promote';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From 04f6dfdc29e400569a7a3f7850baf11d6a9715c6 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 30 Jul 2026 10:22:44 +0800 Subject: [PATCH 29/31] Web composer stats detail row and input-zone polish Stats line moves into the InputBar's new footer slot (sharing the card's width column) and expands to the design's grouped detail row: turns/steps, LLM and tool wall time, cache hit, and input/output token split, all derived client-side from the snapshot. The composer stack owns one 8px rhythm, the seat fades the transcript through a fixed 36px gradient band, back-to-bottom follows a live --dsh-composer-height, and goal/todo strips share one 752px tip-fill column. --- ...-composer-stats-and-input-polish.i18n.yaml | 6 ++ ...-30-web-composer-stats-and-input-polish.md | 33 ++++++++ ...-web-composer-stats-and-input-polish.zh.md | 33 ++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/ChatView.module.css | 7 +- .../src/client/chat/StatsLine.module.css | 12 ++- .../src/client/chat/StatsLine.tsx | 79 ++++++++++++++++--- .../src/client/contract/slots.ts | 4 +- .../src/client/queue/QueueDock.module.css | 1 - .../skeleton/ConversationRoot.module.css | 18 ++++- .../src/client/skeleton/ConversationRoot.tsx | 27 +++++-- .../src/client/skeleton/InputBar.module.css | 16 ++-- .../src/client/skeleton/InputBar.tsx | 3 +- .../src/client/skeleton/TodoPanel.module.css | 15 ++-- .../tests/chat-stats-bash-sample.spec.tsx | 47 +++++++++-- .../ui-goal/src/client/GoalBar.module.css | 21 ++--- 18 files changed, 273 insertions(+), 57 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml new file mode 100644 index 0000000000..c82bd65908 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md +2026-07-30-web-composer-stats-and-input-polish.md: 0d90b8c1d2e283f2bcca7d9e82ac461d9fa4eb7e +2026-07-30-web-composer-stats-and-input-polish.zh.md: db47250852724e62337948aa516effb42f19066c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md new file mode 100644 index 0000000000..0d90b8c1d2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md @@ -0,0 +1,33 @@ +# Agent Note: Web composer stats detail and input-zone polish + +Status: implemented + +English | [中文](2026-07-30-web-composer-stats-and-input-polish.zh.md) + +## Problem + +The web composer footer showed a single joined stats string (cache/tokens/turns/steps) in its own stack row, visually detached from the input card and missing the design's duration and token-split details. The input zone itself had accumulated per-entry spacing hacks: dock strips carried their own margins, the sticky seat sat on a solid fill that clipped the transcript hard, the back-to-bottom control cleared the composer by a hardcoded offset that broke as the draft grew, and the goal and todo strips disagreed on surface color and column width. + +## Decision + +**The stats line renders inside the InputBar's width column through a new `footer` owner prop and expands to the design's grouped detail row; the composer stack owns one 8px rhythm; the seat fades the transcript through a fixed 36px token-bound gradient; the back-to-bottom control follows a live `--dsh-composer-height`; goal and todo share one 752px tip-fill column.** + +- `'conversation.composer.dock'` entries reach the page as the `ComposerBarOwnerProps.footer` slot, rendered under the card inside the bar's `.root`, so the stats line and the card share one width constraint. `StatsLine` derives everything client-side from the snapshot: turns/steps, LLM wall time from assistant `timing` (`completedTime - stepStartTime`), tool wall time from tool-result `time - callTime` pairs, prompt/output token split with cache-read folded into input, and cache-hit percentage. Groups render pipe-separated and drop out whole when empty; `formatTokens` (517 / 12.2K / 1.2M) and `formatDuration` (45.2s / 2m42s) are exported for tests. Durations cover only in-window nodes — the README owns that limitation. +- `.composerStack` carries `gap: 8px` and entries carry no outer margins (QueueDock's margin removed), so a dock entry that renders null costs nothing. GoalBar is the one deliberate exception: `margin: 0 auto -10px` cancels the gap and tucks its square bottom edge 2px under the card. +- The sticky seat's background is a `linear-gradient` from `color-mix(bg-base 0%, transparent)` at 0px to solid `bg-base` at 36px — pixel stops, not the figma export's percentage, so a growing draft widens only the solid region; `color-mix` keeps both themes fading from their own base. +- A `useCallback` ref on the seat attaches a ResizeObserver that publishes `--dsh-composer-height` on the scroll body; ChatView's back-to-bottom slot computes `bottom` from it (152px first-paint fallback) instead of the prior hardcoded 168px. +- The textarea's 52px two-line floor applies to the hero variant only; the docked composer collapses to content height. Goal and todo strips both use the 44px-gutter / 752px-cap column with the todo `tip` fill and l1 border; the todo header is compacted (13/20 type, 8+8 padding) so its collapsed height equals the goal strip's 38px. + +## Alternatives considered + +**Percentage gradient stops (the figma export's 24%).** Rejected: the stop scales with seat height, so a tall draft stretches the fade band over most of the transcript; the fixed 36px band equals the design's 24% at the resting ~150px composer and stays constant as the composer grows. + +**A skeleton-owned dock column with a generic "bottommost entry tucks" contract.** Built and backed out in review: a `.inputDock` wrapper owning width/rhythm plus `--dsh-dock-tuck-*` vars on `:last-child` would retarget the tuck automatically on reorder, but it rewrote every entry and the GoalBar DOM ahead of a pending merge. Per-entry CSS with GoalBar owning its own tuck was chosen; the generic column remains available if dock entries multiply. + +**Backend-supplied duration fields for the stats line.** Unnecessary: assistant `timing` and tool call/result pairs already reach the snapshot, so wall times fold client-side with no new session event or host projection. + +**Keeping the stats line as a composer-stack sibling.** Rejected: as a stack row it carried its own width constraint that drifted from the card's; as the bar's `footer` both share one column and the stats participate in the seat's sticky/gradient region by construction. + +## Consequences + +The stats row now reads turns/steps, LLM and tool durations, cache hit, and input/output tokens at a glance, at the cost that durations cover only the loaded event window (README Known Limitation). The one-gap stack rhythm makes dock spacing composition-independent, but GoalBar's tuck is positional: it must stay the bottommost dock entry (`order: 1`) or its negative margin tucks it under the wrong neighbor. The fade band is a constant 36px, so any future design retune is one stop value. `chat-stats-bash-sample.spec.tsx` pins the derivation (timing/tool folds, token split), both formatters, the grouped render, and the zero-renders-during-streaming acceptance. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md new file mode 100644 index 0000000000..db47250852 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web composer stats detail and input-zone polish + +Status: implemented + +[English](2026-07-30-web-composer-stats-and-input-polish.md) | 中文 + +## Problem + +Web 编辑器页脚原本以独立 stack 行显示一条拼接的统计字符串(cache/tokens/turns/steps),视觉上与输入卡脱节,且缺少设计稿中的耗时与 token 拆分细节。输入区自身也积累了逐条目的间距补丁:dock 条各带自己的 margin,sticky 座位下是硬切消息流的纯色填充,「回到底部」控件用硬编码偏移躲避编辑器、草稿一长高就失效,goal 与 todo 条的底色和列宽也互不一致。 + +## Decision + +**统计行经由新的 `footer` owner prop 渲染进 InputBar 的宽度列内,并扩展为设计稿的分组细节行;composer stack 拥有唯一的 8px 节奏;座位以固定 36px 的 token 绑定渐变淡出消息流;「回到底部」控件跟随实时的 `--dsh-composer-height`;goal 与 todo 共用一条 752px 的 tip 填充列。** + +- `'conversation.composer.dock'` 条目以 `ComposerBarOwnerProps.footer` 席位到达页面,渲染在卡片下方、bar 的 `.root` 之内,统计行与卡片因此共享同一宽度约束。`StatsLine` 全部在客户端从快照推导:turns/steps、由 assistant `timing`(`completedTime - stepStartTime`)折算的 LLM 墙钟时间、由 tool-result 的 `time - callTime` 配对折算的工具墙钟时间、把 cache-read 并入输入侧的提示/输出 token 拆分,以及缓存命中率。各组以竖线分隔、无数据时整组消失;`formatTokens`(517 / 12.2K / 1.2M)与 `formatDuration`(45.2s / 2m42s)导出供测试。耗时只覆盖窗口内节点——该限制由 README 记录。 +- `.composerStack` 携带 `gap: 8px`,条目不带外边距(QueueDock 的 margin 已删除),渲染为 null 的 dock 条目零成本。GoalBar 是唯一的刻意例外:`margin: 0 auto -10px` 抵消 gap,把方形下缘塞进卡片下方 2px。 +- sticky 座位的背景是从 0px 处的 `color-mix(bg-base 0%, transparent)` 到 36px 处纯色 `bg-base` 的 `linear-gradient`——像素节点而非 figma 导出的百分比,草稿长高只扩大纯色区域;`color-mix` 让两个主题都从各自的底色淡出。 +- 座位上的 `useCallback` ref 挂 ResizeObserver,把 `--dsh-composer-height` 发布到滚动体上;ChatView 的回到底部席位据此计算 `bottom`(首帧回退 152px),替换先前硬编码的 168px。 +- textarea 的 52px 两行下限只保留在 hero 变体;停靠态编辑器折叠到内容高度。goal 与 todo 条统一使用 44px 边距/752px 上限的列、todo 的 `tip` 填充与 l1 边框;todo 表头紧凑化(13/20 字号、8+8 内边距),折叠高度与 goal 条的 38px 对齐。 + +## Alternatives considered + +**百分比渐变节点(figma 导出的 24%)。** 否决:节点随座位高度缩放,长草稿会把过渡带拉伸到消息流的大半;固定 36px 过渡带等于设计稿在静息 ~150px 编辑器下的 24%,且随编辑器长高保持恒定。 + +**骨架拥有的 dock 列加通用「最底条目贴卡」契约。** 实现后在评审中撤回:由 `.inputDock` 包装层拥有宽度/节奏、在 `:last-child` 上发布 `--dsh-dock-tuck-*` 变量,重排时贴卡会自动换人,但它在一次待合并前重写了每个条目和 GoalBar 的 DOM。最终选择逐条目 CSS、GoalBar 自持贴卡;dock 条目增多时通用列方案仍然可用。 + +**由后端为统计行提供耗时字段。** 不必要:assistant `timing` 与工具 call/result 配对已经到达快照,墙钟时间可在客户端折算,无需新的会话事件或 host 投影。 + +**统计行保持为 composer stack 的兄弟节点。** 否决:作为 stack 行它携带独立的宽度约束、与卡片漂移;作为 bar 的 `footer`,两者共享一列,统计行也天然落在座位的 sticky/渐变区域内。 + +## Consequences + +统计行现在一眼可读 turns/steps、LLM 与工具耗时、缓存命中和输入/输出 token,代价是耗时只覆盖已加载事件窗口(README 已知限制)。单 gap 的 stack 节奏使 dock 间距与组合无关,但 GoalBar 的贴卡是位置性的:它必须保持为最底的 dock 条目(`order: 1`),否则其负边距会塞到错误的邻居下面。过渡带恒为 36px,未来设计调整只改一个节点值。`chat-stats-bash-sample.spec.tsx` 钉住推导(timing/工具折算、token 拆分)、两个格式化器、分组渲染,以及流式期间零重渲染的验收。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 31a714a655..193c84a5bb 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 5e24e4aad5154430fa48c80eb439694005df7c6f -README.zh.md: 89a34041e156e137d966bdafbc92d86477df166e +README.md: 650ba0abd6848831b588ba03fc8f84c4719bf08d +README.zh.md: d3465df7f0b24f12bcadc838a116d08a514d1759 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 5e24e4aad5..650ba0abd6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -34,7 +34,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source. +- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 89a34041e1..d3465df7f0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -34,7 +34,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 ## 已知限制与暂缓事项 -- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。 +- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 80e461b518..42b5384dfc 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -144,8 +144,11 @@ } :global([data-conversation-scroll]) .toBottomSlot { - /* Clears the sticky composer stack (stats + docks + input card). */ - bottom: 168px; + /* Clears the sticky composer stack (docks + input card + stats): the live + height rides --dsh-composer-height (ConversationRoot's seat observer) so + the control follows a growing textarea; the fallback covers the first + paint before the observer fires. */ + bottom: calc(var(--dsh-composer-height, 152px) + 16px); } .toBottom { diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.module.css b/packages/client/ui-conversation/src/client/chat/StatsLine.module.css index d8ea74bbf3..e66afba2c8 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.module.css +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.module.css @@ -2,12 +2,22 @@ 736px message column axis. */ .root { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; max-width: 736px; width: 100%; margin: 0 auto; box-sizing: border-box; - padding: 4px 24px 8px; + padding: 4px 24px 0px; font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-tertiary); + white-space: nowrap; + overflow: hidden; +} + +.sep { + color: var(--dsw-alias-separator-primary); } diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 7db5695030..4b9446211c 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -2,7 +2,7 @@ // Mounted on 'conversation.composer.dock' so it sticks with the composer in the // active conversation scrollport (see ConversationRoot data-conversation-scroll). -import { memo, useMemo } from 'react' +import { Fragment, memo, useMemo } from 'react' import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import css from './StatsLine.module.css' @@ -10,7 +10,13 @@ import css from './StatsLine.module.css' interface UsageTotals { turns: number steps: number - tokens: number + /** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */ + llmMs: number + /** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */ + toolMs: number + /** Prompt-side tokens: inputTokens + cacheReadTokens. */ + inputTokens: number + outputTokens: number cacheHitPct: number | null } @@ -22,35 +28,72 @@ interface UsageLike { } /** - * Fold assistant nodes into display totals. + * Fold assistant and tool-result nodes into display totals. * @param nodes - snapshot nodes. * @returns totals; cacheHitPct null until any cache accounting arrives. */ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals { const turns = new Set() let steps = 0 - let tokens = 0 + let llmMs = 0 + let toolMs = 0 let input = 0 + let output = 0 let cacheRead = 0 for (const node of nodes) { + if (node.kind === 'tool-result') { + if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime) + continue + } if (node.kind !== 'assistant') continue turns.add(node.turn) steps += 1 + if (node.timing !== undefined && node.timing.stepStartTime !== null) { + llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime) + } const usage = node.usage as UsageLike | undefined if (usage === undefined) continue input += usage.inputTokens ?? 0 + output += usage.outputTokens ?? 0 cacheRead += usage.cacheReadTokens ?? 0 - tokens += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0) } const denom = input + cacheRead return { turns: turns.size, steps, - tokens, + llmMs, + toolMs, + inputTokens: input + cacheRead, + outputTokens: output, cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100), } } +/** + * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits). + * @param n - token count. + * @returns display string. + */ +export function formatTokens(n: number): string { + const scaled = (v: number): string => + v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10) + if (n < 1_000) return String(n) + if (n < 1_000_000) return `${scaled(n / 1_000)}K` + return `${scaled(n / 1_000_000)}M` +} + +/** + * Compact duration: 45.2s under a minute, 2m42s from there on. + * @param ms - duration in milliseconds. + * @returns display string. + */ +export function formatDuration(ms: number): string { + const s = ms / 1_000 + if (s < 60) return `${Math.round(s * 10) / 10}s` + const whole = Math.round(s) + return `${Math.floor(whole / 60)}m${whole % 60}s` +} + /** Props: the conversation-snapshot selector (dock registration or unit mount). */ export interface StatsLineProps { useSession: SnapshotSelectorHook } @@ -58,10 +101,22 @@ export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) const nodes = useSession(s => s.nodes) const stats = useMemo(() => deriveStats(nodes), [nodes]) if (stats.steps === 0) return null - const parts: string[] = [] - if (stats.cacheHitPct !== null) parts.push(`cache hit ${stats.cacheHitPct}%`) - parts.push(`${stats.tokens.toLocaleString('en-US')} tokens`) - parts.push(`${stats.turns} turns`) - parts.push(`${stats.steps} steps`) - return
{parts.join(' · ')}
+ // Pipe-separated groups (figma stats strip); a group with no data drops out whole. + const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`] + const durations: string[] = [] + if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`) + if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`) + if (durations.length > 0) groups.push(durations.join(' · ')) + if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`) + groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`) + return ( +
+ {groups.map((group, i) => ( + + {i > 0 && |} + {group} + + ))} +
+ ) }) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index c468c418a4..38de7675a7 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -67,7 +67,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * design §6 MIX evidence: entries coexist in fixed order). */ 'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone } - /** The composer top-edge band (stats line family). */ + /** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */ 'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone } /** Tool-row left region inside the input card (existing chrome stays in place beside entries). */ 'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone } @@ -253,6 +253,8 @@ export interface ComposerBarOwnerProps { leftItems?: ReactNode /** input.right slot entries (tool row, before the primary button). */ rightItems?: ReactNode + /** composer.dock entries (stats line), rendered under the card inside the bar's width column. */ + footer?: ReactNode onAdd?: () => void addLabel?: string } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index adc0c42b48..952df9797f 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -1,7 +1,6 @@ /* Neutral stacked strip above the input (queue rows are informational, not a warn state). */ .dock { - margin: 6px 0; padding: 8px 12px; border: 1px solid var(--dsw-alias-separator-primary); border-radius: 10px; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 272bbae873..e240fea889 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -127,10 +127,14 @@ min-height: 0; } -/* Composer stack: dock strips above the input card (design §6 MIX order). */ +/* Composer stack: dock strips above the input card (design §6 MIX order). + The stack owns the vertical rhythm: one gap here, entries carry no outer + margins — an entry that renders null costs nothing, so spacing stays + correct for any dock combination. */ .composerStack { display: flex; flex-direction: column; + gap: 8px; } /* Common seat for the composer chain (fallback + elected overlay siblings). */ @@ -170,7 +174,17 @@ /* Above markdown CodeBlock sticky banners (z-index 6) so the footer never paints under a sticking code header while scrolling. */ z-index: 7; - background: var(--dsw-alias-bg-base); + /* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px + band at the seat's top (the figma 24% of the resting ~150px composer), + solid below — px stops, not %, so a growing draft only widens the solid + region and the fade band never stretches. The 0px stop is bg-base at + zero alpha (not white, which the figma export hardcodes) so both themes + fade from their own base. */ + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent) 0px, + var(--dsw-alias-bg-base) 36px + ); } /* Hero phase: the composer stack (hero chrome + workspace row + card) is diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 3afae25de9..bec3bb1fde 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -2,7 +2,7 @@ // chain stay mounted across no-session/session transitions. Only the inert // input body swaps for the strict session InputBar. -import { useEffect, useRef, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' @@ -29,6 +29,23 @@ export function ConversationRoot({ const [pendingWorkspaceId, setPendingWorkspaceId] = useState() const pickerAnchor = useRef(null) + // Publishes the seat's live height as --dsh-composer-height on the scroll + // body so floating controls (ChatView back-to-bottom) clear the composer as + // it grows. Callback ref, not an effect: the seat remounts when the tree + // moves between the no-session and session paths. Stable identity so React + // reattaches only on those remounts, not on every render. + const seatObserver = useRef(null) + const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => { + seatObserver.current?.disconnect() + seatObserver.current = null + const scroller = seat?.parentElement ?? null + if (seat === null || scroller === null) return + seatObserver.current = new ResizeObserver(() => { + scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`) + }) + seatObserver.current.observe(seat) + }, []) + const sessionWorkspace = sessionId === undefined ? undefined : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId)) @@ -106,6 +123,9 @@ export function ConversationRoot({ overlay: renderSlot('conversation.input.overlay', {}), leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), + // Stats band under the card, inside the bar's width column so both + // share one constraint (composer.dock = stats-line family). + footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null, }) const composerBar = ( @@ -113,9 +133,6 @@ export function ConversationRoot({ {hero && } {hero && } {hero && heroWorkspaceRow} - {/* Stats band above the input-dock strips so the prior ChatView footer - order (stats → todo/queue → card) is preserved under the sticky stack. */} - {!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)} {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} {inputBar}
@@ -133,7 +150,7 @@ export function ConversationRoot({ // on the fallback alone would leave Question/Approval panels at the content // end off-screen when the user is not pinned to the floor. const composerSeat = ( -
+
{composer}
) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 8837752830..f0a59942f7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -20,10 +20,10 @@ display: flex; flex-direction: column; align-items: center; - /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by - the chat scroller. Top 6 is the gap under the dock todo strip (12px todo - margin + 6px here); error/status strips still carry their own margin. */ - padding: 6px 32px 12px; + /* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by + the chat scroller. No top pad: the composer stack's gap owns the space + above; error/status strips still carry their own margin. */ + padding: 0 32px 8px; } .hero { @@ -209,12 +209,16 @@ .mirror { visibility: hidden; pointer-events: none; - /* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */ - min-height: 52px; max-height: 336px; overflow: hidden; } +/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24 + line + 4pt); the docked composer collapses to the content height. */ +.hero .mirror { + min-height: 52px; +} + /* Toolbar: attach + Plan + Read-only on the left; model + send on the right (figma Input_Bottom chrome). */ .row { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 22f645f0ce..0f922b5930 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -30,7 +30,7 @@ export type InputBarProps = ComposerBarProps export function InputBar({ useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection, - variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', + variant, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) @@ -417,6 +417,7 @@ export function InputBar({
+ {footer} ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 7b506b5553..716f3d9419 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,13 +1,14 @@ /* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419): tip surface, 14px radius, status icons + secondary item labels. Column is - calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */ + calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer + stack owns the gap. */ .root { flex: none; overflow: hidden; margin: 0 auto; width: calc(100% - 88px); - max-width: 776px; + max-width: 752px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; background: var(--dsw-specific-tip); @@ -20,11 +21,13 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +/* Compact scale (GoalBar reference): collapsed header totals the goal + strip's 38px (8+8 pad + 20 line + 2 border). */ .body { display: flex; flex-direction: column; - gap: 10px; - padding: 10px 16px; + gap: 8px; + padding: 8px 14px; } .header { @@ -41,8 +44,8 @@ .title { flex: none; - font-size: 14px; - line-height: 24px; + font-size: 13px; + line-height: 20px; font-weight: 500; color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 38edc48cca..2e9aa04f92 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -12,7 +12,7 @@ import type { import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' +import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { BashRow } from '../src/client/toolviews/bash-sample.tsx' afterEach(cleanup) @@ -51,7 +51,7 @@ function makeSource(init?: Partial) { } describe('deriveStats', () => { - it('folds turns/steps/tokens and cache hit percentage', () => { + it('folds turns/steps/token split and cache hit percentage', () => { const stats = deriveStats([ assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }), assistant(2, 1, { inputTokens: 100, outputTokens: 50 }), @@ -59,19 +59,53 @@ describe('deriveStats', () => { ]) expect(stats.turns).toBe(2) expect(stats.steps).toBe(3) - expect(stats.tokens).toBe(1200) + expect(stats.inputTokens).toBe(1100) + expect(stats.outputTokens).toBe(100) expect(stats.cacheHitPct).toBe(82) }) - it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => { + it('cache hit stays null with no cache accounting; out-of-window tool results ignored', () => { const tool: ToolResultNode = { kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [], isError: false, callView: null, resultView: null, } const stats = deriveStats([tool, assistant(1, 1)]) expect(stats.steps).toBe(1) + expect(stats.toolMs).toBe(0) expect(stats.cacheHitPct).toBeNull() }) + + it('sums LLM wall time from assistant timing and tool wall time from call/result pairs', () => { + const timed: AssistantMessageNode = { + ...assistant(1, 1), + timing: { stepStartTime: 1_000, firstTokenTime: 1_200, completedTime: 3_500 }, + } + const untimed: AssistantMessageNode = { + ...assistant(2, 1), + timing: { stepStartTime: null, firstTokenTime: null, completedTime: 9_000 }, + } + const tool: ToolResultNode = { + kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [], + isError: false, callView: null, resultView: null, + } + const stats = deriveStats([timed, untimed, tool]) + expect(stats.llmMs).toBe(2_500) + expect(stats.toolMs).toBe(3_000) + }) +}) + +describe('formatters', () => { + it('formats token counts compactly', () => { + expect(formatTokens(517)).toBe('517') + expect(formatTokens(12_240)).toBe('12.2K') + expect(formatTokens(517_000)).toBe('517K') + expect(formatTokens(1_230_000)).toBe('1.2M') + }) + + it('formats durations under and over a minute', () => { + expect(formatDuration(45_230)).toBe('45.2s') + expect(formatDuration(162_000)).toBe('2m42s') + }) }) describe('StatsLine', () => { @@ -79,12 +113,13 @@ describe('StatsLine', () => { return { useSession: bindSnapshotSelector(source) } } - it('renders the joined stats row and hides with zero steps', () => { + it('renders the grouped stats row and hides with zero steps', () => { const { source } = makeSource({ nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })], }) const view = render() - expect(view.getByText('cache hit 90% · 105 tokens · 1 turns · 1 steps')).toBeTruthy() + // No timing on the fixture: the duration group drops out whole. + expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok') const empty = makeSource() const emptyView = render() expect(emptyView.container.textContent).toBe('') diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index 80c87be57b..aaf832464e 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -1,11 +1,12 @@ -/* GoalBar: the goal strip docked above the composer card. The dock mirrors - InputBar's horizontal geometry (32px side padding, 776px centered cap) - plus the mock's 12px inset, so the bar's edges land 12px inside the - composer card's edges in both the capped and the squeezed regimes. The - negative bottom margin eats InputBar's 8px top padding and tucks the +/* GoalBar: the goal strip docked above the composer card. The dock's 44px + side padding and the bar's 752px cap match the todo strip's column + (TodoPanel.module.css), 24px inside the composer card's edges. The + negative bottom margin cancels the composer stack's 8px gap and tucks the bar's square bottom edge 2px under the composer card's top edge (the - card, later in DOM order, paints over it). All states share one fixed - 38px height so switching between them never resizes the strip. */ + card, later in DOM order, paints over it). Surface matches the todo + strip: tip fill, l1 border — no bottom edge where it disappears under the + card. All states share one fixed 38px height so switching between them + never resizes the strip. */ .dock { padding: 0 44px; @@ -20,10 +21,10 @@ height: 38px; margin: 0 auto -10px; padding: 0 14px; + border: 1px solid var(--dsw-alias-border-l1); + border-bottom: none; border-radius: 14px 14px 0 0; - /* Translucent hover gray doubles as the mock's #F5F6F7 over the white - base and lifts the strip off the composer card in dark mode. */ - background: var(--dsw-alias-interactive-bg-hover); + background: var(--dsw-specific-tip); } .sparkle { From 60620ec36c41864b07efcca5aba5d84857f95536 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:09:03 +0800 Subject: [PATCH 30/31] Green the jsdom lane for the composer-seat ResizeObserver and new StatsLine format The seat's height publisher needs a ResizeObserver stub in every spec that renders ConversationRoot (jsdom has none), and the two branch-tail StatsLine assertions move to the grouped detail-row output. --- .../tests/assembly-surfaces.spec.tsx | 13 ++++++++++++- .../tests/chat-branch-tails.spec.tsx | 2 +- .../tests/chat-code-subcalls.spec.tsx | 13 ++++++++++++- .../tests/chat-toolview-slot.spec.tsx | 13 ++++++++++++- .../tests/gate-branch-tails.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 17 +++++++++++++++-- 6 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 5d7f4c05e7..4322ae508b 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -29,9 +29,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' const SID = 's1' as SessionId -afterEach(cleanup) +/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */ +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) beforeEach(() => { localStorage.clear() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) const TODOS: TodoItem[] = [ diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 9bb6ba539a..c3a5543627 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -216,6 +216,6 @@ describe('small branch tails', () => { const view = render( , ) - expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy() + expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok') }) }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 6b75f940d4..bd1ac63f1f 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -23,9 +23,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client' const SID = 's1' as SessionId -afterEach(cleanup) +/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */ +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) beforeEach(() => { localStorage.clear() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing' diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 02bb6b92dc..ccf940d430 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -21,10 +21,21 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien const SID = 's1' as SessionId -afterEach(cleanup) +/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */ +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) // The chat store persists under its declared key; clear between cases. beforeEach(() => { localStorage.clear() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({ diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 6d58932ece..08190d917d 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -49,7 +49,7 @@ describe('render branch tails', () => { const view = render( } />, ) - expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy() + expect(view.container.textContent).toBe('2 turns · 3 steps|Cache hit 0%|Input 9 tok · Output 6 tok') }) it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => { diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b174bace53..3ed459b5a9 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -28,8 +28,21 @@ function fakeWiring() { return { wiring: shell, sink, shell } } -afterEach(cleanup) -beforeEach(() => { localStorage.clear() }) +/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */ +class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) +beforeEach(() => { + localStorage.clear() + vi.stubGlobal('ResizeObserver', ResizeObserverStub) +}) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId From 3ecd4a569aaca05e14aaf8b0ae8c6a9b80784c37 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:02:21 +0800 Subject: [PATCH 31/31] test(skill-local): cover root unlink rewatch --- .../tests/skill-local-watcher.spec.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 14e3c07e41..bebf4378cf 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -235,6 +235,32 @@ describe('skill-local watcher failures', () => { await settle() }) + it('replaces a retained watcher when its root emits unlinkDir', async () => { + const home = await tempDir('skill-watch-root-unlink') + const root = join(home, '.dsh/skills') + await writeSkill(root, 'removed-skill') + const ctx = new Context() + await ctx.plugin(SkillService) + const fiber = await ctx.plugin(SkillLocal, { + dshHome: join(home, '.dsh'), + agentsHome: join(home, '.agents'), + watch: true, + watchPollIntervalMs: 10, + watchStabilityThresholdMs: 20, + }) + + expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['removed-skill']) + const original = watcherHarness.watchers[0] + if (original === undefined) throw new Error('expected a root watcher') + + await rm(root, { recursive: true }) + original.emitter.emit('unlinkDir', root) + await vi.waitFor(() => { expect(original.closeCalls).toBeGreaterThan(0) }) + expect(watcherHarness.watchFiles.some(control => control.path === root)).toBe(true) + + await fiber.dispose() + }) + it('re-probes a retained root after child unlink and observes immediate recreation', async () => { const home = await tempDir('skill-watch-root-reprobe') const root = join(home, '.dsh/skills')