diff --git "a/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/.template-ref" "b/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/.template-ref" deleted file mode 100644 index d9d802aa50..0000000000 --- "a/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/.template-ref" +++ /dev/null @@ -1 +0,0 @@ -# Phase 1 template: assets/prompt-templates/d20-signal-articles.md diff --git "a/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/hook.yaml" "b/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/hook.yaml" deleted file mode 100644 index 0d83bf625f..0000000000 --- "a/assets/prompt-hooks/d20-\344\277\241\345\217\267\346\226\207\347\253\240/hook.yaml" +++ /dev/null @@ -1,25 +0,0 @@ -id: D20 -name: 信号文章 -stage: per-turn -order: 2000 -version: 1 -enabled: true - -# Content resolution -template: d20-signal-articles.md -resolver: D20SignalArticlesResolver - -# Dependencies -inputs: - - signalArticles - -# Override constraints -disableable: true - -# Classification (Phase 1 3-axis) -safetyTier: readonly -transparencyTier: opt-in-view -governanceTier: human-gated - -# operator-facing -userExplanation: "相关的信号/知识文章摘要" diff --git a/assets/prompt-templates/d20-signal-articles.md b/assets/prompt-templates/d20-signal-articles.md deleted file mode 100644 index 47e8b49465..0000000000 --- a/assets/prompt-templates/d20-signal-articles.md +++ /dev/null @@ -1,6 +0,0 @@ - - - - -Signal articles linked to this thread: -{{SIGNAL_ARTICLES_BLOCK}} diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/api-build.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/api-build.log.gz new file mode 100644 index 0000000000..86e43ea321 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/api-build.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/api-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/api-green.log.gz new file mode 100644 index 0000000000..1c699493f0 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/api-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/commands.md b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/commands.md new file mode 100644 index 0000000000..7204f2b195 --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/commands.md @@ -0,0 +1,15 @@ +# Reproducible commands (feature checkout) + +All API commands use `pnpm --filter @cat-cafe/api exec bash scripts/with-test-home.sh`, with `node --import ./test/helpers/setup-cat-registry.js --test --test-timeout=30000`. + +Red: `--test-name-pattern='callback preserves different explicit|callback retry restores' test/callback-routes.test.js test/callback-routes-agent-key.test.js` before the production edits. + +API final: test/callback-routes.test.js test/callback-routes-agent-key.test.js test/callback-message-identity.test.js test/callback-a2a-trigger.test.js test/cross-thread-action-successor.test.js test/action-successor-carrier-recovery.test.js test/action-successor-carrier-refresh-admission.test.js test/action-successor-carrier-refresh-recovery.test.js test/callback-f193-boundary.test.js test/callback-cross-post-fail-closed.test.js test/post-message-successor-e2e.test.js test/f247-cloud-return-append-winner.test.js test/callback-f177h-participant-check.test.js test/callbacks-f182-c.test.js test/callback-scope-helpers.test.js test/cross-thread-coordination-ingress.test.js test/message-store.test.js test/direct-action-carrier-recovery-e2e.test.js test/direct-action-carrier-refresh-e2e.test.js test/1398-reeval-carrier-atomic-admission.test.js + +Redis: same wrapper, then `bash scripts/run-isolated-redis-tests.sh -- node --import ./test/helpers/setup-cat-registry.js --test --test-timeout=60000 test/redis-message-store.test.js test/redis-message-delivery-atomicity.test.js`. + +MCP: same HOME wrapper, then `node --test ../mcp-server/test/callback-tools.test.js ../mcp-server/test/callback-tools-agent-key.test.js ../mcp-server/test/callback-retry.test.js`. + +Original reproducer: same HOME wrapper, then `node --test /tmp/astra-callback-distinct-client-id.test.mjs`. + +Build: `pnpm --filter @cat-cafe/api exec tsc`; `pnpm --filter @cat-cafe/finance build`; `pnpm --filter @cat-cafe/mcp-server exec tsc`. diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/finance-build.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/finance-build.log.gz new file mode 100644 index 0000000000..272b8c4e14 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/finance-build.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/format.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/format.log.gz new file mode 100644 index 0000000000..98d2216806 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/format.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/mcp-build.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/mcp-build.log.gz new file mode 100644 index 0000000000..86e43ea321 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/mcp-build.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/original-reproducer-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/original-reproducer-green.log.gz new file mode 100644 index 0000000000..863052658f Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/original-reproducer-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/producer-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/producer-green.log.gz new file mode 100644 index 0000000000..e4412016d5 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/producer-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/red.log.gz new file mode 100644 index 0000000000..92aa472d3d Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/red.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/redis-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/redis-green.log.gz new file mode 100644 index 0000000000..7e1ec43c03 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/redis-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/redis-initial.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/redis-initial.log.gz new file mode 100644 index 0000000000..421ecc6e8b Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/redis-initial.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/report.md b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/report.md new file mode 100644 index 0000000000..1b71cb423e --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/report.md @@ -0,0 +1,34 @@ +# Callback 来源身份:删除正文窗口过滤 + +续接原 task300 / A2A Lifecycle1398。接受来源为 operator231 `thread_msr51149hym0i79f#0001791607522054-000231-d1275eea`;精确新增 P1 来自 Astra237 `0001791607642722-000237-455b56e0`。基线 `6bec5551f92f6980300d22b33e0582d8a40d18a0` 的 D20 测试/注释修复已有独立批准,本增量另行审查。 + +## 根因与删除 + +普通 invocation / agent-key `post_message` 在入队之前,先扫描5秒内同文消息,再认领正文指纹;显式 clientMessageId 不在指纹中。不同ID同文的新消息被判重复。两条入口还在写入前认领短期 client ID,存储失败后该ID已被消耗,重试没有消息可恢复。四个新反例先跑,4/4失败:两入口新ID被吞、两入口落盘失败后重试被吞。 + +删除正文窗口扫描、指纹计算、原子正文认领及其缓存、Redis键生成器、消息库接口/实现和仅验证已退役接口的两条测试。普通发送不再调用 invocation/agent-key 的提前认领。没有新增缓存、TTL窗口、正文判别器或队列防重实现。 + +## 现有身份与事务 + +现有 MCP post/cross-post 发送器为一次调用生成 clientMessageId;HTTP重试和outbox保持原载荷。普通回调将此ID与已认证的发送者绑定,复用消息库的 user/thread/idempotencyKey 索引及 appendIdempotent;有接收成员时复用既有 Message+Queue 原子准入。不同ID同文独立持久化、各自唤醒;同ID重试返回原 messageId。失败发生在落盘前则不占ID。没有ID的直接HTTP请求按独立消息处理,既有MCP发送器无需改动。 + +空接收集合分支也复用 appendIdempotent,否则原子消息/队列分支之外会遗漏身份。恢复投递只使用已持久消息的正文、目标和 replyTo,不将重试载荷当新来源。身份同时适用于 invocation 和同一发送者的 agent-key 传输;不同发送者使用相同字符串ID互不覆盖。 + +精确云端回流、typed local-review 和 action-generation 的专用持久键与冲突校验保留,并优先使用原键。callback token、owner/thread/source/action围栏不变,未增加来源/角色发送许可。原短期认领API没有普通发送调用;原始鉴权后端旧状态读取和历史崩溃夹具保留,不清除存量键。无ID的历史消息不凭正文回填请求身份。 + +## 同类消费者和兼容 + +将旧同文合并断言改成新ID或无ID的独立消息/队列断言,保留 action lease/fence、coordination 和持久身份的语义检查。扫描方法仍被审查历史读取使用,因此更名为历史读取而保留该消费者。更新三处 stream+callback 合并的陈旧注释;历史 cliStdout/speechContent 的读取与渲染没有删除。 + +真实隔离Redis验证另暴露同文件既有 F317 夹具直接传旧 catId 输入:缺少 canonical MessageFrom。改用同文件既有 appendFixture 助手,原历史身份断言全部保留,未改生产F317行为。初跑63/64与修后64/64日志均保留。 + +## 验证与边界 + +- 20个API文件、23 suites、422/422;包括普通两入口、11个真实 MessageStore/InvocationQueue/Fastify.inject 身份场景、跨thread、action恢复、typed review、云回流、原子准入。 +- 独立分配临时Redis实例:消息库及投递原子性64/64,0跳过;测试wrapper核对实例归属,未使用运行实例Redis。 +- MCP发送/agent-key/传输重试3文件108/108;包含已有稳定clientMessageId重试/outbox断言。未改MCP生产源。 +- 原Astra反例脚本未改,作者亲跑1/1转绿;不是把作者结果宣称为新增独立批准。 +- Feature API、finance依赖及MCP tsc完成。最初MCP缺finance dist,编译现有依赖后成功;这是构建准备,不是候选行为失败。 +- diff-check通过;退休正文扫描/指纹接口及上一轮跨消息target过滤标识在src/test无命中。保留的精确载荷比较只校验同一typed事实/action-generation的冲突,不扫描历史正文认定普通重复。 + +本增量Web仅注释,没有UI行为变更;没有重跑未变full gate/provider/browser/soak。当前运行实例仍未包含本增量,未修改配置/数据/运行代码,未merge/restart。原1398/220是唯一发布路径;需本增量独立批准和新head的实际CI,不能用旧head的批准/CI宣称220可合。 diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/retired-search.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/retired-search.log.gz new file mode 100644 index 0000000000..86e43ea321 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/retired-search.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/sha256.json b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/sha256.json new file mode 100644 index 0000000000..ca9624be1a --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/callback-source-identity-2026-10-10/sha256.json @@ -0,0 +1,15 @@ +{ + "api-build.log.gz": "9ceffb7310338057cfe71a4ae1e2c98d2c485d81cdef906532a801f457a38d64", + "api-green.log.gz": "46a880ab997620e3d18c18ace5d2cf84d0fe3c5df172b3ffba726f8751c3dbcb", + "commands.md": "5cdc82656846023b12a65843e4b094e49866d288b279a702b5d501c1a0d45644", + "finance-build.log.gz": "b89f30aaf70abb26a4cadd588400c10f84c2e45d4eb4fe95807abdfe86eca219", + "format.log.gz": "8476dcb5c8d1ef7a932e09d29b4adbfba1a3fb5ad22cba0f7da18398b72c2df0", + "mcp-build.log.gz": "9ceffb7310338057cfe71a4ae1e2c98d2c485d81cdef906532a801f457a38d64", + "original-reproducer-green.log.gz": "e576139ff7cad4df095e3570b9a8340a067c904cc50f1c7683179f5828ca8438", + "producer-green.log.gz": "9fda10d5d675d4d1414ba2cf9df4aaf72340080fab181f5c0e03a432ebe33cf7", + "red.log.gz": "22ad2eaed010bf2a7722fd6bcef4f2f374a89f1a98eac89a11350660c049d642", + "redis-green.log.gz": "8f63bbc49e3e168fe5f7c440f342c6c93a78f22575d9b56f0d41b4acdf15d8f6", + "redis-initial.log.gz": "ed5ef9c5a00158be1cd904bfb835d063400d1ed271f9234b391cf8465eee35b7", + "report.md": "d8fa1551a2dabbe1746cd8dbebbaee6f6701b9d5038db6e1bb29e8cd024e62a8", + "retired-search.log.gz": "9ceffb7310338057cfe71a4ae1e2c98d2c485d81cdef906532a801f457a38d64" +} diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/build.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/build.log.gz new file mode 100644 index 0000000000..86e43ea321 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/build.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/ci-failure-excerpt.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/ci-failure-excerpt.log.gz new file mode 100644 index 0000000000..d8ed8b3703 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/ci-failure-excerpt.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/format.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/format.log.gz new file mode 100644 index 0000000000..f211f11bbf Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/format.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/green.log.gz new file mode 100644 index 0000000000..61436ee1a9 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/red.log.gz new file mode 100644 index 0000000000..a4d7a7ac00 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/red.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/report.md b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/report.md new file mode 100644 index 0000000000..f67caf90b3 --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/report.md @@ -0,0 +1,21 @@ +# D20 retirement: remaining dual-path CI consumers + +Accepted source: `thread_msr51149hym0i79f#0001791607259130-000220-13857b48`, revision `0001791607259130-000220-13857b48`. Peer handoff: `0001791607343110-000223-7896e07a`; queue clarification: operator222. Base: published `64d4611e3c7f18ac390e61ba44d0fab1c9eb7e27`, shared by upstream1398 and fork220. + +## Failure and cause + +Exact-head CI run38023710000 failed distributable-5 job114130261485 at dual-path-strict-equality.test.js:155 and distributable-6 job114130261558 at dual-path-validation.test.js:186. Both expected24 per-turn hooks; D20 automatic Signal article injection had been intentionally retired, leaving23. The prior cleanup missed these two consumer assertions. Original CI failure excerpt and local red12tests/10pass/2fail are retained. No production hook or prompt behavior is being changed to satisfy an obsolete test. + +## Successor + +Keep count23 and explicitly assert all surviving IDs D1-D19,D21,R1,R2,N1. Assert retiredD20 produces no trace event or prompt patch; session mailboxD21 remains registered. Existing session22, identity, governance, direct-message, teammates, serial/A2A mode, infrastructure, scope filtering, trace draining and structural trace assertions remain intact. This verifies the retired capability and surviving catalog, rather than only decrementing a number. + +Correct stale comments in PipelinePromptBuilder, the manifest endpoint and manifest generator to active45/session22/per-turn23. These are comment-only changes; production executable behavior, schemas and generator entries are unchanged. Scan source/tests/shared/generator/manifests/templates for obsolete24/46 hook-count assertions and retired D20 wiring; no active matches remain. Historical feature docs and evidence remain historical rather than being rewritten. + +Also confirm confirmedLocalCallbackRoutedTargets/confirmedLocalCallbackRoutingMentions/getLocalRoutingLineStartMentions have no remaining src/test matches. No new dedup layer is introduced; existing Queue source identity remains unchanged. Do not redeliver historical101/123. + +## Verification and limits + +Nine affected API files,13suites,110/110 pass: the two failing files plus hook-pipeline, hook-pipeline-integration, hook-resolver-registry, hook-resolvers-session, hook-resolvers-turn, hook-segment-coverage and transport-boundary-l0-equivalence. Temporary testHOME/blocked Redis/in-memory fixtures; no real provider or runtime experiment. Feature API tsc, Biome and diff-check pass. No unchanged Web/full-gate rerun. Fresh remote CI is not yet proven for the successor. + +Prior approved4f04 cleanup/tips,6a8 errors and2f9623 routing scope remains intact; this successor is test assertions plus comments/evidence only. Exact successor review goes back to Astra, then normal fast-forward updates original1398/220. No new PR/tracker, runtime/config/data/process mutation, merge or restart. Operator220 says to finish before they merge220; no claim it is ready until actual review and current-head CI are accounted for. diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/sha256.json b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/sha256.json new file mode 100644 index 0000000000..96433d4126 --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/dual-path-ci-2026-10-10/sha256.json @@ -0,0 +1,8 @@ +{ + "build.log.gz": "9ceffb7310338057cfe71a4ae1e2c98d2c485d81cdef906532a801f457a38d64", + "ci-failure-excerpt.log.gz": "c18c42e7b66de210c977a7632b8cf754554fb22887e7fdba4a411f5e05d8fa28", + "format.log.gz": "8300ca4ea5b136b1e618858b1c31d5c68a3039a5507376e68533e5b0cda5a3d8", + "green.log.gz": "bc7f3efd25580537a2b7c6fbdd6dae140a69a14d53b4b82c8fce11cf3d3f3701", + "red.log.gz": "1209bddf2606729926e750ed8ff1321339851f5d49225c544ffe1ac6c377e366", + "report.md": "27187f68d7bb39fc2da267fcd80bc81b765db38a4c9f9d4379b3d41cf0541a5d" +} diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/boundary-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/boundary-green.log.gz new file mode 100644 index 0000000000..81645edc9a Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/boundary-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/build.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/build.log.gz new file mode 100644 index 0000000000..86e43ea321 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/build.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/format.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/format.log.gz new file mode 100644 index 0000000000..cd80e12246 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/format.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/red.log.gz new file mode 100644 index 0000000000..d6acabf022 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/red.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/report.md b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/report.md new file mode 100644 index 0000000000..056a7386a8 --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/report.md @@ -0,0 +1,34 @@ +# Distinct final handoff after a callback — operator143/152/153 + +Accepted source/revision: `thread_msr51149hym0i79f#0001791604877657-000152-d70a0ecf` / `0001791604877657-000152-d70a0ecf`. Related operator153: `0001791604934659-000153-3ba6c6ac`. Reviewer P1 and previous UI approval: `0001791605056758-000160-068add86`. + +## Observed failure and root cause + +Astra independently inspected final response101 (`0001791604123156-000101-224c12c9`): its body included a legal line-start @astra, but History had mentions=[] and no dispatch receipt, with no remaining Queue work. Earlier callback123 (`0001791604430844-000123-29f6d684`) had delivered a different scope notification to Astra. The running source and dist matched. + +routeSerial collected successful callback targets in a per-turn set and removed every matching target from the final response. This incorrectly treated two independent message sources as one source. Removing availability checks earlier did not address this separate mechanism. + +## Final design + +The callback message and the final lifecycle response each own their own source × target identity. Final response routing takes its own parsed body, without merging or subtracting callback targets. Existing Queue source identity/idempotency owns duplicate admission; no new cache, fallback, condition or inferred-text identity is added. A final response with identical text still has a distinct source ID; string equality cannot erase its delivery. A callback confirmation contributes its own persisted message ID once and never becomes a new final-message carrier. + +Delete the per-turn recipient set, callback-to-final mention union/filter, and unused callback routing fields. Keep callback evidence for the existing turn-exit/custody guard and co-creator event ownership. Failed callbacks do not suppress a final handoff; plain final prose does not invent delivery from callback targets. Do not re-dispatch historical101/123. + +Parallel route inspection found no corresponding target set/filter. Its existing contract suppresses ordinary final @ routing; explicit callback admission is independent. Keep that contract and add a regression for a confirmed callback followed by parallel final @ prose. This fix does not change parallel planning, permissions, provider behavior or Queue persistence schema. + +## Red → green and affected checks + +- Nine new real-source scenarios use actual MessageStore, InvocationQueue, lifecycle response admission and production commitCompletedResponseAndEnqueueA2ATargets. Before the change: 4 failures / 5 passes. Four failures precisely reproduce suppressed final mentions after a successful callback, including identical text and duplicate confirmation. +- All nine now pass. Assertions cover exact final source ID, completed lifecycle, production wake commit, independently queued source × target, no duplicated callback row and callback IDs recorded once. No recursive inline target execution. +- Five related API files: 156/156 (routeSerial callback/persistence, route strategies, parallel suppression, callback result parser, completed wake admission). +- Six boundary files: 52/52 (includes four parallel suppression tests, plus phase-H, verdict, callback custody, callback post-message and turn-custody telemetry). Parallel file overlaps the earlier run; these counts are not summed as unique tests. +- Author reran Astra's original independent reproducer against newly compiled feature dist: 1/1; both priorCallback=false and priorCallback=true retain codex. This rerun is author verification, not a new independent verdict. +- Feature API tsc succeeds; dist route-serial.js is newer than source. Biome succeeds with existing warnings; git diff --check succeeds. +- Tests use temporary test HOME, blocked Redis endpoint, in-memory stores and mocked provider streams. No runtime mutation, process restart or live provider invocation. No full gate/UI rerun for this API-only delta; prior UI review remains scoped to6a8. + +## Review/publication continuity + +- Cleanup/tips: approved4f04c36a4b99acf19a747b74bc3d687d501d4f9d/tree352e4d56f5254b1963861a3d9256ac064c9ea441, fact0001791604181900-000105-4a948a51. +- Error display: approved6a8f91618cf1a34a86536218b0f19d177d08c46c/tree63be5b317457d2f828c8846d703db4dd5ff4c78e, typed fact0001791605056758-000160-068add86. It explicitly does not approve this new routing delta. +- Next: exact-head independent review of this API delta, then authorized normal fast-forward of original#1398 source branch and a new fork develop_base incremental PR. No self-merge/restart. Do not reopen merged219 or claim operator soak complete. +- Known prior internal preparation delay and collaborative-content member-availability selector remain recorded separately; this handoff fix does not claim to resolve them. Operator121 conditional retry request stays excluded because no generic failed-response retry endpoint exists. diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/reviewer-reproducer-author-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/reviewer-reproducer-author-green.log.gz new file mode 100644 index 0000000000..204490725a Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/reviewer-reproducer-author-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/route-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/route-green.log.gz new file mode 100644 index 0000000000..889250281d Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/route-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/sha256.json b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/sha256.json new file mode 100644 index 0000000000..52f2b21e86 --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/final-handoff-source-2026-10-10/sha256.json @@ -0,0 +1,9 @@ +{ + "boundary-green.log.gz": "5d154f7b5c78af9c52c617b3e96b7251573e3c16ce46b91cdd4109fd1d6a6f9e", + "build.log.gz": "9ceffb7310338057cfe71a4ae1e2c98d2c485d81cdef906532a801f457a38d64", + "format.log.gz": "de01ee221cb2ebaf36d350c0d7601bc8d14649319ac7288d1164f8663367666c", + "red.log.gz": "f916217dedc7cd5dce035f74be891ceba127b989ed6f02e53a629059a17ae770", + "report.md": "0f60c6f5fe2e65334adfa541fbfcd2d7929b35a98588f5a712b14c27357524a4", + "reviewer-reproducer-author-green.log.gz": "16bfce4d9b6e550d5e6e4f0df676295a484e03029138fdaa445fc4475cc35c1f", + "route-green.log.gz": "7ecc05be25a678ec8292a7736fb37c4303c0545ed58dfef2863a526c653f3bc6" +} diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/build.cjs b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/build.cjs new file mode 100644 index 0000000000..3ad3de170c --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/build.cjs @@ -0,0 +1,60 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const root = process.cwd(); +const esbuild = require(path.join(root, 'node_modules/esbuild')); +const out = '/tmp/f117-guide-preview'; +const cats = [ + ['a', 'A', true], + ['b', 'B', false], + ['c', 'C', true], + ['d', 'D', true], +].map(([id, displayName, guideReply]) => ({ + id, + displayName, + mentionPatterns: [], + clientId: 'codex', + carrier: 'codex_app_server', + defaultModel: 'test', + avatar: '', + color: { primary: '#3f8552', secondary: '#f7f2ea' }, + roleDescription: '', + personality: '', + messageDeliveryCapabilities: { guideReply }, +})); +fs.writeFileSync( + path.join(out, 'entry.tsx'), + `import React from 'react'; import {createRoot} from 'react-dom/client'; import {ChatInput} from '${root}/packages/web/src/components/ChatInput'; import {useChatStore} from '${root}/packages/web/src/stores/chatStore'; +const variant = new URLSearchParams(location.search).get('case')||'mixed'; +const ids = variant==='none'?['b']:variant==='idle'?[]:['a','b','c']; +window.fetch=async()=>new Response(JSON.stringify({productDefault:'next_work',global:null,thread:variant==='next'?'next_work':'continue_current',effective:variant==='next'?'next_work':'continue_current',source:'thread'}),{status:200}); +useChatStore.setState({currentThreadId:'preview',targetCats:ids,hasActiveInvocation:ids.length>0,activeInvocations:Object.fromEntries(ids.map(id=>['inv-'+id,{catId:id,mode:'execute'}])),catInvocations:{}}); +createRoot(document.getElementById('root')!).render(true}/>); +setTimeout(()=>{ const ta=document.querySelector('textarea')!; document.getElementById('receipt')!.textContent=JSON.stringify({variant,placeholder:ta.placeholder,banner:!!document.querySelector('[data-testid="active-invocation-banner"]'),stop:!!document.querySelector('[aria-label="Stop generation"]')}); },1000);`, +); +const plugin = { + name: 'fixture-data', + setup(build) { + build.onResolve({ filter: /^next\/navigation$/ }, () => ({ path: 'navigation', namespace: 'fixture' })); + build.onResolve({ filter: /^@\/hooks\/useCatData$/ }, () => ({ path: 'cats', namespace: 'fixture' })); + build.onLoad({ filter: /.*/, namespace: 'fixture' }, (args) => ({ + contents: + args.path === 'navigation' + ? `export const useRouter=()=>({push(){}}); export const usePathname=()=>'/'; export const useSearchParams=()=>new URLSearchParams();` + : `const cats=${JSON.stringify(cats)}; export const useCatData=()=>({cats,isLoading:false,hasFetched:true,getCatById:id=>cats.find(c=>c.id===id),getCatsByBreed:()=>new Map(),refresh:async()=>cats}); export const formatCatName=cat=>cat.displayName; export const getCachedCats=()=>cats;`, + loader: 'js', + })); + }, +}; +esbuild.build({ + entryPoints: [path.join(out, 'entry.tsx')], + outfile: path.join(out, 'bundle.js'), + bundle: true, + platform: 'browser', + jsx: 'automatic', + absWorkingDir: root, + tsconfig: path.join(root, 'packages/web/tsconfig.json'), + nodePaths: [path.join(root, 'packages/web/node_modules'), path.join(root, 'node_modules')], + define: { 'process.env': '{}', 'process.env.NODE_ENV': '"test"', 'process.env.NEXT_PUBLIC_API_URL': '""' }, + loader: { '.woff': 'dataurl', '.woff2': 'dataurl', '.ttf': 'dataurl' }, + plugins: [plugin], +}); diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/entry.tsx b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/entry.tsx new file mode 100644 index 0000000000..635ef74d5c --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/entry.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { ChatInput } from '/Users/lang/workspace/github-lab/cat-cafe-append-receipt-feedback/packages/web/src/components/ChatInput'; +import { useChatStore } from '/Users/lang/workspace/github-lab/cat-cafe-append-receipt-feedback/packages/web/src/stores/chatStore'; + +const variant = new URLSearchParams(location.search).get('case') || 'mixed'; +const ids = variant === 'none' ? ['b'] : variant === 'idle' ? [] : ['a', 'b', 'c']; +window.fetch = async () => + new Response( + JSON.stringify({ + productDefault: 'next_work', + global: null, + thread: variant === 'next' ? 'next_work' : 'continue_current', + effective: variant === 'next' ? 'next_work' : 'continue_current', + source: 'thread', + }), + { status: 200 }, + ); +useChatStore.setState({ + currentThreadId: 'preview', + targetCats: ids, + hasActiveInvocation: ids.length > 0, + activeInvocations: Object.fromEntries(ids.map((id) => ['inv-' + id, { catId: id, mode: 'execute' }])), + catInvocations: {}, +}); +createRoot(document.getElementById('root')!).render( true} />); +setTimeout(() => { + const ta = document.querySelector('textarea')!; + document.getElementById('receipt')!.textContent = JSON.stringify({ + variant, + placeholder: ta.placeholder, + banner: !!document.querySelector('[data-testid="active-invocation-banner"]'), + stop: !!document.querySelector('[aria-label="Stop generation"]'), + }); +}, 1000); diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/index.html b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/index.html new file mode 100644 index 0000000000..f8870d5301 --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/index.html @@ -0,0 +1 @@ +

\ No newline at end of file
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/mixed-390.png b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/mixed-390.png
new file mode 100644
index 0000000000..e6f9414491
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/mixed-390.png differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/mixed.png b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/mixed.png
new file mode 100644
index 0000000000..83cc36bddf
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/mixed.png differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/next-1440.png b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/next-1440.png
new file mode 100644
index 0000000000..cff22f0e5f
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/next-1440.png differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/none-1440.png b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/none-1440.png
new file mode 100644
index 0000000000..c26d43f5e7
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/none-1440.png differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/receipts.json b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/receipts.json
new file mode 100644
index 0000000000..86b46b4976
--- /dev/null
+++ b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/receipts.json
@@ -0,0 +1,37 @@
+[
+  {
+    "variant": "mixed",
+    "placeholder": "执行发生了偏离?A/C支持引导回复,可继续输入直接发送消息而不中断当前回复",
+    "banner": false,
+    "stop": true,
+    "viewport": "1440,600"
+  },
+  {
+    "variant": "none",
+    "placeholder": "输入消息... (@ 召唤猫猫 · /thread 引用对话)",
+    "banner": false,
+    "stop": true,
+    "viewport": "1440,600"
+  },
+  {
+    "variant": "idle",
+    "placeholder": "输入消息... (@ 召唤猫猫 · /thread 引用对话)",
+    "banner": false,
+    "stop": false,
+    "viewport": "1440,600"
+  },
+  {
+    "variant": "next",
+    "placeholder": "执行发生了偏离?A/C支持引导回复,可继续输入直接发送消息而不中断当前回复",
+    "banner": false,
+    "stop": true,
+    "viewport": "1440,600"
+  },
+  {
+    "variant": "mixed",
+    "placeholder": "执行发生了偏离?A/C支持引导回复,可继续输入直接发送消息而不中断当前回复",
+    "banner": false,
+    "stop": true,
+    "viewport": "390,600"
+  }
+]
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/report.md b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/report.md
new file mode 100644
index 0000000000..b078998863
--- /dev/null
+++ b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/report.md
@@ -0,0 +1,17 @@
+# Active-member guide-reply hint
+
+Sources: operator thread_msr51149hym0i79f messages 0001791603468042-000039-7fa315e0 and 0001791603555843-000044-aa0d9c06. The earlier residual cleanup is commit 25ec5e7f5812012332f113d21be7a120bb1c1d48, based on published 7c87b0f8. Both changes share the original feature checkout and receive one final local review.
+
+## Behavior
+
+Reuse the existing thread-scoped activeCatIds projection, concrete-client messageDeliveryCapabilities.guideReply, and shared display-name formatter. The empty-input hint names only running members with guideReply=true. Missing capability does not count as support; inactive guide-capable members are omitted. When none qualify, use the ordinary/custom placeholder. Private whisper input retains its own placeholder. Operator 0001791603938321-000078-aa972e40 explicitly clarifies that this capability tip is independent of the queue/default sending strategy. Remove the intermediate preference condition from 908d932f; both existing strategies now show the same active-member capability tip. Operator 0001791603845245-000070-788959e8 also corrects the old 下一件工作 terminology. No routing, API, ability declaration, read state or sending preference changes.
+
+Requested example: A/B/C are running, only A/C support guide-reply. Render: 执行发生了偏离?A/C支持引导回复,可继续输入直接发送消息而不中断当前回复. Existing textarea resize now also follows the hint, so an asynchronously loaded long hint is not clipped on mobile. No new status row or control.
+
+## Verification
+
+Three existing Web test files, 28/28: chat-input-message-disposition, chat-input-v2-stop, chat-input-b10-whisper-active. The existing disposition journey now covers mixed capability, inactive capable member and missing/unsupported capability. Stop/send controls are preserved. Biome error-level check and git diff --check pass.
+
+Author browser proof: isolated headless Chromium renders the real worktree ChatInput, actual chat store, real preference hook and compiled worktree stylesheet. Fixture-only member data, router and fetch are inert; no runtime network, Redis, provider request or config change. Build/entry scripts included for traceability; paths identify the author checkout. Desktop mixed/none/idle/排队等待 (same capability hint) and mobile mixed receipt assertions passed. Screenshots mixed.png, none-1440.png, mixed-390.png were inspected; the mobile follow-up specifically rechecks complete text after resize dependency update. No full shell experience or soak claim.
+
+This is a frontend tip optimization plus the already recorded orphan cleanup. The content-modification target-service availability check is explained in the residual report and remains unchanged. Opus requests 079/083 returned session-limit text and supplied no verdict; do not treat either as approval. A different independent individual will cover the corrected final candidate. Independent review, publication and runtime uptake are pending at this author cut.
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/sha256.json b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/sha256.json
new file mode 100644
index 0000000000..cec51b1260
--- /dev/null
+++ b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/sha256.json
@@ -0,0 +1,12 @@
+{
+  "none-1440.png": "c3796d7439d3e4a6970fd01a51affa0667cec66ac0371a8bae7290f66115469a",
+  "index.html": "1a97a584adc21bfc7ddee50ca75b566c2fbc4715f350e6dde10d9eda42fbcfa0",
+  "targeted-tests.log.gz": "b3d5eb05122c84a76edf45805eb66b1998881b91c4dd0ce2766471aa857cb3e1",
+  "next-1440.png": "9c4d75d2053971c275ddfb02104122329850f229f5ed5b3bb9ed12161c73278a",
+  "report.md": "04c40ce08b367f079d1d2e8407b88fc6f13bda401d44e9b1aafa67f68a805314",
+  "receipts.json": "5d7d44cf31681306ddf7f3b9d91aaf40e64e0cc0c843682e1d9a304fa7678b8c",
+  "mixed.png": "a1a8ff831075a239d73049ec4d239f034e7269a8ddc68c621816c922d22760a4",
+  "entry.tsx": "a1292434858857a809d5fd140868ea26321271bac23ed8839276c53e761084ce",
+  "mixed-390.png": "7ef5c0077c7c104b2987f654879333e9b784d84bf6ed7bc8aaaff6c9df8ea22b",
+  "build.cjs": "9c0bee1bc937dd53b63a86cfa8ad2ed24f7869ca6498d5c7278a7f7b406dfb1b"
+}
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/targeted-tests.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/targeted-tests.log.gz
new file mode 100644
index 0000000000..3e286ae3eb
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/guide-reply-hint-2026-10-10/targeted-tests.log.gz differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-api-build.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-api-build.log.gz
new file mode 100644
index 0000000000..86e43ea321
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-api-build.log.gz differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-count-red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-count-red.log.gz
new file mode 100644
index 0000000000..9c3176ae6a
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-count-red.log.gz differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-final-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-final-green.log.gz
new file mode 100644
index 0000000000..920db68840
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-final-green.log.gz differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-shared-build.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-shared-build.log.gz
new file mode 100644
index 0000000000..6eca7683e3
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/f117-cleanup-shared-build.log.gz differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/report.md b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/report.md
new file mode 100644
index 0000000000..400b1965a1
--- /dev/null
+++ b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/report.md
@@ -0,0 +1,27 @@
+# F117 residual code retirement
+
+Authorization: operator `0001791603099297-000023-e1f693f2` asks what the placeholder and content-modification availability check mean, and explicitly asks to clean residual code. Base: published `7c87b0f8ef5fba154fb36a757798012f4e841a72`; fork #219 was already merged as `9e464952788a886a0c89aff1859d72d6bcfca155`, complete tree equivalent.
+
+## Scope and reasons
+
+- Remove the disconnected automatic Signal article lookup, unused InvocationContext/AssemblerInput fields and formatting bridge, D20 resolver/catalog registration, manifest/template, preview entry and generator entry. Both ordinary routing strategies had already stopped producing this context in #218. Active article lookup/read APIs and article storage remain unchanged. Existing test-only D20 firing case retires with the hook; active catalog counts adjust 46→45 and per-turn counts 24→23. No replacement hook, condition or fallback.
+- Remove normal-send routing projection's always-false source-attribution input and unreachable reference emission, and orphan F293 comments. Historical routing source lookup and stored routing signals remain readable; they are not deleted.
+- Remove 12 stale generated files for the deleted owner-attempt/preflight-notice/Signal lookup modules from the owned feature dist only. No runtime output or user data touched.
+- Correct one stale StudyFoldArea comment that claimed the removed automatic field still supplied article context. No UI behavior change.
+
+## Clarifications, not extra feature work
+
+The composer placeholder picks its sentence from `messageDisposition.effective`, a sending preference. The current sentence says an append can avoid interruption without qualifying the recipient capability; real dispatch still resolves the recipient capability. The intended explanation is conditional wording, not another routing policy.
+
+`collaborative-content/modification/target-service.ts` serves the content review modification form. It lists members/threads and preflights `authorize` and return delivery, throwing `target_denied` before requests to predicted-unavailable members. The frontend disables those choices or shows warnings. This is a separate remaining consumer, not fixed by this dead-code cleanup; no claim it should bypass genuine artifact/thread/run ownership checks. No change to that flow in this candidate.
+
+## Validation and ownership
+
+Shared build and API tsc passed. Eleven relevant API files: 291/291, covering resolver catalog, real registry/template assembly, prompt/transport boundary, source attribution and active article storage. Initial post-retirement run had three catalog-count assertion failures (288/291); the original log is preserved and all retired-hook counts were corrected. Biome on the 23 changed JS/TS files and git diff check passed. No live-provider, runtime-data experiment, broad gate, or unchanged Web suite rerun.
+
+Architecture cell: prompt assembly and normal-delivery routing consumer retirement. Map delta: no new owner/writer/read path; remove orphan hook and dead assembly contracts after their producers already retired.
+Canonical source: `packages/api/src/domains/prompt-hooks/resolvers/index.ts#RESOLVER_MAP`, `SystemPromptBuilder.ts#InvocationContext`, `RoutingContext` ordinary-delivery retirement as merged in #219.
+Consumer evidence: repository `rg` for activeSignals/SIGNAL_ARTICLES_BLOCK/createSignalArticleLookup/hasRoutingContextProjection/D20Resolver/d20-signal in packages/api/src, packages/shared/src, assets and scripts has zero matches after retirement; no production factory caller existed before removal.
+Claim guard: existing registry, segment coverage, transport boundary and prompt tests validate all surviving hooks and source-attribution owners. Preserve active Signal article-store tests.
+
+Independent review, publication and merged-runtime uptake of this cleanup remain pending at this author cut. Earlier 709/7c87 verdict and CI do not cover this new candidate. No self-merge or restart.
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/sha256.json b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/sha256.json
new file mode 100644
index 0000000000..17073afebc
--- /dev/null
+++ b/feature-specs/evidence/f117-feedback-2026-10-09/residual-cleanup-2026-10-10/sha256.json
@@ -0,0 +1,18 @@
+[
+  {
+    "file": "f117-cleanup-shared-build.log.gz",
+    "sha256": "dedc1234390b200bd86c03c759a299f6815868ca0ac6240e420d4c5798b2e1a2"
+  },
+  {
+    "file": "f117-cleanup-api-build.log.gz",
+    "sha256": "9ceffb7310338057cfe71a4ae1e2c98d2c485d81cdef906532a801f457a38d64"
+  },
+  {
+    "file": "f117-cleanup-count-red.log.gz",
+    "sha256": "570cfc209aed17111285b7eca4812a40b1badc8896f99f8ac9804a587513d6cc"
+  },
+  {
+    "file": "f117-cleanup-final-green.log.gz",
+    "sha256": "e467470787927dc867e087231d3ef35cd535c9a186e283c20be7d8e97c9e6a29"
+  }
+]
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/browser-receipts.json b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/browser-receipts.json
new file mode 100644
index 0000000000..83ba4684fa
--- /dev/null
+++ b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/browser-receipts.json
@@ -0,0 +1,12 @@
+{
+  "desktop": {
+    "errorCount": 1,
+    "panels": 0,
+    "extraPreserved": true
+  },
+  "mobile": {
+    "errorCount": 1,
+    "panels": 0,
+    "extraPreserved": true
+  }
+}
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/build.cjs b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/build.cjs
new file mode 100644
index 0000000000..2a3e6c634a
--- /dev/null
+++ b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/build.cjs
@@ -0,0 +1,50 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const root = process.cwd();
+const esbuild = require(path.join(root, 'node_modules/esbuild'));
+const out = '/tmp/f117-error-preview';
+const cats = [
+  ['a', 'A', true],
+  ['b', 'B', false],
+  ['c', 'C', true],
+  ['d', 'D', true],
+].map(([id, displayName, guideReply]) => ({
+  id,
+  displayName,
+  mentionPatterns: [],
+  clientId: 'codex',
+  carrier: 'codex_app_server',
+  defaultModel: 'test',
+  avatar: '',
+  color: { primary: '#3f8552', secondary: '#f7f2ea' },
+  roleDescription: '',
+  personality: '',
+  messageDeliveryCapabilities: { guideReply },
+}));
+const plugin = {
+  name: 'fixture-data',
+  setup(build) {
+    build.onResolve({ filter: /^next\/navigation$/ }, () => ({ path: 'navigation', namespace: 'fixture' }));
+    build.onResolve({ filter: /^@\/hooks\/useCatData$/ }, () => ({ path: 'cats', namespace: 'fixture' }));
+    build.onLoad({ filter: /.*/, namespace: 'fixture' }, (args) => ({
+      contents:
+        args.path === 'navigation'
+          ? `export const useRouter=()=>({push(){}}); export const usePathname=()=>'/'; export const useSearchParams=()=>new URLSearchParams();`
+          : `const cats=${JSON.stringify(cats)}; export const useCatData=()=>({cats,isLoading:false,hasFetched:true,getCatById:id=>cats.find(c=>c.id===id),getCatsByBreed:()=>new Map(),refresh:async()=>cats}); export const formatCatName=cat=>cat.displayName; export const getCachedCats=()=>cats;`,
+      loader: 'js',
+    }));
+  },
+};
+esbuild.build({
+  entryPoints: [path.join(out, 'entry.tsx')],
+  outfile: path.join(out, 'bundle.js'),
+  bundle: true,
+  platform: 'browser',
+  jsx: 'automatic',
+  absWorkingDir: root,
+  tsconfig: path.join(root, 'packages/web/tsconfig.json'),
+  nodePaths: [path.join(root, 'packages/web/node_modules'), path.join(root, 'node_modules')],
+  define: { 'process.env': '{}', 'process.env.NODE_ENV': '"test"', 'process.env.NEXT_PUBLIC_API_URL': '""' },
+  loader: { '.woff': 'dataurl', '.woff2': 'dataurl', '.ttf': 'dataurl' },
+  plugins: [plugin],
+});
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/desktop.png b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/desktop.png
new file mode 100644
index 0000000000..55c1f95a89
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/desktop.png differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/entry.tsx b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/entry.tsx
new file mode 100644
index 0000000000..6741fe4e2c
--- /dev/null
+++ b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/entry.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import { ChatMessage } from '/Users/lang/workspace/github-lab/cat-cafe-append-receipt-feedback/packages/web/src/components/ChatMessage';
+import { primeCoCreatorConfigCache } from '/Users/lang/workspace/github-lab/cat-cafe-append-receipt-feedback/packages/web/src/hooks/useCoCreatorConfig';
+import { useChatStore } from '/Users/lang/workspace/github-lab/cat-cafe-append-receipt-feedback/packages/web/src/stores/chatStore';
+
+window.fetch = async () => new Response('{}', { status: 200 });
+primeCoCreatorConfigCache({ name: 'lang', aliases: [], mentionPatterns: [] });
+const error = "You've hit your session limit · resets 11:50pm (America/Los_Angeles)";
+const message = {
+  id: 'response',
+  from: { kind: 'agent', catId: 'opus' },
+  type: 'assistant',
+  catId: 'opus',
+  content: error,
+  timestamp: 1791604122472,
+  lifecycle: {
+    kind: 'response',
+    orderKey: '1:response',
+    invocationId: 'turn',
+    targetId: 'opus',
+    inputEntryIds: ['entry'],
+    inputMessageIds: ['source'],
+    status: 'failed',
+    startedAt: 1791604122400,
+    completedAt: 1791604122472,
+  },
+  extra: {
+    cliDiagnostics: {
+      publicSummary: 'Claude Code 报告:' + error,
+      publicHint: '展开看完整原因;可换一只猫或刷新对话重试。',
+      safeExcerpt: error,
+      excerptSource: 'cc_structured',
+      debugRef: { command: 'claude-agent-sdk', exitCode: 1, signal: null },
+    },
+  },
+};
+const cat = {
+  id: 'opus',
+  displayName: '布偶猫',
+  variantLabel: 'opus',
+  mentionPatterns: [],
+  clientId: 'claude',
+  defaultModel: 'claude-opus-5-5',
+  avatar: '',
+  color: { primary: '#9d86bb', secondary: '#e6ddee' },
+  roleDescription: '',
+  personality: '',
+};
+useChatStore.setState({ currentThreadId: 'preview', messages: [message], threads: [], isLoadingThreads: false });
+createRoot(document.getElementById('root')!).render(
+   cat} />,
+);
+setTimeout(() => {
+  document.getElementById('receipt')!.textContent = JSON.stringify({
+    errorCount: document.getElementById('root')!.textContent!.split(error).length - 1,
+    panels: document.querySelectorAll('[data-testid="cli-diagnostics"], [data-testid="timeout-diagnostics"]').length,
+    extraPreserved: message.extra.cliDiagnostics.safeExcerpt === error,
+  });
+}, 1000);
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/format.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/format.log.gz
new file mode 100644
index 0000000000..e3efe72e00
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/format.log.gz differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/green.log.gz
new file mode 100644
index 0000000000..c84b548760
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/green.log.gz differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/index.html b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/index.html
new file mode 100644
index 0000000000..8524876ac3
--- /dev/null
+++ b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/index.html
@@ -0,0 +1 @@
+

\ No newline at end of file
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/mobile.png b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/mobile.png
new file mode 100644
index 0000000000..61f5536f11
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/mobile.png differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/red.log.gz
new file mode 100644
index 0000000000..4247684267
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/red.log.gz differ
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/report.md b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/report.md
new file mode 100644
index 0000000000..9437817ce2
--- /dev/null
+++ b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/report.md
@@ -0,0 +1,25 @@
+# Response error display — operator feedback 100 / 111
+
+Accepted source: `thread_msr51149hym0i79f#0001791604239991-000111-b1752ff1` (same immutable revision). Original feedback: `0001791604122490-000100-3c8bea8b`.
+
+Operator clarified that the two Opus failures came from two distinct requests. This delta only removes duplicate error presentation; no retry, response grouping, provider, queue or persistence behavior changes.
+
+## Cause and change
+
+Serial and parallel routes already accumulate the complete provider error in the named response body. ChatMessage additionally projected CLI/timeout diagnostics below that same response, repeating its failure as a banner, hint and folded excerpt. Delete the response footer projection and its unused response-only banner switches. The body remains the error surface; persisted `extra` diagnostics remain available. Standalone system failures with no admitted response still use their existing panel. Historic empty terminal responses keep their single lifecycle notice. The list/export diagnostics projection excludes responses, matching the actual rendered surface.
+
+Old response-panel assertions are replaced with the final contract: complete body, no duplicate panels, retained diagnostic metadata, empty historic outcomes and two independent response bodies/anchors. Standalone classification, timeout, excerpt safety and dedup behavior remain covered.
+
+## Verification
+
+- Red: real ChatMessage ownership test, 3 failures / 1 pass against pre-change code, reproducing classified CLI, unknown CLI and timeout duplicates.
+- Green: 7 related Web files, 64/64, including real ChatMessage, lifecycle renderability, visibility, list dedup and export dedup.
+- Standalone CLI panel tests: 15/15 after removing the unused response-only presentation mode.
+- Biome check passes (existing warnings); git diff --check passes.
+- Headless Chromium loaded `file:///tmp/f117-error-preview/index.html`, built from this worktree's actual ChatMessage with inert fixture data/fetch, at 1440×600 and 390×600. Author inspected both screenshots. Each DOM receipt confirms one original error, zero duplicate panels and unchanged diagnostic payload. This is an isolated component preview, not a full shell/provider/runtime/soak acceptance. No service port, runtime data or process touched.
+
+## Continuity and limits
+
+4f04c36a4b99acf19a747b74bc3d687d501d4f9d cleanup/tips approval remains bound to tree 352e4d56f5254b1963861a3d9256ac064c9ea441 and reviewer message 0001791604181900-000105-4a948a51. It does not approve this new error delta. This delta requires its own exact-head peer verdict before publication.
+
+Operator121 optional retry inquiry was independently checked by Astra (message 0001791604492036-000126-659bced1): no generic failed-response manual retry endpoint exists. Per operator's conditional scope, no button or new backend is added. No API/full gate rerun for this frontend-only delta; prior API evidence remains scoped to the earlier cleanup. Overall F117/#1398 operator soak remains open.
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/sha256.json b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/sha256.json
new file mode 100644
index 0000000000..928541babb
--- /dev/null
+++ b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/sha256.json
@@ -0,0 +1,13 @@
+{
+  "browser-receipts.json": "dfa1c4a69ea30497f3e41af1d92042822aca1fa0053264ff38fc70faa7227df6",
+  "build.cjs": "cff9817b56f96b86657b7f47bf63f82307509841142d53a3f8e0e6f329bf2241",
+  "desktop.png": "2069c3276d10ca1d321bbce5e1e2e8faa44228615eb069eab81734b8719870ad",
+  "entry.tsx": "2e0b3627e82a5d3049d6ed4cb9425a6403597e1410097a67c5edc89f1c61a57a",
+  "format.log.gz": "5895c6afc0295d70649c42a5bd303d1fe5259d583606c2a052359b985f107d06",
+  "green.log.gz": "cd8b03fbda8d776387f288a9ee309126f73ab49d2f20ea9b03db8011244a58f6",
+  "index.html": "842400fb2c4ba5a4c66094824c5700997ac2a27f082aaa31ddb36800e6d37ec3",
+  "mobile.png": "a16d3a3af8c600329d02ee1d012c05a876140ac4ae3ab13e9487344c3263dcb6",
+  "red.log.gz": "f6ba9250500b0afc52ec73782d99dd7dc89d680e4deab8d12022b172b026c40e",
+  "report.md": "67a283bbd81f1d30463607ff9ab63fe70cfc679ecd31b34c9d4b6d3f137677f4",
+  "standalone-panel.log.gz": "8beb39171061e658b1733d4fe12724c5056af04ed90aae61bc37518414c86bfb"
+}
diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/standalone-panel.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/standalone-panel.log.gz
new file mode 100644
index 0000000000..0ebc9a9543
Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/response-error-2026-10-10/standalone-panel.log.gz differ
diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts
index e475de98b3..1e5ffb9913 100644
--- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts
+++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts
@@ -259,7 +259,6 @@ export function requestGenerationMessageSourceRefs(input: {
   readonly injectSystemPrompt: boolean;
   readonly hasContextHint: boolean;
   readonly hasStagingPrepend: boolean;
-  readonly hasRoutingContextProjection?: boolean;
   readonly hasMissionPrefix: boolean;
 }): RequestGenerationSourceRef[] {
   const refs: RequestGenerationSourceRef[] = [
@@ -275,9 +274,6 @@ export function requestGenerationMessageSourceRefs(input: {
       ? [{ owner: 'runtime_context' as const, ref: `context-management-hint:${input.invocationId}` }]
       : []),
     ...(input.hasStagingPrepend ? [{ owner: 'system_prompt' as const, ref: 'staging:adr-038' }] : []),
-    ...(input.hasRoutingContextProjection
-      ? [{ owner: 'runtime_context' as const, ref: `routing-context:${input.invocationId}` }]
-      : []),
     ...(input.hasMissionPrefix ? [{ owner: 'home_state' as const, ref: `thread-mission:${input.threadId}` }] : []),
     { owner: 'runtime_context', ref: `transcript-path-hints:${input.threadId}` },
   ];
@@ -1113,7 +1109,6 @@ export interface InvocationDeps {
   readonly collectiveContext?: () =>
     | import('../../../../plugin/builtin-runtime/collective-current-context.js').CollectiveCurrentContext
     | undefined;
-  /** F293: fresh owner-scoped sparse routing projection resolved for every provider generation. */
   /** F296 B3b-1: single owner of context epoch and cold/hot mode for provider-bound invocations. */
   readonly contextEpochOwner?: Pick;
   /** Live project-hook auth readiness required before Claude can own a compaction sequence. */
@@ -3719,7 +3714,6 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP
                 injectSystemPrompt,
                 hasContextHint: Boolean(contextHintPrefix),
                 hasStagingPrepend: Boolean(stagingPrepend),
-                hasRoutingContextProjection: false,
                 hasMissionPrefix: Boolean(missionPrefix),
               });
               return {
diff --git a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts
index 4e37d4ee49..c54ad67d44 100644
--- a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts
+++ b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts
@@ -511,9 +511,6 @@ export interface AgentRouterOptions {
   claudeCompactionHooks?: ClaudeCompactionHooksFactory;
   /** F296 B3b-2: shared provider-presentation delivery ledger. */
   presentationLedger?: PresentationLedger;
-  /** F293: owner-scoped sparse routing projection consumed by provider generation. */
-  /** F293: shared actual-send preflight over the same resolver/catalog graph. */
-  /** F293: durable dispatch terminal observer over the same routing signal graph. */
   /** F276: terminal disposition authority for person-memory write opportunities. */
   writeOpportunityTerminalLedger?: import('../invocation/invoke-single-cat.js').InvocationDeps['writeOpportunityTerminalLedger'];
   /** F276: reservation and delivery authority for person-memory write opportunities. */
diff --git a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts
index e4a9132600..29f0363643 100644
--- a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts
+++ b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts
@@ -119,7 +119,6 @@ export interface RouteStrategyDeps {
   services: Record;
   unavailableServices?: ReadonlyMap;
   invocationDeps: InvocationDeps;
-  /** F293: fresh advisory/rejection decision at each actual child boundary. */
   messageStore: IMessageStore;
   deliveryCursorStore?: DeliveryCursorStore;
   /** #80: Streaming draft persistence store */
diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts
index 5fec9a138f..f3144cb0c1 100644
--- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts
+++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts
@@ -350,7 +350,6 @@ function isCallbackContentRoutingToolName(toolName: string | undefined): boolean
 export type CallbackContentRoutingState = {
   scope: 'local' | 'target';
   guardLineStartMentions: CatId[];
-  localLineStartMentions: CatId[];
   hasGuardCoCreatorLineStartMention: boolean;
   hasLocalCoCreatorLineStartMention: boolean;
   hasTargetCoCreatorLineStartMention: boolean;
@@ -359,8 +358,6 @@ export type CallbackContentRoutingState = {
 type CallbackContentRoutingExit = CallbackContentRoutingState & {
   toolName: string;
   toolUseId?: string;
-  targetCatIds: CatId[];
-  createsCustodyHandoff: boolean;
 };
 
 export function classifyCallbackContentRoutingState(
@@ -374,7 +371,6 @@ export function classifyCallbackContentRoutingState(
     return {
       scope,
       guardLineStartMentions: [],
-      localLineStartMentions: [],
       hasGuardCoCreatorLineStartMention: false,
       hasLocalCoCreatorLineStartMention: false,
       hasTargetCoCreatorLineStartMention: false,
@@ -389,7 +385,6 @@ export function classifyCallbackContentRoutingState(
   return {
     scope,
     guardLineStartMentions,
-    localLineStartMentions: scope === 'local' ? guardLineStartMentions : [],
     hasGuardCoCreatorLineStartMention: hasCoCreatorLineStartMention,
     hasLocalCoCreatorLineStartMention: scope === 'local' && hasCoCreatorLineStartMention,
     hasTargetCoCreatorLineStartMention: scope === 'target' && hasCoCreatorLineStartMention,
@@ -405,15 +400,9 @@ function collectCallbackContentRoutingExit(
   const content = readToolInputContent(toolInput);
   const state = classifyCallbackContentRoutingState(toolName, content, currentCatId);
   if (!state) return null;
-  const targetCatIds = [
-    ...new Set([...collectStructuredTargetCatsFromInput(toolInput), ...state.guardLineStartMentions]),
-  ].map(createCatId);
-  const createsCustodyHandoff = state.scope === 'target' && buildCrossThreadNoObligationWake(toolInput) === undefined;
   return {
     toolName,
     ...(toolUseId ? { toolUseId } : {}),
-    targetCatIds,
-    createsCustodyHandoff,
     ...state,
   };
 }
@@ -1570,8 +1559,6 @@ export async function* routeSerial(
       const verifiedConciergeToolTargets = new VerifiedConciergeToolTargetCollector();
       const pendingCallbackRoutingExits: CallbackContentRoutingExit[] = [];
       const confirmedCallbackRoutingGuardMentions = new Set();
-      const confirmedLocalCallbackRoutingMentions = new Set();
-      const confirmedLocalCallbackRoutedTargets = new Set();
       let confirmedCallbackRoutingGuardHasCoCreatorLineStartMention = false;
       let confirmedLocalCallbackRoutingHasCoCreatorLineStartMention = false;
       const emittedBallHandedCvoMessageIds = new Set();
@@ -1798,14 +1785,6 @@ export async function* routeSerial(
         const [exit] = pendingCallbackRoutingExits.splice(exitIndex, 1);
         if (!confirmed || !exit) return undefined;
         for (const mention of exit.guardLineStartMentions) confirmedCallbackRoutingGuardMentions.add(mention);
-        for (const mention of exit.localLineStartMentions) confirmedLocalCallbackRoutingMentions.add(mention);
-        if (exit.scope === 'local') {
-          // The callback already admitted this source/target through InvocationQueue or the
-          // legacy worklist. Never reinterpret the same persisted callback body as a fresh
-          // serial handoff after that carrier has already completed and disappeared from the
-          // live queue: that callback+text-scan fork caused the exact A→B duplicate dogfood.
-          for (const targetCatId of exit.targetCatIds) confirmedLocalCallbackRoutedTargets.add(targetCatId);
-        }
         if (exit.hasGuardCoCreatorLineStartMention) confirmedCallbackRoutingGuardHasCoCreatorLineStartMention = true;
         if (exit.hasLocalCoCreatorLineStartMention) confirmedLocalCallbackRoutingHasCoCreatorLineStartMention = true;
         return exit;
@@ -1813,10 +1792,6 @@ export async function* routeSerial(
       const getRoutingExitLineStartMentions = (textMentions: readonly CatId[] = []): CatId[] => [
         ...new Set([...textMentions, ...confirmedCallbackRoutingGuardMentions]),
       ];
-      const getLocalRoutingLineStartMentions = (textMentions: readonly CatId[] = []): CatId[] =>
-        [...new Set([...textMentions, ...confirmedLocalCallbackRoutingMentions])].filter(
-          (targetCatId) => !confirmedLocalCallbackRoutedTargets.has(targetCatId),
-        );
       const hasRoutingExitCoCreatorLineStartMention = (content: string): boolean =>
         Boolean(
           (content ? detectUserMention(content) : false) || confirmedCallbackRoutingGuardHasCoCreatorLineStartMention,
@@ -2551,7 +2526,6 @@ export async function* routeSerial(
         verifiedConciergeToolTargets.reset();
         pendingCallbackRoutingExits.splice(0, pendingCallbackRoutingExits.length);
         confirmedCallbackRoutingGuardMentions.clear();
-        confirmedLocalCallbackRoutingMentions.clear();
         confirmedCallbackRoutingGuardHasCoCreatorLineStartMention = false;
         confirmedLocalCallbackRoutingHasCoCreatorLineStartMention = false;
         callbackPosts.reset();
@@ -3066,7 +3040,9 @@ export async function* routeSerial(
             }),
         );
         await scheduleTurnCustodyStopGate(textLegacyObservedBlock);
-        a2aMentions = getLocalRoutingLineStartMentions(a2aMentions);
+        // This response is its own durable source. Callback exits can satisfy the
+        // turn guard, but their targets belong to their callback message, not this
+        // response. Its explicit mentions must survive earlier posts to the same cat.
         if (
           malformedRelayTarget &&
           !worklist.slice(index + 1).includes(malformedRelayTarget) &&
diff --git a/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts b/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts
index 7a16c6886e..229665894e 100644
--- a/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts
+++ b/packages/api/src/domains/cats/services/context/SystemPromptBuilder.ts
@@ -133,25 +133,6 @@ export interface InvocationContext {
     readonly suggestedSkillSource?: string;
     readonly featureId: string;
   };
-  /**
-   * F091: Active Signal articles in discussion context.
-   * Injected when co-creator links a Signal article in the thread.
-   */
-  activeSignals?: readonly {
-    readonly id: string;
-    readonly title: string;
-    readonly source: string;
-    readonly tier: number;
-    readonly contentSnippet: string;
-    readonly note?: string | undefined;
-    readonly relatedDiscussions?:
-      | readonly {
-          readonly sessionId: string;
-          readonly snippet: string;
-          readonly score: number;
-        }[]
-      | undefined;
-  }[];
   /**
    * F092: Voice companion mode.
    * When true, cats should prioritize audio rich blocks for spoken output.
diff --git a/packages/api/src/domains/cats/services/context/prompt-template-loader.ts b/packages/api/src/domains/cats/services/context/prompt-template-loader.ts
index 52bb6f5534..10ada68cf5 100644
--- a/packages/api/src/domains/cats/services/context/prompt-template-loader.ts
+++ b/packages/api/src/domains/cats/services/context/prompt-template-loader.ts
@@ -244,7 +244,6 @@ const TEMPLATE_FILES: Record = {
   D17: { base: 'd17-guide-candidate.md', local: '' },
   D18: { base: 'd18-world-context.md', local: '' },
   D19: { base: 'd19-constitutional-knowledge.md', local: '' },
-  D20: { base: 'd20-signal-articles.md', local: '' },
 };
 
 /**
diff --git a/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts b/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts
index 07889a92bf..abffd3e474 100644
--- a/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts
+++ b/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts
@@ -2038,15 +2038,6 @@ export interface IMessageStore {
    * Returns null only when the message is not found.
    */
   markCanceled(id: string): MarkCanceledResult | null | Promise;
-  /**
-   * Atomic content-dedup claim. Returns true if this fingerprint was newly claimed
-   * (caller should proceed to append) or false if an identical claim is still live within
-   * the window (caller must treat the post as a duplicate). Closes the check-then-act race
-   * in the callback exact-duplicate scan: two concurrent byte-identical posts can both pass
-   * the recent-message read before either appends, so the append decision needs an atomic
-   * gate. In-memory: synchronous Map check+set (atomic within the event loop). Redis: SET NX PX.
-   */
-  claimContentDedupKey(key: string, ttlMs: number): boolean | Promise;
   /** #697: Find message IDs with a given deliveryStatus. Used by StartupReconciler
    *  to recover orphaned queued messages after process restart. */
   scanByDeliveryStatus?(status: NonNullable): string[] | Promise;
@@ -2237,8 +2228,6 @@ export class MessageStore {
   }
   private readonly maxMessages: number;
   private readonly idempotencyIndex = new Map();
-  /** Content-dedup claims: fingerprint key → expiry timestamp (ms). Bounds the callback exact-duplicate race. */
-  private readonly contentDedupIndex = new Map();
   /** F102 KD-34: Listener called after every successful append (fire-and-forget) */
   onAppend?: MessageAppendListener;
 
@@ -3537,26 +3526,6 @@ export class MessageStore {
   // in production Redis. StartupReconciler's guard `if (!messageStore?.scanByDeliveryStatus)`
   // gracefully skips orphan recovery for in-memory mode. (LL-048 / PR #805 P2-2)
 
-  /**
-   * Atomic content-dedup claim (synchronous — atomic within the single-threaded event loop).
-   * Returns true on first claim within the window, false if an identical claim is still live.
-   */
-  claimContentDedupKey(key: string, ttlMs: number): boolean {
-    const now = Date.now();
-    const existing = this.contentDedupIndex.get(key);
-    if (existing !== undefined && existing > now) {
-      return false;
-    }
-    this.contentDedupIndex.set(key, now + ttlMs);
-    // Opportunistic prune so the index stays bounded under sustained traffic.
-    if (this.contentDedupIndex.size > 2048) {
-      for (const [k, exp] of this.contentDedupIndex) {
-        if (exp <= now) this.contentDedupIndex.delete(k);
-      }
-    }
-    return true;
-  }
-
   /**
    * Current message count (for testing)
    */
diff --git a/packages/api/src/domains/cats/services/stores/redis-keys/message-keys.ts b/packages/api/src/domains/cats/services/stores/redis-keys/message-keys.ts
index f43f55b5d2..ff257aff3e 100644
--- a/packages/api/src/domains/cats/services/stores/redis-keys/message-keys.ts
+++ b/packages/api/src/domains/cats/services/stores/redis-keys/message-keys.ts
@@ -22,9 +22,6 @@ export const MessageKeys = {
   /** Idempotency index: msg:idem:{userId}:{threadId}:{key} -> messageId */
   idempotency: (userId: string, threadId: string, key: string) => `msg:idem:${userId}:${threadId}:${key}`,
 
-  /** Callback content-dedup claim (race-safe exact-duplicate gate): msg:cbdedup:{fingerprint} */
-  contentDedup: (fingerprint: string) => `msg:cbdedup:${fingerprint}`,
-
   /** F264 Gap F: TTL=0 owner+thread composer draft. */
   ownerComposerDraft: (ownerUserId: string, threadId: string) =>
     `msg:composer-draft:${encodeURIComponent(ownerUserId)}:${encodeURIComponent(threadId)}`,
diff --git a/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts b/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts
index f5395ed440..44790a921d 100644
--- a/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts
+++ b/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts
@@ -3638,22 +3638,6 @@ export class RedisMessageStore {
     return { ...message, deliveryTransitioned: true };
   }
 
-  /**
-   * Atomic content-dedup claim via SET NX PX. Returns true on first claim within the window,
-   * false if an identical claim is still live (concurrent or recent byte-identical post). This
-   * is the race-safe gate for the callback exact-duplicate scan.
-   */
-  async claimContentDedupKey(key: string, ttlMs: number): Promise {
-    const claimed = await this.redis.set(
-      MessageKeys.contentDedup(key),
-      '1',
-      'PX',
-      Math.max(1, Math.floor(ttlMs)),
-      'NX',
-    );
-    return claimed === 'OK';
-  }
-
   /**
    * #697: Scan for message IDs matching a given deliveryStatus.
    * Uses SCAN + pipeline HGET pattern (same as InvocationRecordStore.scanByStatus).
diff --git a/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts b/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts
index 8421c4438f..e791d9adfb 100644
--- a/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts
+++ b/packages/api/src/domains/prompt-hooks/PipelinePromptBuilder.ts
@@ -30,9 +30,9 @@ import { RESOLVER_MAP } from './resolvers/index.js';
 // The pipeline executes ALL hooks for a stage (producing full trace events),
 // but prompt output is filtered to match legacy scope for backward compat.
 //
-// Legacy architecture splits 46 hooks across multiple injection points:
+// Legacy architecture splits 45 active hooks across multiple injection points:
 //   buildStaticIdentity → S1-S13 only
-//   buildInvocationContext → D1-D21 only
+//   buildInvocationContext → D1-D19, D21 only (D20 retired)
 //   L0 compiler → L1-L7 (separate channel for native providers)
 //   route-serial/parallel → R1-R2
 //   route-helpers → N1
@@ -44,7 +44,7 @@ import { RESOLVER_MAP } from './resolvers/index.js';
 // ---------------------------------------------------------------------------
 
 const SCOPE_S = /^S\d/; // S1-S13: buildStaticIdentity
-const SCOPE_D = /^D\d/; // D1-D21: buildInvocationContext
+const SCOPE_D = /^D\d/; // D1-D19, D21: buildInvocationContext
 
 // ---------------------------------------------------------------------------
 // Singleton pipeline (lazy init on first call)
@@ -217,7 +217,7 @@ export function buildInvocationContextViaHookPipelineWithTrace(context: Invocati
  * Equivalent to legacy `buildSystemPrompt()`.
  *
  * Unlike the scoped builders above, this produces the FULL unfiltered pipeline
- * output — all 46 hooks. Use when the pipeline IS the single source (future).
+ * output — all 45 active hooks. Use when the pipeline IS the single source (future).
  *
  * @returns Combined prompt string with trace results for observability.
  */
diff --git a/packages/api/src/domains/prompt-hooks/assemble-bridge.ts b/packages/api/src/domains/prompt-hooks/assemble-bridge.ts
index 3a93e6fa3f..54726420de 100644
--- a/packages/api/src/domains/prompt-hooks/assemble-bridge.ts
+++ b/packages/api/src/domains/prompt-hooks/assemble-bridge.ts
@@ -30,7 +30,6 @@ import {
   flattenWorldContext,
   formatAlwaysOnDocs,
   formatHandleFreeLabel,
-  formatSignalsBlock,
   PROVIDER_LABELS,
   resolveActiveParticipants,
   resolveDirectMessage,
@@ -145,7 +144,6 @@ export function assembleForSession(catId: CatId, options?: StaticIdentityOptions
     conciergeLines: null,
     worldContext: null,
     alwaysOnDocsBlock: null,
-    activeSignalsBlock: null,
     a2aBallCheckContent: null,
     handoffDecisionTreeContent: null,
   };
@@ -219,8 +217,6 @@ export function assembleForTurn(context: InvocationContext): AssemblerInput {
     worldContext: context.worldContext ? flattenWorldContext(context.worldContext) : null,
     alwaysOnDocsBlock:
       context.alwaysOnDocs && context.alwaysOnDocs.length > 0 ? formatAlwaysOnDocs(context.alwaysOnDocs) : null,
-    activeSignalsBlock:
-      context.activeSignals && context.activeSignals.length > 0 ? formatSignalsBlock(context.activeSignals) : null,
     a2aBallCheckContent: shouldA2A ? loadA2aBallCheck() || null : null,
     handoffDecisionTreeContent: null,
   };
diff --git a/packages/api/src/domains/prompt-hooks/context-assembler.ts b/packages/api/src/domains/prompt-hooks/context-assembler.ts
index 73904357b3..103db7e0af 100644
--- a/packages/api/src/domains/prompt-hooks/context-assembler.ts
+++ b/packages/api/src/domains/prompt-hooks/context-assembler.ts
@@ -146,37 +146,6 @@ function flattenWorldContext(wc: WorldContextEnvelope): WorldContextInput {
   };
 }
 
-// ---------------------------------------------------------------------------
-// Signal articles block formatting
-// ---------------------------------------------------------------------------
-
-function formatSignalsBlock(
-  signals: readonly {
-    id: string;
-    title: string;
-    source: string;
-    tier: number;
-    contentSnippet: string;
-    note?: string;
-    relatedDiscussions?: readonly { sessionId: string; snippet: string }[];
-  }[],
-): string {
-  return signals
-    .map((s) => {
-      const parts = [`### [${s.id}] ${s.title} (${s.source}/T${s.tier})`];
-      if (s.note) parts.push(`Note: ${s.note}`);
-      parts.push(s.contentSnippet);
-      if (s.relatedDiscussions && s.relatedDiscussions.length > 0) {
-        parts.push('Related past discussions:');
-        for (const d of s.relatedDiscussions) {
-          parts.push(`- [session:${d.sessionId}] ${d.snippet}`);
-        }
-      }
-      return parts.join('\n');
-    })
-    .join('\n');
-}
-
 // ---------------------------------------------------------------------------
 // Teammates resolution
 // ---------------------------------------------------------------------------
@@ -243,7 +212,6 @@ export {
   flattenWorldContext,
   formatAlwaysOnDocs,
   formatHandleFreeLabel,
-  formatSignalsBlock,
   resolveActiveParticipants,
   resolveDirectMessage,
   resolveModel,
diff --git a/packages/api/src/domains/prompt-hooks/resolvers/index.ts b/packages/api/src/domains/prompt-hooks/resolvers/index.ts
index 2752c3bd49..d68f47dbf9 100644
--- a/packages/api/src/domains/prompt-hooks/resolvers/index.ts
+++ b/packages/api/src/domains/prompt-hooks/resolvers/index.ts
@@ -1,7 +1,7 @@
 /**
  * Resolver Registry — F237 Phase 2-B
  *
- * Maps hookId → resolver instance. All 46 Tier 1 pipeline hooks.
+ * Maps hookId → resolver instance. All 45 active Tier 1 pipeline hooks.
  * Resolvers are stateless singletons — safe for concurrent invocations.
  */
 
@@ -54,7 +54,6 @@ import {
   D17Resolver,
   D18Resolver,
   D19Resolver,
-  D20Resolver,
   D21Resolver,
   N1Resolver,
   R1Resolver,
@@ -110,7 +109,6 @@ const RESOLVER_MAP: ReadonlyMap = new Map Promise {
-  const paths = resolveSignalPaths();
-  const studyMeta = new StudyMetaService();
-
-  return async (threadId: string): Promise => {
-    const allRecords = await readInboxRecords(paths, undefined);
-    const matched: ActiveSignalArticle[] = [];
-
-    for (const record of allRecords) {
-      if (matched.length >= MAX_ACTIVE_SIGNALS) break;
-      const meta = await studyMeta.readMeta(record.id, record.filePath);
-      const hasThread = meta.threads.some((t) => t.threadId === threadId && !t.stale);
-      if (!hasThread) continue;
-
-      try {
-        const detail = await readArticleDocument(record);
-        if (!detail) continue;
-        const article = detail.article;
-        if (article.deletedAt) continue;
-
-        // AC-10: Search session history for related discussions
-        let relatedDiscussions: DiscussionSnippet[] | undefined;
-        if (deps?.transcriptReader) {
-          try {
-            const hits = await deps.transcriptReader.search(threadId, article.title, {
-              limit: MAX_DISCUSSION_HITS,
-              scope: 'digests',
-            });
-            if (hits.length > 0) {
-              relatedDiscussions = hits.map((h) => ({
-                sessionId: h.sessionId,
-                snippet: h.snippet,
-                score: h.score,
-              }));
-            }
-          } catch {
-            // Best-effort: memory search failure doesn't block context injection
-          }
-        }
-
-        matched.push({
-          id: article.id,
-          title: article.title,
-          source: article.source,
-          tier: article.tier,
-          contentSnippet: detail.content.slice(0, CONTENT_SNIPPET_LENGTH),
-          note: article.note,
-          ...(relatedDiscussions ? { relatedDiscussions } : {}),
-        });
-      } catch {
-        // Skip unreadable articles
-      }
-    }
-
-    return matched;
-  };
-}
diff --git a/packages/api/src/routes/callback-a2a-trigger.ts b/packages/api/src/routes/callback-a2a-trigger.ts
index 8f1a75fc59..05932dc3e9 100644
--- a/packages/api/src/routes/callback-a2a-trigger.ts
+++ b/packages/api/src/routes/callback-a2a-trigger.ts
@@ -120,6 +120,10 @@ export async function appendA2ASourceWithLedgerAdmission(
 ): Promise {
   if (!deps.messageStore) throw new Error('A2A source admission requires MessageStore');
   if (options.plan.acceptedTargetCats.length === 0) {
+    if (message.idempotencyKey) {
+      const result = await deps.messageStore.appendIdempotent(message);
+      return { message: result.message, preAdmittedReplayed: result.idempotent };
+    }
     return { message: await deps.messageStore.append(message) };
   }
   if (!deps.invocationQueue) throw new Error('A2A source admission requires InvocationQueue');
diff --git a/packages/api/src/routes/callback-multi-mention-routes.ts b/packages/api/src/routes/callback-multi-mention-routes.ts
index 57c9fda5a7..e7d237a851 100644
--- a/packages/api/src/routes/callback-multi-mention-routes.ts
+++ b/packages/api/src/routes/callback-multi-mention-routes.ts
@@ -237,7 +237,6 @@ export interface MultiMentionRouteDeps {
     ActionSuccessorAdmissionService,
     'admit' | 'markUnavailable' | 'markReturnedDelivered'
   >;
-  /** F293: fresh routing policy at the actual Queue admission boundary. */
   /** F122B B6: InvocationQueue for unified dispatch */
   invocationQueue?: A2ATriggerDeps['invocationQueue'] &
     Pick;
diff --git a/packages/api/src/routes/callback-scope-helpers.ts b/packages/api/src/routes/callback-scope-helpers.ts
index 3ebc9d1073..89f05f40f5 100644
--- a/packages/api/src/routes/callback-scope-helpers.ts
+++ b/packages/api/src/routes/callback-scope-helpers.ts
@@ -30,11 +30,8 @@ export function deriveCallbackActor(record: InvocationRecord): CallbackActor {
   };
 }
 
-/**
- * #573: identity used for cross-handler broadcast/persistence dedup.
- * QueueProcessor:761 broadcasts agent_message with parent (outer) id; route-serial
- * persists with parent (outer) id. Callback path must use the same to keep the
- * frontend's `(catId, invocationId)` dedup contract intact across stream + callback.
+/** Parent execution scope for queue, cancellation and callback attribution.
+ * Callback messages and final responses retain independent message/source identities.
  */
 export function effectiveInvocationId(actor: Pick): string {
   return actor.parentInvocationId ?? actor.invocationId;
diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts
index c177fd68d7..164e7dd16d 100644
--- a/packages/api/src/routes/callbacks.ts
+++ b/packages/api/src/routes/callbacks.ts
@@ -275,7 +275,6 @@ import { clearVoteTimer, closeVoteInternal, voteTimers } from './votes.js';
 import { publishDispatchProposal } from './wave2-proposal-publication.js';
 
 const log = createModuleLogger('routes/callbacks');
-const CALLBACK_EXACT_DUPLICATE_WINDOW_MS = 5_000;
 
 async function readBoundedUnreadContext(input: {
   readonly messageStore: Pick;
@@ -313,9 +312,8 @@ async function readBoundedUnreadContext(input: {
  * Mirrors authoritative server parser at a2a-mentions.ts:86-114
  * (strip code fences, trimStart, strip markdown prefix, startsWith('@')).
  *
- * Used by AC-A4 fail-closed gate which MUST run BEFORE
- * registry.claimClientMessageId (Codex review P1 round 2 close — otherwise
- * malformed retries permanently consume idempotency keys).
+ * Used by AC-A4 validation before persistence. Invalid requests must leave
+ * their message identity available for a corrected retry.
  *
  * Local mirror (no cross-package dep) — server parser remains authoritative;
  * this is just a raw caller-input signal: did the caller TRY to provide
@@ -545,7 +543,7 @@ function sameCoordinationForCallbackDedup(
   return coordinationDedupKey === 'action-active-root' && !storedKey && !readStoredCoordination(msg);
 }
 
-type CallbackDuplicateCandidateStore = IMessageStore & {
+type CallbackHistoryStore = IMessageStore & {
   getByThreadIncludingQueued?: (
     threadId: string,
     limit?: number,
@@ -553,13 +551,13 @@ type CallbackDuplicateCandidateStore = IMessageStore & {
   ) => StoredMessage[] | Promise;
 };
 
-async function getRecentCallbackDuplicateCandidates(
+async function getCallbackHistoryIncludingQueued(
   messageStore: IMessageStore,
   threadId: string,
   userId: string,
   limit = 20,
 ): Promise {
-  const store = messageStore as CallbackDuplicateCandidateStore;
+  const store = messageStore as CallbackHistoryStore;
   if (typeof store.getByThreadIncludingQueued === 'function') {
     return store.getByThreadIncludingQueued(threadId, limit, userId);
   }
@@ -598,12 +596,18 @@ function hasAnyLocalReviewAnchor(input: LocalReviewAnchorInput): boolean {
   return Boolean(input.reviewSubjectRef || input.acceptedSourceRef || input.acceptedRevision);
 }
 
+/** Message identity is scoped by the authenticated sender; text never identifies a retry. */
+function buildCallbackMessageIdempotencyKey(catId: string, clientMessageId: string): string {
+  const digest = createHash('sha256').update(clientMessageId).digest('hex');
+  return `callback:post:${catId}:${digest}`;
+}
+
 function buildLocalReviewFactMessageIdempotencyKey(catId: string, clientMessageId: string): string {
   const digest = createHash('sha256').update(clientMessageId).digest('hex');
   return `callback:local-review:${catId}:${digest}`;
 }
 
-function isExactCallbackDuplicate(
+function matchesCallbackPayload(
   msg: StoredMessage,
   input: {
     catId: string;
@@ -637,39 +641,6 @@ function isExactCallbackDuplicate(
   return sameCoordinationForCallbackDedup(msg, input.coordination, input.coordinationDedupKey);
 }
 
-async function findRecentExactCallbackDuplicate(
-  messageStore: IMessageStore,
-  input: {
-    threadId: string;
-    userId: string;
-    catId: string;
-    content: string;
-    richBlocks?: readonly RichBlock[] | undefined;
-    mentions: readonly CatId[];
-    mentionsUser?: boolean | undefined;
-    replyTo?: string | undefined;
-    isExplicitPost?: boolean | undefined;
-    coordination?: CrossThreadCoordination | undefined;
-    coordinationDedupKey?: CallbackCoordinationDedupKey | undefined;
-    localReviewVerdict?: LocalReviewVerdict | undefined;
-    reviewedHeadSha?: string | undefined;
-    reviewSubjectRef?: string | undefined;
-    acceptedSourceRef?: string | undefined;
-    acceptedRevision?: string | undefined;
-    now: number;
-  },
-): Promise {
-  const recent = await getRecentCallbackDuplicateCandidates(messageStore, input.threadId, input.userId);
-  for (let i = recent.length - 1; i >= 0; i -= 1) {
-    const msg = recent[i];
-    if (!msg) continue;
-    const ts = msg.deliveredAt ?? msg.timestamp;
-    if (input.now - ts > CALLBACK_EXACT_DUPLICATE_WINDOW_MS) continue;
-    if (isExactCallbackDuplicate(msg, input)) return msg;
-  }
-  return undefined;
-}
-
 type CallbackContentProjection =
   | { ok: true; content: string }
   | {
@@ -729,53 +700,6 @@ async function projectCallbackContentForStorage(
   }
 }
 
-/**
- * Stable fingerprint over the exact dimensions findRecentExactCallbackDuplicate compares
- * (thread/user/cat/content/richBlocks/replyTo/isExplicitPost/mentionsUser/mentions). Used as the key for the atomic
- * content-dedup claim that closes the check-then-act race in the duplicate scan. Hashed so
- * the key stays bounded regardless of message length.
- */
-function buildCallbackContentDedupFingerprint(input: {
-  threadId: string;
-  userId: string;
-  catId: string;
-  content: string;
-  richBlocks?: readonly RichBlock[] | undefined;
-  mentions: readonly CatId[];
-  mentionsUser?: boolean | undefined;
-  replyTo?: string | undefined;
-  isExplicitPost?: boolean | undefined;
-  coordination?: CrossThreadCoordination | undefined;
-  coordinationDedupKey?: CallbackCoordinationDedupKey | undefined;
-  localReviewVerdict?: LocalReviewVerdict | undefined;
-  reviewedHeadSha?: string | undefined;
-  reviewSubjectRef?: string | undefined;
-  acceptedSourceRef?: string | undefined;
-  acceptedRevision?: string | undefined;
-}): string {
-  const parts = [
-    input.threadId,
-    input.userId,
-    input.catId,
-    input.replyTo ?? '',
-    input.isExplicitPost ? '1' : '0',
-    input.mentionsUser ? '1' : '0',
-    [...input.mentions].join(','),
-    input.content,
-    richBlocksFingerprintPart(input.richBlocks),
-    input.localReviewVerdict ?? '',
-    input.reviewedHeadSha ?? '',
-    input.reviewSubjectRef ?? '',
-    input.acceptedSourceRef ?? '',
-    input.acceptedRevision ?? '',
-    input.coordinationDedupKey === 'action-active-root'
-      ? ''
-      : (input.coordinationDedupKey ??
-        (input.coordination ? `${input.coordination.id}:${input.coordination.phase}:${input.coordination.hop}` : '')),
-  ].join('\u0000');
-  return createHash('sha256').update(parts).digest('hex');
-}
-
 async function commitCloudReturnGrantAfterPersistence(input: {
   store: Pick;
   claim: import('../domains/cats/services/cloud-bridge/cloud-return-grant.js').CloudReturnGrantClaim;
@@ -800,96 +724,6 @@ async function commitCloudReturnGrantAfterPersistence(input: {
   }
 }
 
-type CallbackDuplicateResponse = {
-  status: 'duplicate';
-  threadId: string;
-  messageId?: string;
-  replyTo?: string;
-  clientMessageId?: string;
-};
-
-/**
- * Atomic content-dedup gate shared by the invocation-auth and agent-key post-message paths.
- * findRecentExactCallbackDuplicate is check-then-act (read recent → append later), so two
- * concurrent byte-identical deliveries can both pass it before either appends. This claims the
- * content fingerprint atomically: the winner gets null (proceed to append); a loser (or any
- * recent identical post within the window) gets a 'duplicate' response without a second append —
- * closing the byte-identical duplicate-message race. Returns null when routing warnings are
- * present, matching the existing scan's gate.
- */
-async function claimCallbackContentOrDuplicate(
-  messageStore: IMessageStore,
-  input: {
-    threadId: string;
-    userId: string;
-    catId: string;
-    content: string;
-    richBlocks?: readonly RichBlock[] | undefined;
-    mentions: readonly CatId[];
-    mentionsUser?: boolean | undefined;
-    replyTo?: string | undefined;
-    isExplicitPost?: boolean | undefined;
-    coordination?: CrossThreadCoordination | undefined;
-    coordinationDedupKey?: CallbackCoordinationDedupKey | undefined;
-    localReviewVerdict?: LocalReviewVerdict | undefined;
-    reviewedHeadSha?: string | undefined;
-    reviewSubjectRef?: string | undefined;
-    acceptedSourceRef?: string | undefined;
-    acceptedRevision?: string | undefined;
-    clientMessageId?: string | undefined;
-    now: number;
-    hasRoutingWarnings: boolean;
-  },
-): Promise {
-  if (input.hasRoutingWarnings) return null;
-  const fingerprint = buildCallbackContentDedupFingerprint({
-    threadId: input.threadId,
-    userId: input.userId,
-    catId: input.catId,
-    content: input.content,
-    ...(input.richBlocks && input.richBlocks.length > 0 ? { richBlocks: input.richBlocks } : {}),
-    mentions: input.mentions,
-    ...(input.mentionsUser ? { mentionsUser: input.mentionsUser } : {}),
-    ...(input.replyTo ? { replyTo: input.replyTo } : {}),
-    isExplicitPost: Boolean(input.isExplicitPost),
-    ...(input.coordination ? { coordination: input.coordination } : {}),
-    ...(input.coordinationDedupKey ? { coordinationDedupKey: input.coordinationDedupKey } : {}),
-    ...(input.localReviewVerdict ? { localReviewVerdict: input.localReviewVerdict } : {}),
-    ...(input.reviewedHeadSha ? { reviewedHeadSha: input.reviewedHeadSha } : {}),
-    ...(input.reviewSubjectRef ? { reviewSubjectRef: input.reviewSubjectRef } : {}),
-    ...(input.acceptedSourceRef ? { acceptedSourceRef: input.acceptedSourceRef } : {}),
-    ...(input.acceptedRevision ? { acceptedRevision: input.acceptedRevision } : {}),
-  });
-  const claimed = await messageStore.claimContentDedupKey(fingerprint, CALLBACK_EXACT_DUPLICATE_WINDOW_MS);
-  if (claimed) return null;
-  const raced = await findRecentExactCallbackDuplicate(messageStore, {
-    threadId: input.threadId,
-    userId: input.userId,
-    catId: input.catId,
-    content: input.content,
-    ...(input.richBlocks && input.richBlocks.length > 0 ? { richBlocks: input.richBlocks } : {}),
-    mentions: input.mentions,
-    ...(input.mentionsUser ? { mentionsUser: input.mentionsUser } : {}),
-    ...(input.replyTo ? { replyTo: input.replyTo } : {}),
-    isExplicitPost: Boolean(input.isExplicitPost),
-    ...(input.coordination ? { coordination: input.coordination } : {}),
-    ...(input.coordinationDedupKey ? { coordinationDedupKey: input.coordinationDedupKey } : {}),
-    ...(input.localReviewVerdict ? { localReviewVerdict: input.localReviewVerdict } : {}),
-    ...(input.reviewedHeadSha ? { reviewedHeadSha: input.reviewedHeadSha } : {}),
-    ...(input.reviewSubjectRef ? { reviewSubjectRef: input.reviewSubjectRef } : {}),
-    ...(input.acceptedSourceRef ? { acceptedSourceRef: input.acceptedSourceRef } : {}),
-    ...(input.acceptedRevision ? { acceptedRevision: input.acceptedRevision } : {}),
-    now: input.now,
-  });
-  return {
-    status: 'duplicate',
-    threadId: input.threadId,
-    ...(raced ? { messageId: raced.id } : {}),
-    ...(input.replyTo ? { replyTo: input.replyTo } : {}),
-    ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}),
-  };
-}
-
 function hasQueuedA2AEntryForMessage(
   invocationQueue: InvocationQueue | undefined,
   threadId: string,
@@ -1028,7 +862,6 @@ export interface CallbackRoutesOptions {
   agentRegistry?: { getAllEntries(): Map };
   /** For post_message @mention → invocation triggering */
   router?: AgentRouter;
-  /** F293: shared per-target decision before callback dispatch admission. */
   invocationRecordStore?: IInvocationRecordStore;
   /** Durable child lifecycle truth. InvocationRegistry remains callback auth only. */
   turnExecutionStore?: Pick;
@@ -1906,7 +1739,7 @@ export const callbacksRoutes: FastifyPluginAsync = async
       const localReviewDuplicateMatches = (message: StoredMessage) =>
         Boolean(
           localReviewFactInput &&
-            isExactCallbackDuplicate(message, {
+            matchesCallbackPayload(message, {
               catId: principal.catId,
               content: persistedContent,
               ...(richBlocks.length > 0 ? { richBlocks } : {}),
@@ -1930,23 +1763,14 @@ export const callbacksRoutes: FastifyPluginAsync = async
           clientMessageId,
         };
       }
-      const duplicateMsg =
-        durableCloudReturnDuplicate ??
-        durableLocalReviewDuplicate ??
-        (!localReviewVerdict && routing_warnings.length === 0
-          ? await findRecentExactCallbackDuplicate(messageStore, {
-              threadId: effectiveThreadId,
-              userId: principal.userId,
-              catId: principal.catId,
-              content: persistedContent,
-              ...(richBlocks.length > 0 ? { richBlocks } : {}),
-              mentions,
-              ...(mentionsUser ? { mentionsUser } : {}),
-              ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}),
-              isExplicitPost: true,
-              now,
-            })
-          : undefined);
+      const callbackMessageIdempotencyKey =
+        clientMessageId && !usesServerGrant && !localReviewVerdict
+          ? buildCallbackMessageIdempotencyKey(principal.catId, clientMessageId)
+          : undefined;
+      const durableCallbackDuplicate = callbackMessageIdempotencyKey
+        ? await messageStore.getByIdempotencyKey(principal.userId, effectiveThreadId, callbackMessageIdempotencyKey)
+        : null;
+      const duplicateMsg = durableCloudReturnDuplicate ?? durableLocalReviewDuplicate ?? durableCallbackDuplicate;
       const recoverPersistedCallbackMessage = async (persisted: StoredMessage) => {
         const persistedMentions = [...persisted.mentions];
         const persistedReplyTo = persisted.replyTo;
@@ -2012,34 +1836,6 @@ export const callbacksRoutes: FastifyPluginAsync = async
       };
       if (duplicateMsg) return recoverPersistedCallbackMessage(duplicateMsg);
 
-      // Server-custodied returns use a durable exact-source idempotency key in
-      // the message store. Consuming the short-lived agent-key key would make
-      // retryable grant/store failures unrecoverable before any append exists.
-      if (clientMessageId && agentKeyRegistry && !usesServerGrant && !localReviewVerdict) {
-        const isFirst = await agentKeyRegistry.claimClientMessageId(principal.agentKeyId, clientMessageId);
-        if (!isFirst) {
-          return { status: 'duplicate', replyTo, clientMessageId };
-        }
-      }
-
-      if (!usesServerGrant && !localReviewVerdict) {
-        const agentKeyContentDuplicate = await claimCallbackContentOrDuplicate(messageStore, {
-          threadId: effectiveThreadId,
-          userId: principal.userId,
-          catId: principal.catId,
-          content: persistedContent,
-          ...(richBlocks.length > 0 ? { richBlocks } : {}),
-          mentions,
-          ...(mentionsUser ? { mentionsUser } : {}),
-          ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}),
-          isExplicitPost: true,
-          ...(clientMessageId ? { clientMessageId } : {}),
-          now,
-          hasRoutingWarnings: routing_warnings.length > 0,
-        });
-        if (agentKeyContentDuplicate) return agentKeyContentDuplicate;
-      }
-
       let a2aAdmissionPlan: A2AFanoutAdmissionPlan | undefined;
 
       const a2aAdmissionOptions = hasA2AMentions
@@ -2083,8 +1879,13 @@ export const callbacksRoutes: FastifyPluginAsync = async
         timestamp: now,
         ...(extra ? { extra } : {}),
         ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}),
-        ...(cloudReturnMessageIdempotencyKey || localReviewFactMessageIdempotencyKey
-          ? { idempotencyKey: cloudReturnMessageIdempotencyKey ?? localReviewFactMessageIdempotencyKey }
+        ...(cloudReturnMessageIdempotencyKey || localReviewFactMessageIdempotencyKey || callbackMessageIdempotencyKey
+          ? {
+              idempotencyKey:
+                cloudReturnMessageIdempotencyKey ??
+                localReviewFactMessageIdempotencyKey ??
+                callbackMessageIdempotencyKey,
+            }
           : {}),
       };
       let atomicAdmission: Awaited>;
@@ -2304,9 +2105,8 @@ export const callbacksRoutes: FastifyPluginAsync = async
     // #814: a post_message is always its own message, never a replacement for a final.
     const standaloneExplicitPostExtra = { isExplicitPost: true as const };
     const { invocationId } = actor;
-    // #573: identity for cross-handler dedup. stream + callback for same logical
-    // response must broadcast/persist with the same id; QueueProcessor + route-serial
-    // use the parent (outer) id, so callback aligns to it.
+    // Attribute callbacks to the parent execution scope. Each callback and final
+    // response keeps its own durable message/source identity.
     const effectiveInvId = effectiveInvocationId(actor);
 
     const invocationIsLatest = await registry.isLatest(invocationId);
@@ -2426,12 +2226,8 @@ export const callbacksRoutes: FastifyPluginAsync = async
       return deletedThreadGuard.body;
     }
 
-    // F193 AC-A4: cross-post fail-closed BEFORE idempotency claim.
-    // Closes Codex review P1 round 2 (2026-05-08): if AC-A4 reject runs AFTER
-    // claimClientMessageId, a malformed first attempt permanently consumes
-    // the idempotency key — corrected retry with the same key is then
-    // treated as `duplicate` and silently dropped. Validation errors must
-    // not be terminal for clients that correctly reuse the same key.
+    // F193 AC-A4: validate the cross-post before persistence. Invalid requests
+    // leave the clientMessageId available for a corrected retry.
     //
     // Raw caller-input check (does NOT depend on contentAnalysis which runs
     // later) — see hasPlausibleLineStartMention() docstring. Server-side
@@ -3088,7 +2884,7 @@ export const callbacksRoutes: FastifyPluginAsync = async
       }
     }
 
-    // Content-shape rejection must precede the idempotency claim so a corrected
+    // Content-shape rejection must precede persistence so a corrected
     // retry can reuse its clientMessageId. Buffered blocks are intentionally not
     // admission evidence: a navigation action must be carried by this callback.
     const contentProjection = await projectCallbackContentForStorage(
@@ -3104,36 +2900,6 @@ export const callbacksRoutes: FastifyPluginAsync = async
       reply.status(contentProjection.statusCode);
       return { kind: contentProjection.kind, message: contentProjection.message };
     }
-    // At-least-once de-duplication: retries with same clientMessageId are treated as duplicate.
-    if (clientMessageId && !localReviewVerdict) {
-      const isFirstSeen = await registry.claimClientMessageId(invocationId, clientMessageId);
-      if (!isFirstSeen && !interruptedActionCarrierRecoveryKey) {
-        if (actionFence && actionCarrierDisposition === 'return') {
-          const accepted = hasQueuedActionSuccessorFence(
-            opts.invocationQueue,
-            effectiveThreadId,
-            actor.userId,
-            actionFence,
-          );
-          await reconcileActionSuccessorEnqueue({
-            service: opts.actionSuccessorAdmissionService,
-            fence: actionFence,
-            disposition: actionCarrierDisposition,
-            unavailableCatIds: accepted ? [] : actionHolderCatIds,
-            now: Date.now(),
-          });
-        } else if (actionFence && actionAdmissionOutcome !== 'replayed') {
-          await opts.actionSuccessorAdmissionService?.markUnavailable({
-            fence: actionFence,
-            holderCatIds: actionHolderCatIds,
-            evidenceRef: `callback:${invocationId}:${clientMessageId}:duplicate`,
-            now: Date.now(),
-          });
-        }
-        return { status: 'duplicate', replyTo, clientMessageId };
-      }
-    }
-
     // Attach blocks to this callback message without closing the invocation.
     // The routing final append closes it; later blocks must remain writable until then.
     const bufferedBlocks = getRichBlockBuffer().consume(effectiveThreadId, actor.catId as string, invocationId, {
@@ -3237,12 +3003,9 @@ export const callbacksRoutes: FastifyPluginAsync = async
     if (suppressTerminalRouting && mergedTargets.size > 0) {
       throw new Error('terminal ACK projection cannot suppress explicit routing targets');
     }
-    const hasDedupBlockingRoutingWarnings = routing_warnings.length > 0;
+    const hasRoutingWarnings = routing_warnings.length > 0;
     const mentions: CatId[] = [...mergedTargets];
-    if (
-      localReviewVerdict &&
-      (mentions.length !== 1 || mentions[0] === senderCatId || hasDedupBlockingRoutingWarnings)
-    ) {
+    if (localReviewVerdict && (mentions.length !== 1 || mentions[0] === senderCatId || hasRoutingWarnings)) {
       reply.status(400);
       return {
         kind: 'invalid_review_fact',
@@ -3396,7 +3159,7 @@ export const callbacksRoutes: FastifyPluginAsync = async
     const localReviewDuplicateMatches = (message: StoredMessage) =>
       Boolean(
         localReviewFactInput &&
-          isExactCallbackDuplicate(message, {
+          matchesCallbackPayload(message, {
             catId: actor.catId,
             content: persistedContent,
             ...(richBlocks.length > 0 ? { richBlocks } : {}),
@@ -3427,7 +3190,7 @@ export const callbacksRoutes: FastifyPluginAsync = async
       : null;
     if (
       durableActionCarrierDuplicate &&
-      !isExactCallbackDuplicate(durableActionCarrierDuplicate, {
+      !matchesCallbackPayload(durableActionCarrierDuplicate, {
         catId: actor.catId,
         content: persistedContent,
         ...(richBlocks.length > 0 ? { richBlocks } : {}),
@@ -3447,32 +3210,26 @@ export const callbacksRoutes: FastifyPluginAsync = async
         clientMessageId,
       };
     }
-    const duplicateMsg =
-      durableLocalReviewDuplicate ??
-      durableActionCarrierDuplicate ??
-      (!localReviewVerdict && !hasDedupBlockingRoutingWarnings && !interruptedActionCarrierRecoveryKey
-        ? await findRecentExactCallbackDuplicate(messageStore, {
-            threadId: effectiveThreadId,
-            userId: actor.userId,
-            catId: actor.catId,
-            content: persistedContent,
-            ...(richBlocks.length > 0 ? { richBlocks } : {}),
-            mentions,
-            ...(mentionsUser ? { mentionsUser } : {}),
-            ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}),
-            isExplicitPost: true,
-            ...(coordinationResult.coordination ? { coordination: coordinationResult.coordination } : {}),
-            ...(coordinationDedupKey ? { coordinationDedupKey } : {}),
-            now,
-          })
-        : undefined);
+    const callbackMessageIdempotencyKey =
+      clientMessageId && !localReviewVerdict && !interruptedActionCarrierRecoveryKey
+        ? buildCallbackMessageIdempotencyKey(actor.catId, clientMessageId)
+        : undefined;
+    const durableCallbackDuplicate = callbackMessageIdempotencyKey
+      ? await messageStore.getByIdempotencyKey(actor.userId, effectiveThreadId, callbackMessageIdempotencyKey)
+      : null;
+    const duplicateMsg = durableLocalReviewDuplicate ?? durableActionCarrierDuplicate ?? durableCallbackDuplicate;
     const recoverPersistedCallbackMessage = async (duplicateMsg: StoredMessage) => {
       const newlyClaimedActionLease = Boolean(actionFence && actionAdmissionOutcome !== 'replayed');
       let recoveredDuplicateCarrier = false;
       if (!newlyClaimedActionLease) {
         recoveredDuplicateCarrier = await ensureDuplicateCallbackWake({
           duplicateMsg,
-          canEnqueueA2A: !!(hasA2AMentions && opts.invocationQueue),
+          canEnqueueA2A: !!(
+            duplicateMsg.mentions.length > 0 &&
+            router &&
+            invocationRecordStore &&
+            opts.invocationQueue
+          ),
           ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}),
           threadId: effectiveThreadId,
           userId: actor.userId,
@@ -3490,8 +3247,8 @@ export const callbacksRoutes: FastifyPluginAsync = async
                 log: app.log,
               },
               {
-                targetCats: mentions,
-                content: storedContent,
+                targetCats: [...duplicateMsg.mentions],
+                content: duplicateMsg.content,
                 userId: actor.userId,
                 threadId: effectiveThreadId,
                 triggerMessage: duplicateMsg,
@@ -3564,7 +3321,7 @@ export const callbacksRoutes: FastifyPluginAsync = async
         status: 'duplicate',
         threadId: effectiveThreadId,
         messageId: duplicateMsg.id,
-        ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}),
+        ...(duplicateMsg.replyTo ? { replyTo: duplicateMsg.replyTo } : {}),
         ...(clientMessageId ? { clientMessageId } : {}),
       };
     };
@@ -3610,38 +3367,6 @@ export const callbacksRoutes: FastifyPluginAsync = async
         },
       );
     }
-    // Race-safe backstop: the exact-duplicate scan above is check-then-act, so an atomic content
-    // claim makes the at-most-once decision (root cause of the byte-identical duplicate bug).
-    const contentDuplicate =
-      localReviewVerdict || interruptedActionCarrierRecoveryKey
-        ? null
-        : await claimCallbackContentOrDuplicate(messageStore, {
-            threadId: effectiveThreadId,
-            userId: actor.userId,
-            catId: actor.catId,
-            content: persistedContent,
-            ...(richBlocks.length > 0 ? { richBlocks } : {}),
-            mentions,
-            ...(mentionsUser ? { mentionsUser } : {}),
-            ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}),
-            isExplicitPost: true,
-            ...(coordinationResult.coordination ? { coordination: coordinationResult.coordination } : {}),
-            ...(coordinationDedupKey ? { coordinationDedupKey } : {}),
-            ...(clientMessageId ? { clientMessageId } : {}),
-            now,
-            hasRoutingWarnings: hasDedupBlockingRoutingWarnings,
-          });
-    if (contentDuplicate) {
-      if (actionFence && actionAdmissionOutcome !== 'replayed' && actionCarrierDisposition !== 'return') {
-        await opts.actionSuccessorAdmissionService?.markUnavailable({
-          fence: actionFence,
-          holderCatIds: actionHolderCatIds,
-          evidenceRef: `callback:${invocationId}:${clientMessageId}:content_duplicate`,
-          now: Date.now(),
-        });
-      }
-      return contentDuplicate;
-    }
     const appendInput: AppendMessageInput = {
       from: { kind: 'agent', catId: actor.catId },
       userId: actor.userId,
@@ -3653,8 +3378,13 @@ export const callbacksRoutes: FastifyPluginAsync = async
       threadId: effectiveThreadId,
       extra: persistedExtra,
       ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}),
-      ...(interruptedActionCarrierRecoveryKey || localReviewFactMessageIdempotencyKey
-        ? { idempotencyKey: interruptedActionCarrierRecoveryKey ?? localReviewFactMessageIdempotencyKey }
+      ...(interruptedActionCarrierRecoveryKey || localReviewFactMessageIdempotencyKey || callbackMessageIdempotencyKey
+        ? {
+            idempotencyKey:
+              interruptedActionCarrierRecoveryKey ??
+              localReviewFactMessageIdempotencyKey ??
+              callbackMessageIdempotencyKey,
+          }
         : {}),
     };
     let atomicAdmission: Awaited>;
@@ -4557,7 +4287,7 @@ export const callbacksRoutes: FastifyPluginAsync = async
     // complete review history. Read one item beyond the bounded history window;
     // if it fills, warn-open instead of asserting a fourth-arrival decision.
     const localReviewHistoryMessages = hasLocalReviewFact
-      ? await getRecentCallbackDuplicateCandidates(messageStore, effectiveThreadId, principalUserId, 201)
+      ? await getCallbackHistoryIncludingQueued(messageStore, effectiveThreadId, principalUserId, 201)
       : [];
     const localReviewHistoryComplete = localReviewHistoryMessages.length < 201;
     const durableLocalReviewHistory = localReviewHistoryComplete
diff --git a/packages/api/src/routes/prompt-injection-manifest.ts b/packages/api/src/routes/prompt-injection-manifest.ts
index 9ab761cf57..cdd773229d 100644
--- a/packages/api/src/routes/prompt-injection-manifest.ts
+++ b/packages/api/src/routes/prompt-injection-manifest.ts
@@ -1,7 +1,7 @@
 /**
  * Prompt Injection Manifest Route — F237 Phase 2
  *
- * GET /api/prompt-injection/manifest — aggregate 46 hook.yaml manifests
+ * GET /api/prompt-injection/manifest — aggregate 45 active hook.yaml manifests
  * into the ManifestSegment[] shape the Console frontend expects.
  *
  * Replaces the old monolithic assets/prompt-injection-manifest.yaml
diff --git a/packages/api/src/routes/prompt-injection-preview.ts b/packages/api/src/routes/prompt-injection-preview.ts
index 10466117fa..704588be9e 100644
--- a/packages/api/src/routes/prompt-injection-preview.ts
+++ b/packages/api/src/routes/prompt-injection-preview.ts
@@ -160,9 +160,6 @@ export const promptInjectionPreviewRoutes: FastifyPluginAsync = async (app) => {
         '── [D19] Constitutional 知识(条件:always_on 文档)──',
         tpl('D19'),
         '',
-        '── [D20] Signal 文章(条件:线程关联文章)──',
-        tpl('D20'),
-        '',
         '── [D21] 传球决策树(条件:A2A 非并行模式)──',
         tpl('D21'),
         '',
diff --git a/packages/api/test/callback-cross-post-fail-closed.test.js b/packages/api/test/callback-cross-post-fail-closed.test.js
index ed2232acc9..3c74864c2d 100644
--- a/packages/api/test/callback-cross-post-fail-closed.test.js
+++ b/packages/api/test/callback-cross-post-fail-closed.test.js
@@ -294,7 +294,7 @@ describe('F193 AC-A4: cross-post fail-closed when no routing credentials', () =>
   });
 
   test('codex P1 round 2 (2026-05-08): AC-A4 reject does not consume clientMessageId — corrected retry delivers', async () => {
-    // Closes Codex P1 round 2: AC-A4 reject MUST run before claimClientMessageId.
+    // AC-A4 validation must leave a corrected retry with the same message ID admissible.
     // Otherwise a malformed first attempt permanently consumes the idempotency
     // key, and the corrected retry with same key gets `duplicate` (silent drop).
     const app = await createApp();
diff --git a/packages/api/test/callback-message-identity.test.js b/packages/api/test/callback-message-identity.test.js
new file mode 100644
index 0000000000..bda83d721d
--- /dev/null
+++ b/packages/api/test/callback-message-identity.test.js
@@ -0,0 +1,191 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import Fastify from 'fastify';
+import './helpers/setup-cat-registry.js';
+import { AgentKeyRegistry } from '../dist/domains/cats/services/agents/agent-key/AgentKeyRegistry.js';
+import {
+  InvocationQueue,
+  queueEntryTargetCats,
+} from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js';
+import { InvocationRegistry } from '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js';
+import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js';
+import { ThreadStore } from '../dist/domains/cats/services/stores/ports/ThreadStore.js';
+import { appendA2ASourceWithLedgerAdmission } from '../dist/routes/callback-a2a-trigger.js';
+import { callbacksRoutes } from '../dist/routes/callbacks.js';
+
+async function harness(t, transport) {
+  const registry = new InvocationRegistry();
+  const agentKeyRegistry = new AgentKeyRegistry();
+  const messageStore = new MessageStore();
+  const invocationQueue = new InvocationQueue();
+  const threadStore = new ThreadStore();
+  const from = await threadStore.create('identity-owner', 'source');
+  const to = await threadStore.create('identity-owner', 'destination');
+  threadStore.addParticipants(to.id, ['opus', 'codex']);
+  const auth = await registry.create('identity-owner', 'opus', from.id);
+  const key = await agentKeyRegistry.issue('opus', 'identity-owner');
+  const headers =
+    transport === 'invocation'
+      ? { 'x-invocation-id': auth.invocationId, 'x-callback-token': auth.callbackToken }
+      : { 'x-agent-key-secret': key.secret };
+  const broadcasts = [];
+  const app = Fastify();
+  t.after(() => app.close());
+  await app.register(callbacksRoutes, {
+    registry,
+    agentKeyRegistry,
+    messageStore,
+    invocationQueue,
+    threadStore,
+    socketManager: {
+      broadcastAgentMessage(message) {
+        broadcasts.push(message);
+      },
+      emitToUser() {},
+    },
+    router: {},
+    invocationRecordStore: {},
+    queueProcessor: {
+      async requestDrain() {},
+      async tryAutoAppendExactEntry() {
+        return { outcome: 'rejected' };
+      },
+      registerCallerDispatchInitialTargets() {},
+    },
+  });
+  const payload = { threadId: to.id, targetCats: ['codex'], content: 'same explicit cross-thread notice' };
+  const post = (id, extra = {}) =>
+    app.inject({
+      method: 'POST',
+      url: '/api/callbacks/post-message',
+      headers,
+      payload: { ...payload, clientMessageId: id, ...extra },
+    });
+  return { app, registry, agentKeyRegistry, messageStore, invocationQueue, to, post, broadcasts, payload, auth, key };
+}
+
+for (const transport of ['invocation', 'agent-key']) {
+  test(`${transport}: same-ID concurrent A2A retry has one durable source and one target row`, async (t) => {
+    const h = await harness(t, transport);
+    const responses = await Promise.all([h.post('source-a'), h.post('source-a')]);
+    assert.deepEqual(responses.map((r) => r.json().status).sort(), ['duplicate', 'ok']);
+    assert.equal(responses[0].json().messageId, responses[1].json().messageId);
+    assert.equal(h.messageStore.size, 1);
+    const entries = h.invocationQueue.list(h.to.id, 'identity-owner');
+    assert.equal(entries.length, 1);
+    assert.equal(entries[0].payload.messageId, responses[0].json().messageId);
+    assert.equal(h.broadcasts.filter((m) => m.type === 'text').length, 1);
+  });
+
+  test(`${transport}: different-ID concurrent A2A notices retain two sources and two target rows`, async (t) => {
+    const h = await harness(t, transport);
+    const responses = await Promise.all([h.post('source-a'), h.post('source-b')]);
+    assert.deepEqual(
+      responses.map((r) => r.json().status),
+      ['ok', 'ok'],
+    );
+    const ids = responses.map((r) => r.json().messageId);
+    assert.equal(new Set(ids).size, 2);
+    assert.equal(h.messageStore.size, 2);
+    assert.deepEqual(
+      h.invocationQueue
+        .list(h.to.id, 'identity-owner')
+        .map((e) => e.payload.messageId)
+        .sort(),
+      ids.sort(),
+    );
+  });
+
+  test(`${transport}: failed atomic admission does not consume the message ID`, async (t) => {
+    const h = await harness(t, transport);
+    const realAdmission = h.invocationQueue.appendAndEnqueueDurable.bind(h.invocationQueue);
+    let fail = true;
+    h.invocationQueue.appendAndEnqueueDurable = async (...args) => {
+      if (fail) {
+        fail = false;
+        throw new Error('isolated transaction failure');
+      }
+      return realAdmission(...args);
+    };
+    assert.equal((await h.post('source-recover')).statusCode, 500);
+    assert.equal(h.messageStore.size, 0);
+    assert.equal(h.invocationQueue.list(h.to.id, 'identity-owner').length, 0);
+    const recovered = await h.post('source-recover');
+    assert.equal(recovered.json().status, 'ok');
+    const replay = await h.post('source-recover');
+    assert.equal(replay.json().status, 'duplicate');
+    assert.equal(replay.json().messageId, recovered.json().messageId);
+    assert.equal(h.messageStore.size, 1);
+    assert.equal(h.invocationQueue.list(h.to.id, 'identity-owner').length, 1);
+  });
+
+  test(`${transport}: replay uses original recipients instead of the replacement request`, async (t) => {
+    const h = await harness(t, transport);
+    const first = await h.post('original-recipient');
+    const replay = await h.post('original-recipient', { targetCats: ['bengal'], content: 'replacement body' });
+    assert.equal(replay.json().status, 'duplicate');
+    assert.equal(replay.json().messageId, first.json().messageId);
+    const entries = h.invocationQueue.list(h.to.id, 'identity-owner');
+    assert.equal(entries.length, 1);
+    assert.deepEqual(queueEntryTargetCats(entries[0]), ['codex']);
+    assert.equal(h.messageStore.getById(first.json().messageId).content, h.payload.content);
+  });
+}
+
+test('one durable source survives retry through another authenticated callback transport', async (t) => {
+  const h = await harness(t, 'invocation');
+  const first = await h.post('transport-stable-source');
+  const replay = await h.app.inject({
+    method: 'POST',
+    url: '/api/callbacks/post-message',
+    headers: { 'x-agent-key-secret': h.key.secret },
+    payload: { ...h.payload, clientMessageId: 'transport-stable-source' },
+  });
+  assert.equal(replay.json().status, 'duplicate');
+  assert.equal(replay.json().messageId, first.json().messageId);
+  assert.equal(h.messageStore.size, 1);
+  assert.equal(h.invocationQueue.list(h.to.id, 'identity-owner').length, 1);
+});
+
+test('different authenticated senders can independently use the same client message ID', async (t) => {
+  const h = await harness(t, 'invocation');
+  const first = await h.post('sender-local-source');
+  const other = await h.agentKeyRegistry.issue('bengal', 'identity-owner');
+  const second = await h.app.inject({
+    method: 'POST',
+    url: '/api/callbacks/post-message',
+    headers: { 'x-agent-key-secret': other.secret },
+    payload: { ...h.payload, clientMessageId: 'sender-local-source' },
+  });
+  assert.equal(first.json().status, 'ok');
+  assert.equal(second.json().status, 'ok');
+  assert.notEqual(first.json().messageId, second.json().messageId);
+  assert.equal(h.messageStore.size, 2);
+});
+
+test('an admission with no accepted recipients still commits and replays the exact source once', async () => {
+  const messageStore = new MessageStore();
+  const message = {
+    from: { kind: 'agent', catId: 'opus' },
+    userId: 'identity-owner',
+    threadId: 'no-accepted-targets',
+    content: 'public source with exhausted fanout',
+    mentions: ['codex'],
+    timestamp: Date.now(),
+    idempotencyKey: 'callback-empty-admission-source',
+  };
+  const options = {
+    plan: { requestedTargetCats: ['codex'], acceptedTargetCats: [], streakTargetCats: [] },
+    ownerAuthProvenance: 'unknown',
+  };
+  const results = await Promise.all([
+    appendA2ASourceWithLedgerAdmission({ messageStore }, message, options),
+    appendA2ASourceWithLedgerAdmission({ messageStore }, message, options),
+  ]);
+  assert.deepEqual(
+    results.map((r) => r.preAdmittedReplayed),
+    [false, true],
+  );
+  assert.equal(results[0].message.id, results[1].message.id);
+  assert.equal(messageStore.size, 1);
+});
diff --git a/packages/api/test/callback-routes-agent-key.test.js b/packages/api/test/callback-routes-agent-key.test.js
index 6c7e5a9962..33876d8182 100644
--- a/packages/api/test/callback-routes-agent-key.test.js
+++ b/packages/api/test/callback-routes-agent-key.test.js
@@ -338,48 +338,102 @@ describe('Callback routes: agent-key auth path', () => {
     assert.equal(messages[1].extra?.rich, undefined);
   });
 
-  // Regression (byte-identical duplicate bug): the shared Antigravity MCP posts via this agent-key
-  // path. Two concurrent identical deliveries must not both persist. Forces the check-then-act
-  // interleave by holding the winner's append open until the second request has run its dup check.
-  test('agent-key post-message does not double-store byte-identical concurrent posts (atomic dedup)', async () => {
+  test('agent-key callback concurrent same-ID retries share one durable message', async () => {
     const app = await createApp();
     const { secret } = await issueKey();
-
-    const realAppend = messageStore.append.bind(messageStore);
-    let releaseFirstAppend;
-    const firstAppendGate = new Promise((resolve) => {
-      releaseFirstAppend = resolve;
-    });
-    let signalFirstAppendEntered;
-    const firstAppendEntered = new Promise((resolve) => {
-      signalFirstAppendEntered = resolve;
-    });
-    let appendCount = 0;
-    messageStore.append = async (msg) => {
-      appendCount += 1;
-      if (appendCount === 1) {
-        signalFirstAppendEntered();
-        await firstAppendGate;
+    const headers = { 'x-agent-key-secret': secret };
+    const realAppendIdempotent = messageStore.appendIdempotent.bind(messageStore);
+    let releaseFirst;
+    const gate = new Promise((resolve) => {
+      releaseFirst = resolve;
+    });
+    let enteredFirst;
+    const entered = new Promise((resolve) => {
+      enteredFirst = resolve;
+    });
+    let calls = 0;
+    messageStore.appendIdempotent = async (input) => {
+      if (++calls === 1) {
+        enteredFirst();
+        await gate;
       }
-      return realAppend(msg);
+      return realAppendIdempotent(input);
+    };
+    const payload = {
+      content: 'concurrent identical callback',
+      clientMessageId: 'concurrent-source-a',
+      threadId: ownedThreadId,
     };
+    const firstPromise = app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    await entered;
+    let second;
+    try {
+      second = await app.inject({
+        method: 'POST',
+        url: '/api/callbacks/post-message',
+        headers,
+        payload: { ...payload, clientMessageId: 'concurrent-source-a' },
+      });
+    } finally {
+      releaseFirst();
+    }
+    const first = await firstPromise;
+    assert.equal(first.statusCode, 200);
+    assert.equal(second.statusCode, 200);
+    assert.equal(first.json().status, 'duplicate');
+    assert.equal(second.json().status, 'ok');
+    assert.equal(first.json().messageId, second.json().messageId);
+    assert.equal(messageStore.size, 1);
+    assert.equal(socketManager.getMessages().filter((m) => m.type === 'text').length, 1);
+  });
 
-    const payload = { content: 'concurrent identical agent-key report', threadId: ownedThreadId };
+  test('agent-key callback concurrent different IDs preserve identical text', async () => {
+    const app = await createApp();
+    const { secret } = await issueKey();
     const headers = { 'x-agent-key-secret': secret };
-
-    const p1 = app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
-    await firstAppendEntered; // winner passed its dup check, now blocked inside append
-    const second = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
-    releaseFirstAppend();
-    await p1;
-
-    assert.equal(
-      JSON.parse(second.body).status,
-      'duplicate',
-      'concurrent identical agent-key post must be deduped even before the winner commits its append',
-    );
-    const recent = messageStore.getByThread(ownedThreadId, 10);
-    assert.equal(recent.length, 1, 'concurrent byte-identical agent-key posts must persist exactly ONE message');
+    const realAppendIdempotent = messageStore.appendIdempotent.bind(messageStore);
+    let releaseFirst;
+    const gate = new Promise((resolve) => {
+      releaseFirst = resolve;
+    });
+    let enteredFirst;
+    const entered = new Promise((resolve) => {
+      enteredFirst = resolve;
+    });
+    let calls = 0;
+    messageStore.appendIdempotent = async (input) => {
+      if (++calls === 1) {
+        enteredFirst();
+        await gate;
+      }
+      return realAppendIdempotent(input);
+    };
+    const payload = {
+      content: 'concurrent identical callback',
+      clientMessageId: 'concurrent-source-a',
+      threadId: ownedThreadId,
+    };
+    const firstPromise = app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    await entered;
+    let second;
+    try {
+      second = await app.inject({
+        method: 'POST',
+        url: '/api/callbacks/post-message',
+        headers,
+        payload: { ...payload, clientMessageId: 'concurrent-source-b' },
+      });
+    } finally {
+      releaseFirst();
+    }
+    const first = await firstPromise;
+    assert.equal(first.statusCode, 200);
+    assert.equal(second.statusCode, 200);
+    assert.equal(first.json().status, 'ok');
+    assert.equal(second.json().status, 'ok');
+    assert.notEqual(first.json().messageId, second.json().messageId);
+    assert.equal(messageStore.size, 2);
+    assert.equal(socketManager.getMessages().filter((m) => m.type === 'text').length, 2);
   });
 
   test('post-message with agent-key + unowned threadId returns 403', async () => {
@@ -473,6 +527,51 @@ describe('Callback routes: agent-key auth path', () => {
 
   // ---- P1-3 pipeline: dedup, mentions, replyTo ----
 
+  test('agent-key callback preserves different explicit message IDs with identical content', async () => {
+    const app = await createApp();
+    const { secret } = await issueKey();
+    const headers = { 'x-agent-key-secret': secret };
+    const base = { content: 'independent same-text callback', threadId: ownedThreadId };
+    const post = (clientMessageId) =>
+      app.inject({
+        method: 'POST',
+        url: '/api/callbacks/post-message',
+        headers,
+        payload: { ...base, clientMessageId },
+      });
+    const first = await post('distinct-source-a');
+    const retry = await post('distinct-source-a');
+    const second = await post('distinct-source-b');
+    assert.equal(first.json().status, 'ok');
+    assert.equal(retry.json().status, 'duplicate');
+    assert.equal(second.json().status, 'ok', 'A different client ID is a new source, not a body duplicate');
+    assert.notEqual(first.json().messageId, second.json().messageId);
+    assert.equal((await messageStore.getByThread(ownedThreadId)).length, 2);
+  });
+
+  test('agent-key callback retry restores an append that failed before persistence', async () => {
+    const app = await createApp();
+    const { secret } = await issueKey();
+    const headers = { 'x-agent-key-secret': secret };
+    const base = { content: 'independent same-text callback', threadId: ownedThreadId };
+    const originalAppend = messageStore.append.bind(messageStore);
+    let attempts = 0;
+    messageStore.append = (input) => {
+      if (++attempts === 1) throw new Error('isolated store failure before persistence');
+      return originalAppend(input);
+    };
+    const payload = { ...base, clientMessageId: 'retry-after-store-failure' };
+    const first = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    assert.equal(first.statusCode, 500);
+    assert.equal((await messageStore.getByThread(ownedThreadId)).length, 0);
+    const retry = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    assert.equal(retry.json().status, 'ok', 'Failed persistence must not consume the source identity');
+    const replay = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    assert.equal(replay.json().status, 'duplicate');
+    assert.equal(replay.json().messageId, retry.json().messageId);
+    assert.equal((await messageStore.getByThread(ownedThreadId)).length, 1);
+  });
+
   test('post-message with agent-key deduplicates by clientMessageId', async () => {
     const app = await createApp();
     const { secret } = await issueKey();
@@ -495,7 +594,7 @@ describe('Callback routes: agent-key auth path', () => {
     assert.equal(JSON.parse(r2.body).status, 'duplicate');
   });
 
-  test('post-message with agent-key suppresses exact duplicate callback posts in the retry window', async () => {
+  test('agent-key callback without a client ID preserves repeated text as independent messages', async () => {
     const app = await createApp();
     const { secret } = await issueKey();
 
@@ -518,15 +617,15 @@ describe('Callback routes: agent-key auth path', () => {
     });
     assert.equal(r2.statusCode, 200);
     const secondBody = JSON.parse(r2.body);
-    assert.equal(secondBody.status, 'duplicate');
-    assert.equal(secondBody.messageId, firstBody.messageId);
+    assert.equal(secondBody.status, 'ok');
+    assert.notEqual(secondBody.messageId, firstBody.messageId);
 
     const messages = await messageStore.getByThread(ownedThreadId);
-    assert.equal(messages.length, 1);
-    assert.equal(socketManager.getMessages().filter((m) => m.type === 'text').length, 1);
+    assert.equal(messages.length, 2);
+    assert.equal(socketManager.getMessages().filter((m) => m.type === 'text').length, 2);
   });
 
-  test('post-message with agent-key suppresses exact duplicate callback posts when first copy is queued', async () => {
+  test('agent-key callback new ID does not reuse a same-text queued source', async () => {
     const app = await createApp();
     const { secret } = await issueKey();
 
@@ -548,15 +647,19 @@ describe('Callback routes: agent-key auth path', () => {
       method: 'POST',
       url: '/api/callbacks/post-message',
       headers: { 'x-agent-key-secret': secret },
-      payload: { content: 'same queued smoke report', threadId: ownedThreadId },
+      payload: {
+        clientMessageId: 'new-independent-source',
+        content: 'same queued smoke report',
+        threadId: ownedThreadId,
+      },
     });
     assert.equal(res.statusCode, 200);
     const body = JSON.parse(res.body);
-    assert.equal(body.status, 'duplicate');
-    assert.equal(body.messageId, queued.id);
+    assert.equal(body.status, 'ok');
+    assert.notEqual(body.messageId, queued.id);
 
-    assert.equal(messageStore.size, 1);
-    assert.equal(socketManager.getMessages().filter((m) => m.type === 'text').length, 0);
+    assert.equal(messageStore.size, 2);
+    assert.equal(socketManager.getMessages().filter((m) => m.type === 'text').length, 1);
   });
 
   test('post-message with agent-key parses @mentions from content', async () => {
diff --git a/packages/api/test/callback-routes.test.js b/packages/api/test/callback-routes.test.js
index 39e9c8c472..65860fdeb2 100644
--- a/packages/api/test/callback-routes.test.js
+++ b/packages/api/test/callback-routes.test.js
@@ -807,6 +807,51 @@ describe('Callback Routes', () => {
     assert.equal(response.statusCode, 401);
   });
 
+  test('invocation callback preserves different explicit message IDs with identical content', async () => {
+    const app = await createApp();
+    const { invocationId, callbackToken } = await registry.create('user-1', 'opus');
+    const headers = { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken };
+    const base = { content: 'independent same-text callback' };
+    const post = (clientMessageId) =>
+      app.inject({
+        method: 'POST',
+        url: '/api/callbacks/post-message',
+        headers,
+        payload: { ...base, clientMessageId },
+      });
+    const first = await post('distinct-source-a');
+    const retry = await post('distinct-source-a');
+    const second = await post('distinct-source-b');
+    assert.equal(first.json().status, 'ok');
+    assert.equal(retry.json().status, 'duplicate');
+    assert.equal(second.json().status, 'ok', 'A different client ID is a new source, not a body duplicate');
+    assert.notEqual(first.json().messageId, second.json().messageId);
+    assert.equal(messageStore.getRecent(20).length, 2);
+  });
+
+  test('invocation callback retry restores an append that failed before persistence', async () => {
+    const app = await createApp();
+    const { invocationId, callbackToken } = await registry.create('user-1', 'opus');
+    const headers = { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken };
+    const base = { content: 'independent same-text callback' };
+    const originalAppend = messageStore.append.bind(messageStore);
+    let attempts = 0;
+    messageStore.append = (input) => {
+      if (++attempts === 1) throw new Error('isolated store failure before persistence');
+      return originalAppend(input);
+    };
+    const payload = { ...base, clientMessageId: 'retry-after-store-failure' };
+    const first = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    assert.equal(first.statusCode, 500);
+    assert.equal(messageStore.getRecent(20).length, 0);
+    const retry = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    assert.equal(retry.json().status, 'ok', 'Failed persistence must not consume the source identity');
+    const replay = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    assert.equal(replay.json().status, 'duplicate');
+    assert.equal(replay.json().messageId, retry.json().messageId);
+    assert.equal(messageStore.getRecent(20).length, 1);
+  });
+
   test('POST post-message deduplicates by clientMessageId (at-least-once safe)', async () => {
     const app = await createApp();
     const { invocationId, callbackToken } = await registry.create('user-1', 'opus');
@@ -842,7 +887,7 @@ describe('Callback Routes', () => {
     assert.equal(socketManager.getMessages().length, 1);
   });
 
-  test('POST post-message suppresses exact duplicate callback posts in the retry window', async () => {
+  test('invocation callback without a client ID preserves repeated text as independent messages', async () => {
     const app = await createApp();
     const { invocationId, callbackToken } = await registry.create('user-1', 'opus');
 
@@ -865,67 +910,105 @@ describe('Callback Routes', () => {
     });
     assert.equal(second.statusCode, 200);
     const secondBody = JSON.parse(second.body);
-    assert.equal(secondBody.status, 'duplicate');
-    assert.equal(secondBody.messageId, firstBody.messageId);
+    assert.equal(secondBody.status, 'ok');
+    assert.notEqual(secondBody.messageId, firstBody.messageId);
 
     const recent = messageStore.getRecent(10);
-    assert.equal(recent.length, 1);
-    assert.equal(socketManager.getMessages().length, 1);
+    assert.equal(recent.length, 2);
+    assert.equal(socketManager.getMessages().length, 2);
   });
 
-  // Regression: byte-identical duplicate posts (the screenshot bug). The recent-message
-  // duplicate scan is check-then-act (read recent → later append); two concurrent identical
-  // deliveries (e.g. an at-least-once retry / double-dispatch, each with its own auto-generated
-  // clientMessageId so the clientMessageId SADD does not match) both pass the "no duplicate"
-  // read before either appends → both persist → two identical messages. Closing the race needs
-  // an ATOMIC claim before append. This test forces the interleave by holding the first append
-  // open until the second request has run its duplicate check.
-  test('POST post-message does not double-store byte-identical concurrent posts (atomic dedup)', async () => {
+  test('invocation callback concurrent same-ID retries share one durable message', async () => {
     const app = await createApp();
     const { invocationId, callbackToken } = await registry.create('user-1', 'opus');
-
-    const realAppend = messageStore.append.bind(messageStore);
-    let releaseFirstAppend;
-    const firstAppendGate = new Promise((resolve) => {
-      releaseFirstAppend = resolve;
-    });
-    let signalFirstAppendEntered;
-    const firstAppendEntered = new Promise((resolve) => {
-      signalFirstAppendEntered = resolve;
-    });
-    let appendCount = 0;
-    messageStore.append = async (msg) => {
-      appendCount += 1;
-      if (appendCount === 1) {
-        signalFirstAppendEntered();
-        await firstAppendGate; // hold the winner's append open
+    const headers = { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken };
+    const realAppendIdempotent = messageStore.appendIdempotent.bind(messageStore);
+    let releaseFirst;
+    const gate = new Promise((resolve) => {
+      releaseFirst = resolve;
+    });
+    let enteredFirst;
+    const entered = new Promise((resolve) => {
+      enteredFirst = resolve;
+    });
+    let calls = 0;
+    messageStore.appendIdempotent = async (input) => {
+      if (++calls === 1) {
+        enteredFirst();
+        await gate;
       }
-      return realAppend(msg);
+      return realAppendIdempotent(input);
     };
+    const payload = { content: 'concurrent identical callback', clientMessageId: 'concurrent-source-a' };
+    const firstPromise = app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    await entered;
+    let second;
+    try {
+      second = await app.inject({
+        method: 'POST',
+        url: '/api/callbacks/post-message',
+        headers,
+        payload: { ...payload, clientMessageId: 'concurrent-source-a' },
+      });
+    } finally {
+      releaseFirst();
+    }
+    const first = await firstPromise;
+    assert.equal(first.statusCode, 200);
+    assert.equal(second.statusCode, 200);
+    assert.equal(first.json().status, 'duplicate');
+    assert.equal(second.json().status, 'ok');
+    assert.equal(first.json().messageId, second.json().messageId);
+    assert.equal(messageStore.size, 1);
+    assert.equal(socketManager.getMessages().filter((m) => m.type === 'text').length, 1);
+  });
 
-    // No clientMessageId on either request → the clientMessageId dedup is skipped, exercising
-    // the content-fingerprint path specifically (matches production where two deliveries carry
-    // different auto-generated keys).
-    const payload = { content: 'concurrent identical callback report' };
+  test('invocation callback concurrent different IDs preserve identical text', async () => {
+    const app = await createApp();
+    const { invocationId, callbackToken } = await registry.create('user-1', 'opus');
     const headers = { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken };
-
-    const p1 = app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
-    await firstAppendEntered; // p1 passed its duplicate check and is now blocked inside append
-    const second = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
-    releaseFirstAppend();
-    await p1;
-
-    assert.equal(
-      JSON.parse(second.body).status,
-      'duplicate',
-      'concurrent identical post must be detected as duplicate even before the winner commits its append',
-    );
-    const recent = messageStore.getRecent(10);
-    assert.equal(recent.length, 1, 'concurrent byte-identical posts must persist exactly ONE message');
-    assert.equal(socketManager.getMessages().length, 1, 'only one broadcast for the deduped pair');
+    const realAppendIdempotent = messageStore.appendIdempotent.bind(messageStore);
+    let releaseFirst;
+    const gate = new Promise((resolve) => {
+      releaseFirst = resolve;
+    });
+    let enteredFirst;
+    const entered = new Promise((resolve) => {
+      enteredFirst = resolve;
+    });
+    let calls = 0;
+    messageStore.appendIdempotent = async (input) => {
+      if (++calls === 1) {
+        enteredFirst();
+        await gate;
+      }
+      return realAppendIdempotent(input);
+    };
+    const payload = { content: 'concurrent identical callback', clientMessageId: 'concurrent-source-a' };
+    const firstPromise = app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    await entered;
+    let second;
+    try {
+      second = await app.inject({
+        method: 'POST',
+        url: '/api/callbacks/post-message',
+        headers,
+        payload: { ...payload, clientMessageId: 'concurrent-source-b' },
+      });
+    } finally {
+      releaseFirst();
+    }
+    const first = await firstPromise;
+    assert.equal(first.statusCode, 200);
+    assert.equal(second.statusCode, 200);
+    assert.equal(first.json().status, 'ok');
+    assert.equal(second.json().status, 'ok');
+    assert.notEqual(first.json().messageId, second.json().messageId);
+    assert.equal(messageStore.size, 2);
+    assert.equal(socketManager.getMessages().filter((m) => m.type === 'text').length, 2);
   });
 
-  test('POST post-message suppresses exact duplicate callback posts when first copy is queued', async () => {
+  test('invocation callback new ID does not reuse a same-text queued source', async () => {
     const app = await createApp();
     const { invocationId, callbackToken } = await registry.create('user-1', 'opus');
 
@@ -951,18 +1034,18 @@ describe('Callback Routes', () => {
       method: 'POST',
       url: '/api/callbacks/post-message',
       headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken },
-      payload: { content: 'same queued callback report' },
+      payload: { clientMessageId: 'new-independent-source', content: 'same queued callback report' },
     });
     assert.equal(response.statusCode, 200);
     const body = JSON.parse(response.body);
-    assert.equal(body.status, 'duplicate');
-    assert.equal(body.messageId, queued.id);
+    assert.equal(body.status, 'ok');
+    assert.notEqual(body.messageId, queued.id);
 
-    assert.equal(messageStore.size, 1);
-    assert.equal(socketManager.getMessages().length, 0);
+    assert.equal(messageStore.size, 2);
+    assert.equal(socketManager.getMessages().length, 1);
   });
 
-  test('POST post-message duplicate scan skips stale candidates without stopping early', async () => {
+  test('invocation callback new ID is independent of same-text recent history', async () => {
     const app = await createApp();
     const { invocationId, callbackToken } = await registry.create('user-1', 'opus');
     const now = Date.now();
@@ -997,15 +1080,15 @@ describe('Callback Routes', () => {
       method: 'POST',
       url: '/api/callbacks/post-message',
       headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken },
-      payload: { content: 'same callback report behind stale tail' },
+      payload: { clientMessageId: 'new-history-source', content: 'same callback report behind stale tail' },
     });
     assert.equal(response.statusCode, 200);
     const body = JSON.parse(response.body);
-    assert.equal(body.status, 'duplicate');
-    assert.equal(body.messageId, freshDuplicate.id);
+    assert.equal(body.status, 'ok');
+    assert.notEqual(body.messageId, freshDuplicate.id);
 
-    assert.equal(messageStore.size, 2);
-    assert.equal(socketManager.getMessages().length, 0);
+    assert.equal(messageStore.size, 3);
+    assert.equal(socketManager.getMessages().length, 1);
   });
 
   test('POST post-message does not suppress plain text after same-text rich callback', async () => {
@@ -1077,6 +1160,35 @@ describe('Callback Routes', () => {
     assert.equal(threadBMessages[0].content, 'cross-thread hello');
   });
 
+  test('cross-thread callback uses explicit identity for retries and independent same-text sources', async () => {
+    const app = await createApp();
+    const from = await threadStore.create('user-1', 'source-thread');
+    const to = await threadStore.create('user-1', 'target-thread');
+    const { invocationId, callbackToken } = await registry.create('user-1', 'opus', from.id);
+    const headers = { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken };
+    const payload = {
+      threadId: to.id,
+      content: 'independent cross-thread notice',
+      targetCats: ['codex'],
+      clientMessageId: 'cross-source-a',
+    };
+    const first = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    const retry = await app.inject({ method: 'POST', url: '/api/callbacks/post-message', headers, payload });
+    const second = await app.inject({
+      method: 'POST',
+      url: '/api/callbacks/post-message',
+      headers,
+      payload: { ...payload, clientMessageId: 'cross-source-b' },
+    });
+    assert.equal(first.json().status, 'ok');
+    assert.equal(retry.json().status, 'duplicate');
+    assert.equal(second.json().status, 'ok');
+    assert.equal(first.json().messageId, retry.json().messageId);
+    assert.notEqual(first.json().messageId, second.json().messageId);
+    assert.equal(messageStore.getByThread(from.id, 20, 'user-1').length, 0);
+    assert.equal(messageStore.getByThread(to.id, 20, 'user-1').length, 2);
+  });
+
   test('POST post-message routes cross-paragraph @mention (no keyword gate)', async () => {
     const app = await createApp();
     const threadA = await threadStore.create('user-1', 'thread-a');
diff --git a/packages/api/test/cross-thread-action-successor.test.js b/packages/api/test/cross-thread-action-successor.test.js
index c241a9bef3..c06563495f 100644
--- a/packages/api/test/cross-thread-action-successor.test.js
+++ b/packages/api/test/cross-thread-action-successor.test.js
@@ -568,7 +568,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => {
     assert.equal(entries[1].execution.actionSuccessorFence.leaseId, 'lease-review-1');
   });
 
-  test('releases a newly claimed action lease when exact-message dedupe reuses an existing message', async () => {
+  test('a new action source is not merged with a same-text ordinary source', async () => {
     threadStore.addParticipants(target.id, ['codex']);
     const first = await post({
       targetCats: ['codex'],
@@ -576,7 +576,7 @@ describe('F167 Phase S: cross-thread action successor admission', () => {
     });
     assert.equal(first.statusCode, 200);
 
-    const duplicate = await post({
+    const successor = await post({
       targetCats: ['codex'],
       clientMessageId: 'action-review-2868',
       action: {
@@ -588,12 +588,15 @@ describe('F167 Phase S: cross-thread action successor admission', () => {
       },
     });
 
-    assert.equal(duplicate.statusCode, 200);
-    assert.equal(duplicate.json().status, 'duplicate');
-    assert.equal(actionService.unavailable.length, 1);
-    assert.equal(actionService.unavailable[0].fence.leaseId, 'lease-review-1');
-    assert.deepEqual(actionService.unavailable[0].holderCatIds, ['codex']);
-    assert.match(actionService.unavailable[0].evidenceRef, /exact_duplicate$/);
+    assert.equal(successor.statusCode, 200);
+    assert.equal(successor.json().status, 'ok');
+    assert.notEqual(successor.json().messageId, first.json().messageId);
+    assert.equal(actionService.unavailable.length, 0);
+    const entries = invocationQueue.list(target.id, 'user-1');
+    assert.equal(entries.length, 2);
+    assert.equal(entries[0].execution.actionSuccessorFence, undefined);
+    assert.equal(entries[1].execution.actionSuccessorFence.leaseId, 'lease-review-1');
+    assert.deepEqual(entries[1].targets, ['codex']);
   });
 
   test('action carrier rejects ambiguous identity and preserves explicit parallel intent', async () => {
diff --git a/packages/api/test/cross-thread-coordination-ingress.test.js b/packages/api/test/cross-thread-coordination-ingress.test.js
index 13fb938bdb..966820d104 100644
--- a/packages/api/test/cross-thread-coordination-ingress.test.js
+++ b/packages/api/test/cross-thread-coordination-ingress.test.js
@@ -110,7 +110,7 @@ describe('F167 Phase R: callback ingress boundaries', () => {
     return messageStore.getByThread(threadId, 20, 'user-1').find((message) => message.content === content);
   }
 
-  test('server-minted coordination roots keep content retry suppression without clientMessageId', async () => {
+  test('server-minted coordination roots preserve independent no-ID same-text messages', async () => {
     const source = await threadStore.create('user-1', 'Source');
     const target = await threadStore.create('user-1', 'Target');
     await threadStore.addParticipants(target.id, ['codex']);
@@ -139,21 +139,22 @@ describe('F167 Phase R: callback ingress boundaries', () => {
         coordination: { phase },
       });
       assert.equal(retry.statusCode, 200);
-      assert.equal(retry.json().status, 'duplicate');
+      assert.equal(retry.json().status, 'ok');
+      assert.notEqual(retry.json().messageId, first.json().messageId);
       assert.equal(
         messageStore.getByThread(target.id, 20, 'user-1').filter((message) => message.content === content).length,
-        1,
+        2,
       );
       assert.equal(
         invocationRecordStore.getRecords().length,
         recordsBefore,
         'durable admission does not manufacture an invocation before the Queue drain owns it',
       );
-      assert.equal(invocationQueue.list(target.id, 'user-1').length, queuedBefore + 1);
+      assert.equal(invocationQueue.list(target.id, 'user-1').length, queuedBefore + 2);
     }
   });
 
-  test('caller-chosen coordination ids remain distinct content-dedup identities', async () => {
+  test('caller-chosen coordination IDs preserve distinct message sources', async () => {
     const source = await threadStore.create('user-1', 'Source');
     const target = await threadStore.create('user-1', 'Target');
     await threadStore.addParticipants(target.id, ['codex']);
diff --git a/packages/api/test/dual-path-strict-equality.test.js b/packages/api/test/dual-path-strict-equality.test.js
index 691bc60ecf..15e3105220 100644
--- a/packages/api/test/dual-path-strict-equality.test.js
+++ b/packages/api/test/dual-path-strict-equality.test.js
@@ -4,7 +4,7 @@
  * Verifies that PipelinePromptBuilder produces the same structural output
  * as the legacy SystemPromptBuilder by asserting on:
  * - Specific segment content from known hooks (identity, mentions, governance)
- * - Correct hook event counts matching the 46-hook manifest catalog
+ * - Correct hook event counts matching the 45-hook manifest catalog
  * - S-prefix scope filtering (L/B/C hooks executed but not in output)
  * - D-prefix scope filtering (R/N hooks executed but not in output)
  * - Trace capture drains correctly (no stale buffer)
@@ -142,7 +142,7 @@ describe('Pipeline equivalence regression (AC-P2-14)', () => {
     }
   });
 
-  it('per-turn trace produces exactly 24 per-turn events', () => {
+  it('per-turn trace retains all 23 active hooks after retiring Signal article injection', () => {
     const { trace } = ppb.buildInvocationContextViaHookPipelineWithTrace({
       catId: 'opus',
       mode: 'serial',
@@ -152,7 +152,14 @@ describe('Pipeline equivalence regression (AC-P2-14)', () => {
       mcpAvailable: true,
       a2aEnabled: true,
     });
-    assert.equal(trace.events.length, 24, `Expected 24 per-turn events, got ${trace.events.length}`);
+    assert.equal(trace.events.length, 23, `Expected 23 per-turn events, got ${trace.events.length}`);
+    const expectedIds = [...Array.from({ length: 19 }, (_, i) => `D${i + 1}`), 'D21', 'R1', 'R2', 'N1'];
+    assert.deepEqual(
+      trace.events.map((event) => event.hookId).sort(),
+      expectedIds.sort(),
+      'Retiring D20 must preserve every other per-turn hook',
+    );
+    assert.ok(!trace.patches.some((patch) => patch.hookId === 'D20'), 'Retired article injection produces no patch');
   });
 
   // -- Trace capture (AC-P2-8) -----------------------------------------------
diff --git a/packages/api/test/dual-path-validation.test.js b/packages/api/test/dual-path-validation.test.js
index 6e9a079d70..3ec76c7f5c 100644
--- a/packages/api/test/dual-path-validation.test.js
+++ b/packages/api/test/dual-path-validation.test.js
@@ -182,8 +182,14 @@ describe('Dual-path validation: AssembleBridge + HookPipeline', () => {
       assert.ok(firedIds.includes(id), `${id} should fire`);
     }
 
-    // 24 per-turn hooks → 24 events
-    assert.equal(result.events.length, 24, `Expected 24 per-turn events, got ${result.events.length}`);
+    // D20 automatic Signal article injection is retired; keep all 23 active hooks.
+    assert.equal(result.events.length, 23, `Expected 23 per-turn events, got ${result.events.length}`);
+    assert.ok(
+      result.events.some((event) => event.hookId === 'D21'),
+      'D21 session mailbox remains registered',
+    );
+    assert.ok(!result.events.some((event) => event.hookId === 'D20'), 'Retired article hook is absent from trace');
+    assert.ok(!result.patches.some((patch) => patch.hookId === 'D20'), 'Retired article hook produces no prompt patch');
   });
 
   it('combined pipeline output is substantial (AC-P2-14)', () => {
diff --git a/packages/api/test/f299-request-generation-source-policy.test.js b/packages/api/test/f299-request-generation-source-policy.test.js
index 8f3f6d3933..83489260f3 100644
--- a/packages/api/test/f299-request-generation-source-policy.test.js
+++ b/packages/api/test/f299-request-generation-source-policy.test.js
@@ -213,7 +213,6 @@ describe('F299 request-generation source policy', () => {
       injectSystemPrompt: true,
       hasContextHint: true,
       hasStagingPrepend: true,
-      hasRoutingContextProjection: true,
       hasMissionPrefix: true,
     });
 
@@ -223,7 +222,6 @@ describe('F299 request-generation source policy', () => {
       { owner: 'system_prompt', ref: 'registry:cat-cafe-owned' },
       { owner: 'runtime_context', ref: 'context-management-hint:inv-1' },
       { owner: 'system_prompt', ref: 'staging:adr-038' },
-      { owner: 'runtime_context', ref: 'routing-context:inv-1' },
       { owner: 'home_state', ref: 'thread-mission:thread-1' },
       { owner: 'runtime_context', ref: 'transcript-path-hints:thread-1' },
     ]);
diff --git a/packages/api/test/hook-pipeline-integration.test.js b/packages/api/test/hook-pipeline-integration.test.js
index 52b26abc4e..9cff84511e 100644
--- a/packages/api/test/hook-pipeline-integration.test.js
+++ b/packages/api/test/hook-pipeline-integration.test.js
@@ -80,7 +80,6 @@ function makeRichInput(overrides = {}) {
     conciergeLines: null,
     worldContext: null,
     alwaysOnDocsBlock: null,
-    activeSignalsBlock: null,
     a2aBallCheckContent: '## A2A Ball Check\nBall ownership rules...',
     handoffDecisionTreeContent: null,
     coCreatorFirstMention: '@lang',
@@ -114,7 +113,7 @@ describe('Pipeline Integration (real registry + resolvers + templates)', () => {
     const templatesDir = join(root, 'assets', 'prompt-templates');
     registry = new registryMod.HookRegistry(hooksDir, templatesDir);
     const manifests = registry.scan();
-    assert.equal(manifests.length, 46, `Expected 46 hooks, got ${manifests.length}`);
+    assert.equal(manifests.length, 45, `Expected 45 hooks, got ${manifests.length}`);
   });
 
   it('D12 manifest and fallback template describe recent speech without implying execution', () => {
@@ -179,8 +178,8 @@ describe('Pipeline Integration (real registry + resolvers + templates)', () => {
     const input = makeRichInput();
     const result = pipeline.executeStage('per-turn', input);
 
-    // 24 per-turn hooks should produce trace events
-    assert.equal(result.events.length, 24, `Expected 24 events, got ${result.events.length}`);
+    // 23 per-turn hooks should produce trace events
+    assert.equal(result.events.length, 23, `Expected 23 events, got ${result.events.length}`);
 
     const firedIds = result.events.filter((e) => e.status === 'fired').map((e) => e.hookId);
 
diff --git a/packages/api/test/hook-pipeline.test.js b/packages/api/test/hook-pipeline.test.js
index 4503c075dd..e364f94385 100644
--- a/packages/api/test/hook-pipeline.test.js
+++ b/packages/api/test/hook-pipeline.test.js
@@ -58,7 +58,6 @@ function makeInput(overrides = {}) {
     conciergeLines: null,
     worldContext: null,
     alwaysOnDocsBlock: null,
-    activeSignalsBlock: null,
     a2aBallCheckContent: null,
     handoffDecisionTreeContent: null,
     coCreatorFirstMention: '@lang',
diff --git a/packages/api/test/hook-resolver-registry.test.js b/packages/api/test/hook-resolver-registry.test.js
index 244d5e6a81..916a26e54a 100644
--- a/packages/api/test/hook-resolver-registry.test.js
+++ b/packages/api/test/hook-resolver-registry.test.js
@@ -13,8 +13,8 @@ describe('ResolverRegistry', () => {
     registry = await import('../dist/domains/prompt-hooks/resolvers/index.js');
   });
 
-  it('has exactly 46 resolvers registered', () => {
-    assert.equal(registry.RESOLVER_COUNT, 46);
+  it('has exactly 45 resolvers registered', () => {
+    assert.equal(registry.RESOLVER_COUNT, 45);
   });
 
   it('covers all L-series (L1-L7)', () => {
@@ -29,8 +29,8 @@ describe('ResolverRegistry', () => {
     }
   });
 
-  it('covers all D-series (D1-D21)', () => {
-    for (let i = 1; i <= 21; i++) {
+  it('covers active D-series (D1-D19 and D21)', () => {
+    for (const i of [...Array.from({ length: 19 }, (_, index) => index + 1), 21]) {
       assert.ok(registry.getResolver(`D${i}`), `Missing resolver for D${i}`);
     }
   });
@@ -48,7 +48,7 @@ describe('ResolverRegistry', () => {
 
   it('all resolvers implement resolve()', () => {
     const ids = registry.getRegisteredResolverIds();
-    assert.equal(ids.length, 46);
+    assert.equal(ids.length, 45);
     for (const id of ids) {
       const resolver = registry.getResolver(id);
       assert.equal(typeof resolver.resolve, 'function', `${id} resolver missing resolve()`);
diff --git a/packages/api/test/hook-resolvers-session.test.js b/packages/api/test/hook-resolvers-session.test.js
index 71bfe3dbaf..39becea997 100644
--- a/packages/api/test/hook-resolvers-session.test.js
+++ b/packages/api/test/hook-resolvers-session.test.js
@@ -59,7 +59,6 @@ function makeInput(overrides = {}) {
     conciergeLines: null,
     worldContext: null,
     alwaysOnDocsBlock: null,
-    activeSignalsBlock: null,
     a2aBallCheckContent: null,
     handoffDecisionTreeContent: null,
     coCreatorFirstMention: '@lang',
diff --git a/packages/api/test/hook-resolvers-turn.test.js b/packages/api/test/hook-resolvers-turn.test.js
index 0e0df3a20f..08b0356d88 100644
--- a/packages/api/test/hook-resolvers-turn.test.js
+++ b/packages/api/test/hook-resolvers-turn.test.js
@@ -58,7 +58,6 @@ function makeInput(overrides = {}) {
     conciergeLines: null,
     worldContext: null,
     alwaysOnDocsBlock: null,
-    activeSignalsBlock: null,
     a2aBallCheckContent: null,
     handoffDecisionTreeContent: null,
     coCreatorFirstMention: '@lang',
@@ -348,11 +347,6 @@ describe('Turn resolvers D11-D21, R1-R2, N1', () => {
     assert.equal(new mod.D19Resolver().resolve(input).status, 'fired');
   });
 
-  it('D20 fires with signals', () => {
-    const input = makeInput({ activeSignalsBlock: '### [S1] Title (HN/T1)\nContent' });
-    assert.equal(new mod.D20Resolver().resolve(input).status, 'fired');
-  });
-
   it('D21 fires when a2a needed and returns CC_MENTION', () => {
     const input = makeInput({
       mode: 'serial',
diff --git a/packages/api/test/hook-segment-coverage.test.js b/packages/api/test/hook-segment-coverage.test.js
index efbb1c814f..f9062a1061 100644
--- a/packages/api/test/hook-segment-coverage.test.js
+++ b/packages/api/test/hook-segment-coverage.test.js
@@ -5,7 +5,7 @@
  * - **Segment coverage**: every expected S/D-prefix hook produces non-empty
  *   content (catches silent drops during refactoring)
  * - Specific segment content from known hooks (identity, mentions, governance)
- * - Correct hook event counts matching the 46-hook manifest catalog
+ * - Correct hook event counts matching the 45-hook manifest catalog
  * - S-prefix scope filtering (L/B/C hooks executed but not in output)
  * - D-prefix scope filtering (R/N hooks executed but not in output)
  * - Trace capture drains correctly (no stale buffer)
@@ -283,7 +283,7 @@ describe('Hook segment coverage (AC-P2-14)', () => {
     }
   });
 
-  it('per-turn trace produces exactly 24 per-turn events', () => {
+  it('per-turn trace produces exactly 23 per-turn events', () => {
     const { trace } = ppb.buildInvocationContextViaHookPipelineWithTrace({
       catId: 'opus',
       mode: 'serial',
@@ -293,7 +293,7 @@ describe('Hook segment coverage (AC-P2-14)', () => {
       mcpAvailable: true,
       a2aEnabled: true,
     });
-    assert.equal(trace.events.length, 24, `Expected 24 per-turn events, got ${trace.events.length}`);
+    assert.equal(trace.events.length, 23, `Expected 23 per-turn events, got ${trace.events.length}`);
   });
 
   // -- Trace capture (AC-P2-8) -----------------------------------------------
diff --git a/packages/api/test/redis-message-store.test.js b/packages/api/test/redis-message-store.test.js
index 7ec46119b4..fd9a2ce0b5 100644
--- a/packages/api/test/redis-message-store.test.js
+++ b/packages/api/test/redis-message-store.test.js
@@ -696,7 +696,7 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, ()
       modality: 'result',
       identity,
     };
-    const stored = await store.append({
+    const stored = await appendFixture(store, {
       userId: 'owner-f317',
       threadId: 'thread-f317-identity',
       catId: 'codex',
@@ -1171,25 +1171,6 @@ describe('RedisMessageStore', { skip: redisIsolationSkipReason(REDIS_URL) }, ()
     assert.deepEqual(page.nextCursor, { timestamp: 10.5, id });
   });
 
-  it('claimContentDedupKey() is atomic: first wins, live duplicate loses, distinct keys independent', async () => {
-    const first = await store.claimContentDedupKey('fp-abc', 5000);
-    assert.equal(first, true, 'first claim of a fingerprint succeeds');
-    const second = await store.claimContentDedupKey('fp-abc', 5000);
-    assert.equal(second, false, 'a still-live claim of the same fingerprint is reported as duplicate');
-    const other = await store.claimContentDedupKey('fp-xyz', 5000);
-    assert.equal(other, true, 'a different fingerprint is independent');
-  });
-
-  it('claimContentDedupKey() re-allows a fingerprint after the PX window expires', async () => {
-    const first = await store.claimContentDedupKey('fp-ttl', 40);
-    assert.equal(first, true);
-    const immediate = await store.claimContentDedupKey('fp-ttl', 40);
-    assert.equal(immediate, false, 'within window → duplicate');
-    await new Promise((resolve) => setTimeout(resolve, 90)); // wait past the PX TTL
-    const afterExpiry = await store.claimContentDedupKey('fp-ttl', 40);
-    assert.equal(afterExpiry, true, 'after Redis PX expiry the fingerprint can be claimed again');
-  });
-
   it('getRecent() returns messages in chronological order', async () => {
     const now = Date.now();
     await appendFixture(store, { userId: 'u', catId: null, content: 'first', mentions: [], timestamp: now });
diff --git a/packages/api/test/route-parallel-mention-suppression.test.js b/packages/api/test/route-parallel-mention-suppression.test.js
index 422428b06e..2291204f5e 100644
--- a/packages/api/test/route-parallel-mention-suppression.test.js
+++ b/packages/api/test/route-parallel-mention-suppression.test.js
@@ -119,4 +119,57 @@ describe('F167 L2: routeParallel mention suppression', () => {
       );
     }
   });
+  test('an earlier routed callback does not create a second carrier from parallel final prose', async () => {
+    const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js');
+    const appendCalls = [];
+    const deps = createDeps(
+      {
+        opus: {
+          async *invoke() {
+            yield {
+              type: 'tool_use',
+              catId: 'opus',
+              toolName: 'cat_cafe_post_message',
+              toolUseId: 'callback',
+              toolInput: { content: '@codex\nCallback source handoff.', targetCats: ['codex'] },
+              timestamp: Date.now(),
+            };
+            yield {
+              type: 'tool_result',
+              catId: 'opus',
+              toolUseId: 'callback',
+              content: JSON.stringify({
+                status: 'ok',
+                threadId: 'thread1',
+                messageId: 'callback-source',
+                routed: ['codex'],
+              }),
+              timestamp: Date.now(),
+            };
+            yield {
+              type: 'text',
+              catId: 'opus',
+              content: '@codex\nParallel final stays reasoning-only.',
+              timestamp: Date.now(),
+            };
+            yield { type: 'done', catId: 'opus', timestamp: Date.now() };
+          },
+        },
+      },
+      { appendCalls },
+    );
+    const events = [];
+    for await (const event of routeParallel(deps, ['opus'], 'ideate', 'user1', 'thread1')) events.push(event);
+    const response = appendCalls.find((row) => row.from?.catId === 'opus' && row.origin === 'stream');
+    assert.ok(response.content.includes('Parallel final stays reasoning-only.'));
+    assert.deepEqual(response.mentions, [], 'parallel text still has no serial routing semantics');
+    assert.equal(
+      events.some((event) => event.type === 'a2a_handoff'),
+      false,
+    );
+    assert.equal(
+      events.some((event) => event.type === 'system_info' && event.content?.includes('a2a_followup_available')),
+      false,
+    );
+  });
 });
diff --git a/packages/api/test/route-serial-callback-dedup.test.js b/packages/api/test/route-serial-callback-dedup.test.js
index ced6de8a8e..abfb901c01 100644
--- a/packages/api/test/route-serial-callback-dedup.test.js
+++ b/packages/api/test/route-serial-callback-dedup.test.js
@@ -241,7 +241,178 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => {
     getRichBlockBuffer().destroy();
   });
 
-  it('does not re-dispatch a callback-routed source/target through the serial mention worklist', async () => {
+  // A confirmed callback and a final response are independent durable sources, even
+  // when addressed to the same target or carrying identical text. Only callback
+  // confirmations refer to the callback source; they must not replay into the final.
+  for (const scenario of [
+    {
+      name: 'earlier scope notification then a distinct final handoff',
+      final: '@codex\nExact commit is ready for review.',
+    },
+    { name: 'identical text with independent callback and response identities', final: '@codex\nScope notification.' },
+    {
+      name: 'structured callback target then a final handoff',
+      final: '@codex\nReview the completed work.',
+      callback: 'Scope notification.',
+    },
+    {
+      name: 'duplicate callback confirmation then a final handoff',
+      final: '@codex\nNew final handoff.',
+      duplicate: true,
+    },
+    { name: 'failed callback then a final handoff', final: '@codex\nFallback handoff.', failed: true },
+    {
+      name: 'callback-only handoff is not replayed into plain final text',
+      final: 'Final summary without a new handoff.',
+    },
+    {
+      name: 'duplicate callback confirmation is not replayed into plain final text',
+      final: 'Final summary.',
+      duplicate: true,
+    },
+    { name: 'failed callback target is not invented for plain final text', final: 'Final summary.', failed: true },
+    { name: 'callback-only tool output creates no second handoff', final: '' },
+  ]) {
+    it(`source-scoped routing: ${scenario.name}`, async () => {
+      const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
+      const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js');
+      const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js');
+      const { commitCompletedResponseAndEnqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js');
+      const { appendTestLifecycleResponseSource } = await import('./helpers/message-from-fixtures.js');
+      const messages = new MessageStore();
+      const queue = new InvocationQueue();
+      let callbackId;
+      let responseId;
+      const callbackBody = scenario.callback ?? '@codex\nScope notification.';
+      const commits = [];
+      const service = {
+        async *invoke() {
+          yield {
+            type: 'tool_use',
+            catId: 'opus',
+            toolName: 'cat_cafe_post_message',
+            toolUseId: 'scope-post',
+            toolInput: { content: callbackBody, targetCats: ['codex'] },
+            timestamp: Date.now(),
+          };
+          if (!scenario.failed) {
+            const admitted = await queue.appendAndEnqueueDurable(
+              messages,
+              {
+                from: { kind: 'agent', catId: 'opus' },
+                userId: 'user1',
+                threadId: 'thread1',
+                content: callbackBody,
+                mentions: ['codex'],
+                timestamp: Date.now(),
+                origin: 'callback',
+              },
+              {
+                from: { kind: 'agent', catId: 'opus' },
+                kind: 'conversation_input',
+                userId: 'user1',
+                threadId: 'thread1',
+                content: callbackBody,
+                targetCats: ['codex'],
+                intent: 'execute',
+                ownerAuthProvenance: 'unknown',
+              },
+            );
+            assert.equal(admitted.outcome, 'enqueued');
+            callbackId = admitted.message.id;
+          }
+          const result = {
+            type: 'tool_result',
+            catId: 'opus',
+            toolUseId: 'scope-post',
+            timestamp: Date.now(),
+            content: scenario.failed
+              ? 'Error: callback token expired'
+              : JSON.stringify({ status: 'ok', threadId: 'thread1', messageId: callbackId, routed: ['codex'] }),
+          };
+          yield result;
+          if (scenario.duplicate)
+            yield {
+              ...result,
+              content: JSON.stringify({ status: 'duplicate', threadId: 'thread1', messageId: callbackId }),
+            };
+          if (scenario.final) yield { type: 'text', catId: 'opus', content: scenario.final, timestamp: Date.now() };
+          yield { type: 'done', catId: 'opus', timestamp: Date.now() };
+        },
+      };
+      const deps = createMockDeps({ opus: service }, []);
+      deps.messageStore = messages;
+      const persistenceContext = { errors: [], failed: false };
+      for await (const _ of routeSerial(deps, ['opus'], 'work', 'user1', 'thread1', {
+        parentInvocationId: 'source-routing-parent',
+        persistenceContext,
+        onLifecycleInvocationStarted: async (input) => {
+          const response = await appendTestLifecycleResponseSource(messages, {
+            invocationId: input.invocationId,
+            catId: input.catId,
+            threadId: input.threadId,
+            userId: input.userId,
+            timestamp: input.startedAt,
+          });
+          responseId = response.id;
+          return {
+            responseMessageId: response.id,
+            priorFrontierMessageId: null,
+            activeRun: {
+              threadId: 'thread1',
+              targetId: 'opus',
+              invocationId: input.invocationId,
+              responseMessageId: response.id,
+              inputEntryIds: [],
+              inputMessageIds: [],
+              privateInputEntryIds: [],
+              startedAt: input.startedAt,
+            },
+          };
+        },
+        commitCompletedA2AWake: async (input) => {
+          commits.push(input);
+          return commitCompletedResponseAndEnqueueA2ATargets(
+            {
+              messageStore: messages,
+              invocationQueue: queue,
+              queueProcessor: { async requestDrain() {} },
+              socketManager: { emitToUser() {}, broadcastAgentMessage() {} },
+              log: { info() {}, warn() {}, error() {} },
+            },
+            input,
+          );
+        },
+      })) {
+        /* consume */
+      }
+      assert.deepEqual(persistenceContext.errors, [], 'route persistence must succeed');
+      const final = await messages.getById(responseId);
+      const wantsFinalHandoff = scenario.final.startsWith('@codex');
+      assert.deepEqual(final.mentions, wantsFinalHandoff ? ['codex'] : [], 'final owns only its own targets');
+      assert.equal(commits.length, wantsFinalHandoff ? 1 : 0);
+      if (wantsFinalHandoff) {
+        assert.equal(commits[0].responseMessageId, responseId);
+        assert.notEqual(responseId, callbackId);
+        assert.equal(final.lifecycle.status, 'completed');
+        const queued = queue.list('thread1', 'user1').find((entry) => entry.payload.sourceRecordId === responseId);
+        assert.ok(queued, 'final must enter the real queue under its own source id');
+        assert.deepEqual(queued.targets, ['codex']);
+      }
+      const entries = queue.list('thread1', 'user1');
+      assert.equal(entries.length, (scenario.failed ? 0 : 1) + (wantsFinalHandoff ? 1 : 0));
+      if (!scenario.failed) {
+        assert.equal(
+          entries.filter((entry) => entry.payload.sourceRecordId === callbackId).length,
+          1,
+          'callback source is not replayed',
+        );
+        assert.equal(persistenceContext.persistedOutputMessageIds.filter((id) => id === callbackId).length, 1);
+      }
+    });
+  }
+
+  it('does not replay a callback carrier through recursive serial execution', async () => {
     const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js');
     const { loadCatConfig, toAllCatConfigs } = await import('../dist/config/cat-config-loader.js');
     const appendCalls = [];
@@ -249,7 +420,6 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => {
     const callbackBody = '@codex\nCallback already routed this exact source and target.';
     const callbackService = {
       async *invoke() {
-        yield { type: 'text', catId: 'opus', content: callbackBody, timestamp: Date.now() };
         yield {
           type: 'tool_use',
           catId: 'opus',
@@ -263,6 +433,7 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => {
           content: JSON.stringify({ status: 'ok', threadId: 'thread1', messageId: 'callback-source-1' }),
           timestamp: Date.now(),
         };
+        yield { type: 'text', catId: 'opus', content: 'Callback handoff sent; final summary.', timestamp: Date.now() };
         yield { type: 'done', catId: 'opus', timestamp: Date.now() };
       },
     };
diff --git a/packages/api/test/transport-boundary-l0-equivalence.test.js b/packages/api/test/transport-boundary-l0-equivalence.test.js
index 260612bdb7..de40265b30 100644
--- a/packages/api/test/transport-boundary-l0-equivalence.test.js
+++ b/packages/api/test/transport-boundary-l0-equivalence.test.js
@@ -73,9 +73,9 @@ describe('Transport boundary + L0 equivalence', () => {
       }
     });
 
-    it('exactly 46 hooks in the pipeline (no transport leakage)', () => {
+    it('exactly 45 hooks in the pipeline (no transport leakage)', () => {
       const allHooks = registry.getAllHooks();
-      assert.equal(allHooks.length, 46, `Expected 46 hooks, got ${allHooks.length}`);
+      assert.equal(allHooks.length, 45, `Expected 45 hooks, got ${allHooks.length}`);
     });
   });
 
@@ -163,7 +163,6 @@ describe('Transport boundary + L0 equivalence', () => {
         conciergeLines: null,
         worldContext: null,
         alwaysOnDocsBlock: null,
-        activeSignalsBlock: null,
         a2aBallCheckContent: null,
         handoffDecisionTreeContent: null,
         coCreatorFirstMention: '@lang',
diff --git a/packages/shared/src/types/prompt-hook.ts b/packages/shared/src/types/prompt-hook.ts
index aefabf2251..38e827316e 100644
--- a/packages/shared/src/types/prompt-hook.ts
+++ b/packages/shared/src/types/prompt-hook.ts
@@ -329,10 +329,9 @@ export interface AssemblerInput {
   guidePromptLines: string | null;
   conciergeLines: readonly string[] | null;
 
-  // --- World / Knowledge / Signals ---
+  // --- World / Knowledge ---
   worldContext: WorldContextInput | null;
   alwaysOnDocsBlock: string | null;
-  activeSignalsBlock: string | null;
 
   // --- Pre-loaded template content (for D8/D21 which use file loading) ---
   a2aBallCheckContent: string | null;
diff --git a/packages/web/src/components/ChatInput.tsx b/packages/web/src/components/ChatInput.tsx
index f44a7a64d3..53c649d177 100644
--- a/packages/web/src/components/ChatInput.tsx
+++ b/packages/web/src/components/ChatInput.tsx
@@ -21,6 +21,7 @@ import { useMessageDispositionPreference } from '@/hooks/useMessageDispositionPr
 import { usePathCompletion } from '@/hooks/usePathCompletion';
 import type { UploadStatus, WhisperOptions } from '@/hooks/useSendMessage';
 import { useThreadLiveness } from '@/hooks/useThreadScopedSelectors';
+import { formatCatDisplayName } from '@/lib/cat-display-name';
 import { useChatStore } from '@/stores/chatStore';
 import { useInputHistoryStore } from '@/stores/inputHistoryStore';
 import { apiFetch } from '@/utils/api-client';
@@ -225,6 +226,13 @@ export function ChatInput({
     return ![...whisperTargets].some((catId) => activeCatIds.has(catId));
   }, [whisperMode, whisperTargets, activeCatIds]);
   const messageDisposition = useMessageDispositionPreference(effectiveThreadId, Boolean(effectiveThreadId));
+  const guideReplyNames = cats
+    .filter((cat) => activeCatIds.has(cat.id) && cat.messageDeliveryCapabilities?.guideReply === true)
+    .map(formatCatDisplayName);
+  const guideReplyHint =
+    guideReplyNames.length > 0
+      ? `执行发生了偏离?${guideReplyNames.join('/')}支持引导回复,可继续输入直接发送消息而不中断当前回复`
+      : undefined;
 
   const [addMenuOpen, setAddMenuOpen] = useState(false);
   const [contextPickerMode, setContextPickerMode] = useState(null);
@@ -791,7 +799,7 @@ export function ChatInput({
     const isMobile = typeof window.matchMedia === 'function' ? window.matchMedia('(max-width: 767px)').matches : false;
     const maxH = isMobile ? 120 : 200; // ~5 lines mobile, ~8 lines desktop
     ta.style.height = `${Math.min(ta.scrollHeight, maxH)}px`;
-  }, [input]);
+  }, [input, guideReplyHint]);
 
   useEffect(() => {
     if (!activeMenu) return;
@@ -968,11 +976,7 @@ export function ChatInput({
             placeholder={
               whisperMode
                 ? '悄悄话...'
-                : hasActiveInvocation && !whisperTargetsAllIdle
-                  ? messageDisposition.effective === 'continue_current'
-                    ? '继续输入,消息可不中断追加给当前成员...'
-                    : '继续输入,发送后进入队列...'
-                  : (placeholder ?? '输入消息... (@ 召唤猫猫 · /thread 引用对话)')
+                : (guideReplyHint ?? placeholder ?? '输入消息... (@ 召唤猫猫 · /thread 引用对话)')
             }
             className={`w-full resize-none rounded-xl border p-3 text-sm focus:outline-none focus:ring-2 placeholder:text-cafe-muted ${
               whisperMode
diff --git a/packages/web/src/components/ChatMessage.tsx b/packages/web/src/components/ChatMessage.tsx
index c0e2d74f9a..71bae7c202 100644
--- a/packages/web/src/components/ChatMessage.tsx
+++ b/packages/web/src/components/ChatMessage.tsx
@@ -25,7 +25,6 @@ import { AppendedInputReceipts } from './AppendedInputReceipts';
 import {
   doesAssistantMessageRenderBubble,
   projectEmptyResponseLifecycleNotice,
-  projectFailedResponseLabel,
 } from './assistant-message-renderability';
 import { CapabilityTipStrip } from './CapabilityTipStrip';
 import { CatAvatar } from './CatAvatar';
@@ -35,7 +34,7 @@ import { CollapsibleMarkdown } from './CollapsibleMarkdown';
 import { ConnectorBubble } from './ConnectorBubble';
 import { ContentBlocks } from './ContentBlocks';
 import { CopyIdButton } from './CopyIdButton';
-import { isHiddenChatRow, projectFailedResponseDiagnostics, projectSystemRowSurface } from './chat-row-surface';
+import { isHiddenChatRow, projectSystemRowSurface } from './chat-row-surface';
 import { CliOutputBlock } from './cli-output/CliOutputBlock';
 import { toCliEvents } from './cli-output/toCliEvents';
 import {
@@ -381,13 +380,6 @@ function ChatMessageContent({
   const cliEvents = toCliEvents(message.toolEvents, cliStdoutContent);
   const hasCliBlock = cliEvents.length > 0;
   const emptyResponseNotice = projectEmptyResponseLifecycleNotice(message, { hasCliBlock });
-  // The failure's diagnostics render under the response whether it streamed a body or only shows
-  // the failure notice.
-  const failedResponseLabel = projectFailedResponseLabel(message);
-  const failedResponseDiagnostics = projectFailedResponseDiagnostics(message);
-  // A duplicate of the CLI panel directly above hides only its panel; the response stays.
-  const visibleFailedResponseDiagnostics =
-    failedResponseDiagnostics?.kind === 'cli' && hideDiagnosticsPanel ? null : failedResponseDiagnostics;
   const assistantPresentationTime =
     message.lifecycle?.kind === 'response' ? getMessageTimelineOrderTime(message) : message.timestamp;
   const cliStatus = message.isStreaming
@@ -941,16 +933,6 @@ function ChatMessageContent({
       }
       footer={
         <>
-          {failedResponseLabel && visibleFailedResponseDiagnostics ? (
-            
- -
- ) : null} {!message.isStreaming && message.metadata ? : null} diff --git a/packages/web/src/components/CliDiagnosticsPanel.tsx b/packages/web/src/components/CliDiagnosticsPanel.tsx index 76f190e0aa..e9ac711eaf 100644 --- a/packages/web/src/components/CliDiagnosticsPanel.tsx +++ b/packages/web/src/components/CliDiagnosticsPanel.tsx @@ -196,16 +196,9 @@ interface CliDiagnosticsPanelProps { * panel entirely via ChatMessage's hideDiagnosticsPanel prop, so this Panel only needs * to render the count badge for the head. */ dedupCount?: number; - /** The owning response already renders its failure; diagnostics are disclosed on demand. */ - showErrorBanner?: boolean; } -export function CliDiagnosticsPanel({ - errorMessage, - diagnostics, - dedupCount, - showErrorBanner = true, -}: CliDiagnosticsPanelProps) { +export function CliDiagnosticsPanel({ errorMessage, diagnostics, dedupCount }: CliDiagnosticsPanelProps) { const [expanded, setExpanded] = useState(false); // 云端 codex P2 (2026-05-27): membership check before indexing — stale/newer/malformed @@ -233,43 +226,41 @@ export function CliDiagnosticsPanel({ return (
{/* Error banner */} - {(showErrorBanner || expanded) && ( -
- -
- - {summary} - {dedupCount !== undefined && dedupCount > 1 && ( - - ×{dedupCount} - - )} - - {diagnostics.publicHint && ( - - {diagnostics.publicHint} +
+ +
+ + {summary} + {dedupCount !== undefined && dedupCount > 1 && ( + + ×{dedupCount} )} -
+ + {diagnostics.publicHint && ( + + {diagnostics.publicHint} + + )}
- )} +
{/* Excerpt toggle — only shown when Phase A populated safeExcerpt (reasonCode whitelisted) */} - {(hasExcerpt || !showErrorBanner) && ( + {hasExcerpt && ( <>
); } diff --git a/packages/web/src/components/TerminalDiagnosticsPanel.tsx b/packages/web/src/components/TerminalDiagnosticsPanel.tsx index 554856bd75..9fb2ec7760 100644 --- a/packages/web/src/components/TerminalDiagnosticsPanel.tsx +++ b/packages/web/src/components/TerminalDiagnosticsPanel.tsx @@ -10,8 +10,7 @@ export type TerminalDiagnostics = | { kind: 'timeout'; diagnostics: TimeoutDiagnostics }; /** - * F212 Phase B precedence (砚砚 P1-1 + 云端 codex P2-3), shared by an error row and by the - * response that owns its failure (F117), so both explain one failure the same way: + * F212 Phase B precedence for standalone error rows: * 1. a classified CLI error → CLI panel * 2. a timeout with no recognized classification → timeout panel * (keeps F118 silence/processAlive; covers unknown-reason persisted payloads too) @@ -34,28 +33,16 @@ export function TerminalDiagnosticsPanel({ selected, errorMessage, dedupCount, - responseOwnsFailure = false, }: { selected: TerminalDiagnostics; /** Copy naming the failure; the CLI panel falls back to it when publicSummary is missing. */ errorMessage: string; /** Head of an adjacent duplicate group: the CLI panel shows "×N". */ dedupCount?: number; - /** The response bubble/status already explains failure; do not repeat a generic banner. */ - responseOwnsFailure?: boolean; }) { return selected.kind === 'cli' ? ( - + ) : ( - + ); } diff --git a/packages/web/src/components/TimeoutDiagnosticsPanel.tsx b/packages/web/src/components/TimeoutDiagnosticsPanel.tsx index 07479aaf58..4bfa396464 100644 --- a/packages/web/src/components/TimeoutDiagnosticsPanel.tsx +++ b/packages/web/src/components/TimeoutDiagnosticsPanel.tsx @@ -58,20 +58,13 @@ interface TimeoutDiagnosticsPanelProps { errorMessage: string; diagnostics: TimeoutDiagnostics; description?: string; - /** A response already names its terminal state; keep only the folded evidence. */ - showErrorBanner?: boolean; } /** * F118 AC-C3: Enhanced timeout diagnostics panel. * Renders error banner + collapsible diagnostics per Pencil Scene 4. */ -export function TimeoutDiagnosticsPanel({ - errorMessage, - diagnostics, - description, - showErrorBanner = true, -}: TimeoutDiagnosticsPanelProps) { +export function TimeoutDiagnosticsPanel({ errorMessage, diagnostics, description }: TimeoutDiagnosticsPanelProps) { const [expanded, setExpanded] = useState(false); const rows: { key: string; value: string; purple?: boolean }[] = [ @@ -100,28 +93,26 @@ export function TimeoutDiagnosticsPanel({ return (
{/* Error banner */} - {showErrorBanner && ( -
- -
- - {errorMessage} +
+ +
+ + {errorMessage} + + {description && ( + + {description} - {description && ( - - {description} - - )} -
+ )}
- )} +
{/* Diagnostics toggle */} diff --git a/packages/web/src/components/__tests__/ChatMessage-terminal-diagnostics-ownership.test.tsx b/packages/web/src/components/__tests__/ChatMessage-terminal-diagnostics-ownership.test.tsx index 351038a292..9adff2e00c 100644 --- a/packages/web/src/components/__tests__/ChatMessage-terminal-diagnostics-ownership.test.tsx +++ b/packages/web/src/components/__tests__/ChatMessage-terminal-diagnostics-ownership.test.tsx @@ -8,7 +8,7 @@ import { ChatMessage } from '../ChatMessage'; vi.mock('@/utils/api-client', () => ({ API_URL: 'http://api.test', apiFetch: vi.fn(async () => new Response('{}')) })); -describe('actual response renders one failure with folded diagnostics', () => { +describe('actual response owns its complete error without duplicate diagnostics', () => { let container: HTMLDivElement; let root: Root; beforeAll(() => Object.assign(globalThis, { React, IS_REACT_ACT_ENVIRONMENT: true })); @@ -43,15 +43,15 @@ describe('actual response renders one failure with folded diagnostics', () => { ); const timeout = { timeoutDiagnostics: { silenceDurationMs: 1846576, processAlive: true, invocationId: 'turn' } }; it.each([ - ['classified CLI', cli('auth_failed'), 'cli'], - ['unknown CLI', cli('future_reason'), 'cli'], - ['timeout', timeout, 'timeout'], - ] as const)('%s preserves details while the response owns the failure', (_name, extra, kind) => { + ['classified CLI', cli('auth_failed')], + ['unknown CLI', cli('future_reason')], + ['timeout', timeout], + ] as const)('%s renders the full failure once in the response', (_name, extra) => { const message: Message = { id: 'response', type: 'assistant', catId: 'opus', - content: '', + content: 'complete provider error\nwith the original detailed failure', timestamp: 1, extra, lifecycle: { @@ -67,20 +67,15 @@ describe('actual response renders one failure with folded diagnostics', () => { }, }; act(() => root.render( undefined} />)); - expect(container.textContent?.match(/回复失败/g)).toHaveLength(1); - expect(container.querySelector('[data-testid="cli-diagnostics-banner"]')).toBeNull(); - expect(container.querySelector('[data-testid="diagnostics-panel"]')).toBeNull(); - const toggle = container.querySelector( - `[data-testid="${kind === 'cli' ? 'cli-diagnostics-toggle' : 'diagnostics-toggle'}"]`, - ) as HTMLButtonElement; - expect(toggle).toBeTruthy(); - act(() => toggle.click()); - expect(container.textContent).toContain('turn'); - if (kind === 'cli') { - expect(container.textContent).toContain('provider exit cause'); - expect(container.textContent).toContain('check credentials'); - } else expect(container.textContent).toContain('1846576ms'); - expect(container.textContent?.match(/回复失败/g)).toHaveLength(1); + expect(container.textContent).toContain('complete provider error'); + expect(container.textContent).toContain('with the original detailed failure'); + expect(container.textContent?.match(/complete provider error/g)).toHaveLength(1); + expect(container.querySelector('[data-testid="cli-diagnostics"]')).toBeNull(); + expect(container.querySelector('[data-testid="timeout-diagnostics"]')).toBeNull(); + expect(container.textContent).not.toContain('查看详细错误'); + expect(container.textContent).not.toContain('provider exit cause'); + expect(container.textContent).not.toContain('check credentials'); + expect(message.extra).toBe(extra); }); it('a standalone admission failure still shows its only error banner', () => { diff --git a/packages/web/src/components/__tests__/ChatMessage-timeout-diagnostics.test.ts b/packages/web/src/components/__tests__/ChatMessage-timeout-diagnostics.test.ts index 34a9f16cc3..e138324113 100644 --- a/packages/web/src/components/__tests__/ChatMessage-timeout-diagnostics.test.ts +++ b/packages/web/src/components/__tests__/ChatMessage-timeout-diagnostics.test.ts @@ -60,7 +60,7 @@ describe('F118 TimeoutDiagnosticsPanel (AC-C3)', () => { expect(el?.textContent).toContain('Diagnostics'); }); - it('a response-owned timeout keeps details folded without a second failure banner', async () => { + it('a standalone timeout retains its error and folded diagnostics', async () => { const { TerminalDiagnosticsPanel } = await import('../TerminalDiagnosticsPanel'); act(() => { root.render( @@ -70,13 +70,12 @@ describe('F118 TimeoutDiagnosticsPanel (AC-C3)', () => { diagnostics: { silenceDurationMs: 1846576, processAlive: true, invocationId: 'timed-out-turn' }, }, errorMessage: '回复失败。', - responseOwnsFailure: true, }), ); }); const panel = container.querySelector('[data-testid="timeout-diagnostics"]'); - expect(panel?.textContent).not.toContain('回复失败。'); - expect(panel?.textContent).toContain('查看超时诊断'); + expect(panel?.textContent).toContain('回复失败。'); + expect(panel?.textContent).toContain('Diagnostics'); expect(container.querySelector('[data-testid="diagnostics-panel"]')).toBeNull(); const toggle = container.querySelector('[data-testid="diagnostics-toggle"]') as HTMLButtonElement; expect(toggle.getAttribute('aria-expanded')).toBe('false'); @@ -85,10 +84,10 @@ describe('F118 TimeoutDiagnosticsPanel (AC-C3)', () => { expect(panel?.textContent).toContain('timed-out-turn'); expect(panel?.textContent).toContain('1846576ms'); expect(panel?.textContent).toContain('true (at timeout)'); - expect(panel?.textContent).not.toContain('回复失败。'); + expect(panel?.textContent).toContain('回复失败。'); }); - it('a response-owned classified CLI error retains its actionable cause and hint', async () => { + it('a standalone classified CLI error retains its actionable cause and hint', async () => { const { TerminalDiagnosticsPanel } = await import('../TerminalDiagnosticsPanel'); act(() => { root.render( @@ -103,14 +102,10 @@ describe('F118 TimeoutDiagnosticsPanel (AC-C3)', () => { }, }, errorMessage: '回复失败。', - responseOwnsFailure: true, }), ); }); - expect(container.querySelector('[data-testid="cli-diagnostics-banner"]')).toBeNull(); - const toggle = container.querySelector('[data-testid="cli-diagnostics-toggle"]') as HTMLButtonElement; - expect(toggle.getAttribute('aria-expanded')).toBe('false'); - act(() => toggle.click()); + expect(container.querySelector('[data-testid="cli-diagnostics-banner"]')).toBeTruthy(); expect(container.querySelector('[data-testid="cli-diagnostics"]')?.textContent).toContain('API 认证失败'); expect(container.querySelector('[data-testid="cli-diagnostics"]')?.textContent).toContain('检查 API key'); expect(container.querySelector('[data-testid="timeout-diagnostics"]')).toBeNull(); diff --git a/packages/web/src/components/__tests__/chat-input-b10-whisper-active.test.ts b/packages/web/src/components/__tests__/chat-input-b10-whisper-active.test.ts index 2f7bba7b0e..8c33581b18 100644 --- a/packages/web/src/components/__tests__/chat-input-b10-whisper-active.test.ts +++ b/packages/web/src/components/__tests__/chat-input-b10-whisper-active.test.ts @@ -159,11 +159,11 @@ describe('F122B AC-B10: whisper mode + executing cats', () => { activeInvocations: { 'inv-1': { catId: 'opus', mode: 'execute', startedAt: Date.now() } }, hasActiveInvocation: true, }); - // Before whisper mode: product-default queued-work placeholder (cat is active) + // Before whisper mode: ordinary placeholder; no guide-capable active member is declared act(() => root.render(React.createElement(ChatInput, { onSend: vi.fn(), hasActiveInvocation: true }))); const textarea = container.querySelector('textarea'); if (!textarea) throw new Error('textarea missing'); - expect(textarea.placeholder).toContain('发送后进入队列'); + expect(textarea.placeholder).toBe('输入消息... (@ 召唤猫猫 · /thread 引用对话)'); // Enter whisper mode — default is no selection (F108B P1-1 fix) enterWhisperMode(); diff --git a/packages/web/src/components/__tests__/chat-input-message-disposition.test.ts b/packages/web/src/components/__tests__/chat-input-message-disposition.test.ts index c47384b115..848163d3d3 100644 --- a/packages/web/src/components/__tests__/chat-input-message-disposition.test.ts +++ b/packages/web/src/components/__tests__/chat-input-message-disposition.test.ts @@ -24,7 +24,29 @@ vi.mock('@/hooks/useCatData', () => ({ avatar: '/opus.png', roster: { available: true }, isDefaultResponder: true, + messageDeliveryCapabilities: { guideReply: true }, }, + { + id: 'sol', + displayName: '缅因猫', + variantLabel: 'sol', + mentionPatterns: ['@sol'], + messageDeliveryCapabilities: { guideReply: true }, + }, + { + id: 'kimi', + displayName: '狸花猫', + mentionPatterns: ['@kimi'], + messageDeliveryCapabilities: { guideReply: false }, + }, + { + id: 'astra', + displayName: '缅因猫', + variantLabel: 'astra', + mentionPatterns: ['@astra'], + messageDeliveryCapabilities: { guideReply: true }, + }, + { id: 'unknown', displayName: '未知成员', mentionPatterns: [] }, ], isLoading: false, }), @@ -171,9 +193,15 @@ describe('F264 author message disposition selector', () => { it('uses one normal Send button during active work and lets Queue apply the selected strategy', async () => { const onSend = vi.fn(async () => true); + useChatStore.setState({ targetCats: ['opus', 'kimi', 'sol'] }); await renderThreadInput({ threadId: 'thread-active', onSend, hasActiveInvocation: true }); + expect(container.querySelector('textarea')?.placeholder).toBe( + '执行发生了偏离?布偶猫/缅因猫(sol)支持引导回复,可继续输入直接发送消息而不中断当前回复', + ); await chooseContinueCurrent(); - expect(container.querySelector('textarea')?.placeholder).toBe('继续输入,消息可不中断追加给当前成员...'); + expect(container.querySelector('textarea')?.placeholder).toBe( + '执行发生了偏离?布偶猫/缅因猫(sol)支持引导回复,可继续输入直接发送消息而不中断当前回复', + ); expect(container.querySelector('[data-testid="active-invocation-banner"]')).toBeNull(); act(() => setTextarea(container.querySelector('textarea') as HTMLTextAreaElement, '继续补充约束')); @@ -186,6 +214,8 @@ describe('F264 author message disposition selector', () => { await Promise.resolve(); }); expect(onSend).toHaveBeenCalledWith('继续补充约束', undefined, undefined, undefined); + act(() => useChatStore.setState({ targetCats: ['kimi', 'unknown'] })); + expect(container.querySelector('textarea')?.placeholder).toBe('输入消息... (@ 召唤猫猫 · /thread 引用对话)'); }); it('can persist the choice for this thread', async () => { diff --git a/packages/web/src/components/__tests__/chat-message-cli-diagnostics.test.tsx b/packages/web/src/components/__tests__/chat-message-cli-diagnostics.test.tsx index 0159e47d9b..ec11f0d477 100644 --- a/packages/web/src/components/__tests__/chat-message-cli-diagnostics.test.tsx +++ b/packages/web/src/components/__tests__/chat-message-cli-diagnostics.test.tsx @@ -514,7 +514,7 @@ describe('F212 Phase B — ChatMessage routes cliDiagnostics to folded panel', ( expect(container.querySelector('[data-message-id="dup-msg-unclassified"]')).toBeTruthy(); }); - describe('F117: a failed response owns its diagnostics', () => { + describe('F117: a response owns its complete failure body', () => { const timeoutDiagnostics = { silenceDurationMs: 1_800_000, processAlive: true, @@ -578,227 +578,52 @@ describe('F212 Phase B — ChatMessage routes cliDiagnostics to folded panel', ( }); } - const bubbleText = () => container.querySelector('[data-testid="message-bubble"]')?.textContent ?? ''; - const cliPanel = () => container.querySelector('[data-testid="cli-diagnostics"]'); - const timeoutPanel = () => container.querySelector('[data-testid="timeout-diagnostics"]'); - function expandCliPanel(panel = cliPanel()) { - const toggle = panel?.querySelector('[data-testid="cli-diagnostics-toggle"]'); - expect(toggle?.getAttribute('aria-expanded')).toBe('false'); - expect(panel?.querySelector('[data-testid="cli-diagnostics-cause"]')).toBeNull(); - act(() => toggle?.click()); - expect(toggle?.getAttribute('aria-expanded')).toBe('true'); - expect(panel?.querySelector('[data-testid="cli-diagnostics-banner"]')).toBeNull(); - } - - // F212 Phase B precedence carried from the error row to the response that now owns the - // failure: the Claude/Codex timeout paths emit timeout_diagnostics and then an error with - // cliDiagnostics, and both routes persist both on the failed response. it.each([ - ['with nothing streamed', ''], - ['under a streamed body', 'partial answer before the CLI failed'], - ])('a classified CLI failure outranks the timeout %s', (_label, content) => { - renderResponse(failedResponse(content, 'failed', { cliDiagnostics: classifiedCli, timeoutDiagnostics })); - - expandCliPanel(); - expect(cliPanel()?.textContent).toContain('API 认证失败'); - expect(timeoutPanel()).toBeNull(); - if (content) expect(bubbleText()).toContain(content); - }); - - it('an unclassified CLI failure yields to the timeout, so silence and process state stay visible', () => { - renderResponse(failedResponse('', 'failed', { cliDiagnostics: unclassifiedCli, timeoutDiagnostics })); - - expect(timeoutPanel()).toBeTruthy(); - expect(cliPanel()).toBeNull(); - }); - - it('a reason code this client does not know yields to the timeout', () => { - const newerCli = { - ...classifiedCli, - reasonCode: 'rate_limited_concurrent_future_code', - } as unknown as CliDiagnostics; - renderResponse(failedResponse('', 'failed', { cliDiagnostics: newerCli, timeoutDiagnostics })); - - expect(timeoutPanel()).toBeTruthy(); - expect(cliPanel()).toBeNull(); - }); - - it('a classified CLI failure without a timeout shows its panel under the failure notice', () => { - renderResponse(failedResponse('', 'failed', { cliDiagnostics: classifiedCli })); - - expect(bubbleText()).toContain('回复失败。'); - expandCliPanel(); - expect(cliPanel()?.textContent).toContain('API 认证失败'); - expect(timeoutPanel()).toBeNull(); - }); - - it('an unclassified CLI failure without a timeout still shows the CLI panel with the unknown icon', () => { - renderResponse(failedResponse('partial answer', 'failed', { cliDiagnostics: unclassifiedCli })); - - expect(bubbleText()).toContain('partial answer'); - expect(cliPanel()).toBeTruthy(); - expandCliPanel(); - expect(container.querySelector('svg[aria-label="cli-error-unknown"]')).toBeTruthy(); - expect(timeoutPanel()).toBeNull(); - }); - - it('an interrupted response shows its CLI diagnostics too', () => { - renderResponse(failedResponse('partial answer', 'interrupted', { cliDiagnostics: classifiedCli })); - - expandCliPanel(); - expect(cliPanel()?.textContent).toContain('API 认证失败'); - }); - - it('a completed response never shows a diagnostics panel', () => { - renderResponse( - failedResponse('final answer', 'completed', { cliDiagnostics: classifiedCli, timeoutDiagnostics }), - ); - - expect(cliPanel()).toBeNull(); - expect(timeoutPanel()).toBeNull(); - }); - - it('a duplicate of the panel above hides only its panel, never the response it belongs to', () => { - renderResponse(failedResponse('partial answer', 'failed', { cliDiagnostics: classifiedCli }), { - hideDiagnosticsPanel: true, - }); - - expect(cliPanel()).toBeNull(); - expect(bubbleText()).toContain('partial answer'); - expect(container.querySelector('[data-message-id="response-timeout"]')).toBeTruthy(); - }); - - it('the head of a duplicate group counts the repeats on its panel', () => { - renderResponse(failedResponse('', 'failed', { cliDiagnostics: classifiedCli }), { dedupCount: 3 }); - - expandCliPanel(); - expect(cliPanel()?.textContent).toContain('×3'); + ['failed', { cliDiagnostics: classifiedCli, timeoutDiagnostics }], + ['failed', { cliDiagnostics: unclassifiedCli, timeoutDiagnostics }], + ['failed', { cliDiagnostics: unclassifiedCli }], + ['interrupted', { cliDiagnostics: classifiedCli }], + ['completed', { cliDiagnostics: classifiedCli, timeoutDiagnostics }], + ] as const)('%s preserves the body without a second diagnostic surface', (status, extra) => { + const message = failedResponse('partial answer\ncomplete original failure', status, extra); + renderResponse(message); + expect(container.textContent).toContain('partial answer'); + expect(container.textContent).toContain('complete original failure'); + expect(container.querySelector('[data-testid="cli-diagnostics"]')).toBeNull(); + expect(container.querySelector('[data-testid="timeout-diagnostics"]')).toBeNull(); + expect(message.extra).toBe(extra); }); - it('shows the panel under the failure notice when nothing was streamed', () => { - render(failedResponse('')); - - expect(container.querySelector('[data-testid="message-bubble"]')?.textContent).toContain('回复失败。'); - const panel = container.querySelector('[data-testid="timeout-diagnostics"]'); - expect(panel).toBeTruthy(); - expect(panel?.textContent).not.toContain('回复失败。'); - expect(panel?.textContent).toContain('查看超时诊断'); + it.each([ + ['failed', '回复失败。'], + ['interrupted', '回复已中断。'], + ] as const)('an empty historic %s response keeps its single lifecycle notice', (status, label) => { + renderResponse(failedResponse('', status, { cliDiagnostics: classifiedCli, timeoutDiagnostics })); + expect(container.textContent).toContain(label); + expect(container.querySelector('[data-testid="cli-diagnostics"]')).toBeNull(); + expect(container.querySelector('[data-testid="timeout-diagnostics"]')).toBeNull(); expect(container.querySelector('[data-message-id="response-timeout"]')).toBeTruthy(); }); - it('keeps the streamed body and shows the panel under it', () => { - render(failedResponse('partial answer before the CLI went silent')); - - expect(container.querySelector('[data-testid="message-bubble"]')?.textContent).toContain( - 'partial answer before the CLI went silent', + it('independent failed responses keep both bodies and anchors without a duplicate group', () => { + const rows = ['first-r', 'second-r'].map((id) => ({ + ...failedResponse(`complete failure from ${id}`, 'failed', { cliDiagnostics: classifiedCli }), + id, + })); + const dedup = computeCliDiagnosticsDedup(rows); + expect(dedup.size).toBe(0); + act(() => + root.render( + rows.map((message) => ( + opusCat()} /> + )), + ), ); - const panel = container.querySelector('[data-testid="timeout-diagnostics"]'); - expect(panel).toBeTruthy(); - expect(panel?.textContent).not.toContain('回复失败。'); - expect(panel?.textContent).toContain('查看超时诊断'); - }); - - it('labels an interrupted response with a body as interrupted', () => { - render(failedResponse('partial answer', 'interrupted')); - - expect(container.querySelector('[data-testid="timeout-diagnostics"]')?.textContent).not.toContain('回复已中断。'); - expect(container.querySelector('[data-testid="timeout-diagnostics"]')?.textContent).toContain('查看超时诊断'); - }); - - it('never shows timeout diagnostics on a completed response', () => { - render(failedResponse('final answer', 'completed')); - - expect(container.querySelector('[data-testid="timeout-diagnostics"]')).toBeNull(); - }); - - // The list-level duplicate projection and the row must agree on which panel a row shows, - // or a hidden duplicate can be the only place a diagnosis appears. - describe('adjacent responses rendered through the duplicate projection', () => { - function responseRow( - id: string, - extra: ChatMessageType['extra'], - status: 'failed' | 'completed', - completedAt: number, - ): ChatMessageType { - return { - ...failedResponse(`answer from ${id}`, status, extra), - id, - lifecycle: { - kind: 'response', - orderKey: `100:${id}`, - invocationId: id, - targetId: 'opus', - inputEntryIds: [`entry-${id}`], - inputMessageIds: [`source-${id}`], - status, - startedAt: 100, - completedAt, - ...(status === 'completed' ? {} : { reason: 'provider_error' }), - }, - } as ChatMessageType; - } - - function renderTimeline(rows: ChatMessageType[]): void { - const dedup = computeCliDiagnosticsDedup(rows); - act(() => { - root.render( - React.createElement( - React.Fragment, - null, - rows.map((message) => - React.createElement(ChatMessage, { - key: message.id, - message, - timelineMessages: rows, - getCatById: (id: string) => (id === 'opus' ? opusCat() : undefined), - hideDiagnosticsPanel: dedup.get(message.id)?.hideDiagnosticsPanel, - dedupCount: dedup.get(message.id)?.dedupCount, - }), - ), - ), - ); - }); + for (const message of rows) { + expect(container.querySelector(`[data-message-id="${message.id}"]`)?.textContent).toContain(message.content); } - - const row = (id: string) => container.querySelector(`[data-message-id="${id}"]`); - const panelIn = (id: string, kind: 'cli' | 'timeout') => - row(id)?.querySelector(`[data-testid="${kind}-diagnostics"]`) ?? null; - - it('a CLI response after a timeout response keeps its own CLI panel', () => { - renderTimeline([ - responseRow('timeout-r', { cliDiagnostics: unclassifiedCli, timeoutDiagnostics }, 'failed', 120), - responseRow('cli-r', { cliDiagnostics: unclassifiedCli }, 'failed', 125), - ]); - - expect(panelIn('timeout-r', 'timeout')).toBeTruthy(); - expect(panelIn('timeout-r', 'cli')).toBeNull(); - expect(panelIn('cli-r', 'cli')).toBeTruthy(); - expect(container.textContent).not.toContain('×2'); - }); - - it('a response that shows no panel never heads a group', () => { - renderTimeline([ - responseRow('completed-r', { cliDiagnostics: classifiedCli }, 'completed', 120), - responseRow('failed-r', { cliDiagnostics: classifiedCli }, 'failed', 125), - ]); - - expect(panelIn('completed-r', 'cli')).toBeNull(); - expandCliPanel(panelIn('failed-r', 'cli')); - expect(panelIn('failed-r', 'cli')?.textContent).toContain('API 认证失败'); - expect(container.textContent).not.toContain('×2'); - }); - - it('a real duplicate hides only its panel, under a head that counts it', () => { - renderTimeline([ - responseRow('first-r', { cliDiagnostics: classifiedCli }, 'failed', 120), - responseRow('second-r', { cliDiagnostics: classifiedCli }, 'failed', 125), - ]); - - expandCliPanel(panelIn('first-r', 'cli')); - expect(panelIn('first-r', 'cli')?.textContent).toContain('×2'); - expect(panelIn('second-r', 'cli')).toBeNull(); - expect(row('second-r')?.textContent).toContain('answer from second-r'); - }); + expect(container.querySelector('[data-testid="cli-diagnostics"]')).toBeNull(); + expect(container.textContent).not.toContain('×2'); }); }); }); diff --git a/packages/web/src/components/assistant-message-renderability.ts b/packages/web/src/components/assistant-message-renderability.ts index fa9b5779b0..0a9ef7e117 100644 --- a/packages/web/src/components/assistant-message-renderability.ts +++ b/packages/web/src/components/assistant-message-renderability.ts @@ -47,15 +47,6 @@ export function hasAssistantBody(message: ChatMessage, context: AssistantMessage const FAILED_RESPONSE_LABEL = '回复失败。'; const INTERRUPTED_RESPONSE_LABEL = '回复已中断。'; -/** Copy naming how a response ended without success; its failure diagnostics render under it. */ -export function projectFailedResponseLabel(message: ChatMessage): string | null { - const lifecycle = message.lifecycle; - if (lifecycle?.kind !== 'response') return null; - if (lifecycle.status === 'failed') return FAILED_RESPONSE_LABEL; - if (lifecycle.status === 'interrupted') return INTERRUPTED_RESPONSE_LABEL; - return null; -} - /** Copy owned by the lifecycle frame while no streamed body exists yet. */ export function projectEmptyResponseLifecycleNotice( message: ChatMessage, diff --git a/packages/web/src/components/chat-row-surface.ts b/packages/web/src/components/chat-row-surface.ts index d055c7e783..d5dbb920e7 100644 --- a/packages/web/src/components/chat-row-surface.ts +++ b/packages/web/src/components/chat-row-surface.ts @@ -1,6 +1,5 @@ import { formatVisibleSystemInfo } from '@/hooks/system-info-visible'; import type { ChatMessage, EvidenceData, RichBlock } from '@/stores/chat-types'; -import { isAssistantAuthored, projectFailedResponseLabel } from './assistant-message-renderability'; import { isLinkedDeliveryFailureCarrier } from './MessageDispatchAvatars'; import { selectTerminalDiagnostics, type TerminalDiagnostics } from './TerminalDiagnosticsPanel'; @@ -65,20 +64,7 @@ export function projectSystemRowSurface( return selected ? { kind: 'diagnostics', selected } : { kind: 'notice', isError }; } -/** - * F118 AC-C3 / F212 / F117: a failed or interrupted response owns its failure, so the diagnostics - * explaining it render under its bubble with the error row's precedence. - */ -export function projectFailedResponseDiagnostics(message: ChatMessage): TerminalDiagnostics | null { - if (!isAssistantAuthored(message) || !projectFailedResponseLabel(message)) return null; - return selectTerminalDiagnostics(message.extra, true); -} - -/** - * The terminal-diagnostics panel a row shows, if any: an error row explains itself and a failed - * or interrupted response explains the failure it owns. Completed and processing responses, cards, - * absorbed carriers and hidden rows show none, whatever diagnostics they carry. - */ +/** Only standalone system errors render diagnostics. Responses own the full error in their body. */ export function projectRowTerminalDiagnostics( message: ChatMessage, timelineMessages: readonly ChatMessage[], @@ -88,5 +74,5 @@ export function projectRowTerminalDiagnostics( const surface = projectSystemRowSurface(message, timelineMessages); return surface.kind === 'diagnostics' ? surface.selected : null; } - return projectFailedResponseDiagnostics(message); + return null; } diff --git a/packages/web/src/components/signals/StudyFoldArea.tsx b/packages/web/src/components/signals/StudyFoldArea.tsx index 35531daea3..5de9e3a7c9 100644 --- a/packages/web/src/components/signals/StudyFoldArea.tsx +++ b/packages/web/src/components/signals/StudyFoldArea.tsx @@ -155,7 +155,7 @@ export function StudyFoldArea({ > {discussLoading ? '正在创建讨论...' : '在对话中讨论'} - {/* AC-6: 多猫研究派发 — signal param binds article context via activeSignals */} + {/* AC-6: 多猫研究派发 — 从文章页创建讨论请求 */} { + it.each([ + 'completed', + 'processing', + 'failed', + 'interrupted', + ] as const)('a %s response shows no panel, so it never heads a group', (status) => { const result = computeCliDiagnosticsDedup([ response('no-panel', { cliDiagnostics: classified }, { status }), response('failed', { cliDiagnostics: classified }), @@ -193,28 +198,12 @@ describe('computeCliDiagnosticsDedup groups only the CLI panel each row renders expect(result.size).toBe(0); }); - it('failed and interrupted responses showing the same CLI panel still collapse onto the first', () => { + it('failed and interrupted responses never collapse independent response bodies', () => { const result = computeCliDiagnosticsDedup([ response('first', { cliDiagnostics: classified }), response('second', { cliDiagnostics: classified }, { status: 'interrupted', completedAt: 1600 }), ]); - - expect(result.get('first')).toEqual({ dedupCount: 2, hideDiagnosticsPanel: false }); - expect(result.get('second')).toEqual({ dedupCount: 0, hideDiagnosticsPanel: true }); - }); - - it('measures the window on the presentation clock, where a terminal response sits at completion', () => { - const admittedTogetherEndedApart = computeCliDiagnosticsDedup([ - response('a', { cliDiagnostics: classified }, { admittedAt: 1000, completedAt: 5_000 }), - response('b', { cliDiagnostics: classified }, { admittedAt: 2000, completedAt: 65_000 }), - ]); - const admittedApartEndedTogether = computeCliDiagnosticsDedup([ - response('a', { cliDiagnostics: classified }, { admittedAt: 1000, completedAt: 70_000 }), - response('b', { cliDiagnostics: classified }, { admittedAt: 61_000, completedAt: 71_000 }), - ]); - - expect(admittedTogetherEndedApart.size).toBe(0); - expect(admittedApartEndedTogether.get('b')?.hideDiagnosticsPanel).toBe(true); + expect(result.size).toBe(0); }); it('a hidden protocol row carrying diagnostics never heads a group', () => { diff --git a/packages/web/src/utils/cli-diagnostics-dedup.ts b/packages/web/src/utils/cli-diagnostics-dedup.ts index 2137444e3d..85b9e80dba 100644 --- a/packages/web/src/utils/cli-diagnostics-dedup.ts +++ b/packages/web/src/utils/cli-diagnostics-dedup.ts @@ -10,13 +10,13 @@ * * Strategy: walk the rows in the order they render and group adjacent rows whose rendered CLI * panel shares the same reasonCode + publicSummary fingerprint within a window on the - * presentation clock (a terminal response sits at its completion time). The group head keeps + * presentation clock. The group head keeps * the full panel + badge "×N"; later rows hide that panel only (the chat bubble and signature * stay). * * A row joins only with the CLI panel it actually renders (`projectRowTerminalDiagnostics`, the * decision ChatMessage renders), never with the raw `cliDiagnostics` it carries: a row showing - * the timeout panel, no panel (a completed or processing response) or nothing at all breaks the + * the timeout panel, no panel (any response) or nothing at all breaks the * group like any other row, so a hidden panel is always one its group head visibly shows (F117). * * Adjacency-only dedup: any row between two same-fingerprint panels breaks the group, so diff --git a/scripts/generate-hook-manifests.mjs b/scripts/generate-hook-manifests.mjs index 06a71b1e4b..2f9db8a02d 100644 --- a/scripts/generate-hook-manifests.mjs +++ b/scripts/generate-hook-manifests.mjs @@ -19,7 +19,7 @@ const ROOT = join(__dirname, '..'); // Phase 1 manifest data (inline since the YAML was removed from tree) // --------------------------------------------------------------------------- -// Only include the 46 Tier 1 pipeline hooks (not N2, M1, M2, H1-H3) +// Only include the 45 active Tier 1 pipeline hooks (not N2, M1, M2, H1-H3) const TIER1_HOOKS = [ // Session-init stage: L1-L7, S1-S13, B1, C1 (22 hooks) { @@ -324,7 +324,7 @@ const TIER1_HOOKS = [ userExplanation: 'MCP 服务可用时注入的回调提示', }, - // Per-turn stage: D1-D21, R1-R2, N1 (24 hooks) + // Per-turn stage: D1-D19, D21, R1-R2, N1 (23 hooks; D20 retired) { id: 'D1', name: '身份锚定', @@ -591,20 +591,6 @@ const TIER1_HOOKS = [ inputs: ['constitutionalKnowledge'], userExplanation: '核心治理知识和决策框架', }, - { - id: 'D20', - name: '信号文章', - stage: 'per-turn', - order: 2000, - template: 'd20-signal-articles.md', - resolver: 'D20SignalArticlesResolver', - disableable: true, - safetyTier: 'readonly', - transparencyTier: 'opt-in-view', - governanceTier: 'human-gated', - inputs: ['signalArticles'], - userExplanation: '相关的信号/知识文章摘要', - }, { id: 'D21', name: '决策树',