From f6a19bf100194ee11b1fda5ef0b9e3e5b41cb7b6 Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Mon, 20 Jul 2026 21:49:35 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(F257):=20#4=20=E7=AD=BE=E5=90=8D=20lin?= =?UTF-8?q?t=20O2=E2=86=92O1=20=E2=80=94=20=E6=A3=80=E6=B5=8B=E5=B1=82?= =?UTF-8?q?=EF=BC=88observe-only=EF=BC=89+=20=E8=B4=A6=E6=9C=AC=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=E6=98=BE=E5=BC=8F=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: 消息末行 [昵称/模型🐾] 签名此前仅靠 prompt 文本约定(O2 观测层),零 结构覆盖 → dev-7a882ba0(Fable 漏签靠 operator 人工发现)。本切片交付 #4 的 **检测层**(O1 结构强制):agent 消息末行是否以被识别的猫签名结尾。 两阶段(owner vision-guardian 校准,AC 完成 ≠ feature 完成): - ① 检测层(本切片):复用 isCatSignatureLine(F167,不重造正则)→ post-seam 记 message.extra.signatureLint(message 级可观测、denominator-bearing、 observe-only 非阻断,绝不 reject 已持久化用户可见消息)。 - ② 账本闭环(deferred 到切片 2 / #3 后):检测到 miss 自动 emit deviation 归因 obj-identity-integrity 进 harness ledger,自动化 dev-7a882ba0 的手动 report_harness_signal。deferred 原因:ledger 读 DeviationEventLog/ GuardRejectionEventLog/eval verdict 不扫 message.extra;正确 deviation 需 registered objective(否则重蹈 #3 修的 free-string 考古)+ segment/condition 归因基建(condition_hit 需 condition registry+typed fact,均属 #2/#3 数据根)。 extra-only ≠ #4 完成——已在 spec #4 + 代码 SCOPE 注释显式在案。 设计自决:observe-only 非阻断(阻断逼近铁律5/硬排除,风险高);#4 检测 ⊥ #3 (不依赖 registry);signatureLintExtra helper 提取使 post-message 巨型 handler biome 复杂度对基线 0 delta。 证据:19 单测(纯函数+helper)+ 2 wiring e2e(extra.signatureLint signed true/false)+ 79 blast-radius 回归全绿;tsc/biome clean;新文件 65 行、无 any。 [宪宪/claude-opus-4-8🐾] Co-Authored-By: Claude Opus 4.8 --- docs/features/F257-harness-ledger.md | 2 +- .../agents/routing/cat-signature-lint.ts | 78 ++++++++++++ .../services/stores/ports/MessageStore.ts | 10 ++ packages/api/src/routes/callbacks.ts | 20 ++- .../api/test/callback-a2a-postmsg.test.js | 36 ++++++ packages/api/test/f257-signature-lint.test.js | 119 ++++++++++++++++++ 6 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts create mode 100644 packages/api/test/f257-signature-lint.test.js diff --git a/docs/features/F257-harness-ledger.md b/docs/features/F257-harness-ledger.md index 8dc4978a9e..d05b589c93 100644 --- a/docs/features/F257-harness-ledger.md +++ b/docs/features/F257-harness-ledger.md @@ -195,7 +195,7 @@ governance_actions: 合并 | 禁用 | 修改 | 新增 # 治理单位是段( 1. **昵称唯一性与模糊 @ fail-closed**(归属 F167/路由域,坐标记录于此):`cat-template.json` 把 nickname 定义在 roleTemplate(家族)层——opus 实例 nickname="宪宪"(L188) + patterns 含 @宪宪(L194),codex nickname="砚砚"(L357/363) 与 sol/terra 三猫共用 → @昵称确定性投错猫 + persona 注入错身份(dev-628ea4d1 根因坐标)。修复:nickname per-cat 唯一 + config 加载时 mentionPatterns 冲突 fail-closed + 多命中拒绝路由要求显式 handle。**状态:✅ PR #44(`10dacad2b`)已合入。** 2. **L 系列段观测粒度**:native-L0 路径 collectTrace 只记 session-init-pack-only 聚合(trace-collector.ts else-if 分支),不做 per-segment 拆解 → L 系列 Console 全体"无数据"。修复:L0 编译器持有确定段清单,直录 trace(结构化直录,不解析文本)。 3. **objective 目录运行时发现机制**:report_harness_signal 的 objectiveId 为自由 string,无 list 工具/schema 枚举/harness 注入清单——三次上报三次考古文档(含一次归因困难降权 0.6)。修复:objective registry 只读发现接口 + 工具 description 同步。 -4. **签名 lint O2→O1**:消息末行签名 [昵称/模型🐾] 是完美可 lint 断言,当前零结构覆盖(dev-7a882ba0:靠 operator 人工发现)。 +4. **签名 lint O2→O1**:消息末行签名 [昵称/模型🐾] 是完美可 lint 断言,当前零结构覆盖(dev-7a882ba0:靠 operator 人工发现)。**两阶段**(2026-07-20 owner vision-guardian 校准,AC 完成 ≠ feature 完成):**① 检测层**(切片 1,PR 待提)——复用 `isCatSignatureLine` 结构化检测 + post-seam 记 `message.extra.signatureLint`(message 级可观测、denominator-bearing、observe-only 非阻断);**② 账本闭环**(deferred 到切片 2 / #3 后)——检测到 miss 自动 emit deviation 归因 `obj-identity-integrity` 进 harness ledger,把 dev-7a882ba0 的**手动** `report_harness_signal` 上报**自动化**。闭环 deferred 原因:harness ledger 读 DeviationEventLog/GuardRejectionEventLog/eval verdict,**不扫 message.extra**;正确 deviation 需 registered objective(否则重蹈 #3 修的 free-string 考古)+ segment/condition 归因基建(属 #2/#3 数据根)。**`extra`-only ≠ #4 完成**。 5. **运行实例写保护**:pre-commit hook 白名单(仅 §14 共享状态文档路径),把 LI-004 从认知纪律降为结构强制(dev-af6d4e28:平行实例任务错位 merge 污染运行基线,V1 一度整体不在运行树)。**状态:✅ PR #44(`10dacad2b`)已合入。** 6. **Console 收尾包**(已固化验收判据于 V2 thread):activeStage/actionableStage 分离、eval 窗口标注(18 vs 0 类矛盾)、判定词解释、tracing 锚点回放剧场式下钻(历史版本渲染防伪造现场)、变量段编辑呈现、启禁用矩阵测试。 diff --git a/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts b/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts new file mode 100644 index 0000000000..8ff397d336 --- /dev/null +++ b/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts @@ -0,0 +1,78 @@ +/** + * F257 修复清单 #4 — message-signature structural lint (O2→O1). + * + * The trailing `[昵称/模型🐾]` signature is an L0 identity convention enforced + * only by prompt text (O2 观测层). dev-7a882ba0: Fable 漏签靠 operator 人工发现 — + * zero structural coverage. This module upgrades the convention to a + * regex-decidable structural assertion (O1 结构强制): does an agent message end + * with a recognized cat signature? + * + * Reuses the single-source-of-truth matcher `isCatSignatureLine` (F167 + * `cat-signature-strip.ts`, narrowed over many FP-reduction rounds R3–R7) — + * this module never re-implements the regex. It is presence-only (does a + * trailing signature exist), NOT identity-correctness (does the signature match + * the posting cat); the latter needs per-cat nickname/model resolution (F257 #1 + * territory) and is out of scope for the minimal O1 upgrade. + * + * Consumed observe-only at the agent post-message seam (routes/callbacks.ts): + * the boolean result rides on message `extra.signatureLint`, making per-message + * sign status a structured field (queryable, denominator-bearing) instead of a + * prose convention discovered by eye. Non-blocking by design: record a miss, + * never reject a persisted user-visible message. + * + * SCOPE — two-phase (F257 owner vision-guardian review 2026-07-20; AC 完成 ≠ + * feature 完成). This module + the post seam are the **detection layer** (O1 + * structural detection, message-level observable). The harness **ledger closure** + * — auto-emitting a deviation on miss, attributed to `obj-identity-integrity`, + * so signature-miss rate/trend becomes ledger-observable (automating the manual + * `report_harness_signal` that recorded dev-7a882ba0) — is DEFERRED to after #3 + * (objective registry). Why deferred: the harness ledger reads + * DeviationEventLog / GuardRejectionEventLog / eval verdicts, NOT `message.extra`; + * a correct deviation needs a *registered* objective (else it reintroduces the + * free-string-objective archaeology #3 fixes) + the segment/condition attribution + * infra of the #2/#3 data root. `extra.signatureLint` is the interim detection + * observable — NOT the ledger closure; do not read extra-only as "#4 complete". + */ + +import { isCatSignatureLine } from './cat-signature-strip.js'; + +export interface SignatureLintResult { + /** true iff the last non-blank line is a recognized cat signature. */ + signed: boolean; + /** the matched signature line (trimmed) when signed; null otherwise. */ + signatureLine: string | null; +} + +const UNSIGNED: SignatureLintResult = { signed: false, signatureLine: null }; + +/** + * Structurally lint whether `text` ends with a trailing cat signature. + * + * Walks from the last line backwards skipping blank lines; the first non-blank + * line decides. A signature that is NOT trailing (content follows it) does not + * count — the L0 convention is that the signature is the final line, and the + * routing strip walker (`stripTrailingCatSignatures`) uses the same tail-anchored + * rule. + */ +export function lintCatSignature(text: string): SignatureLintResult { + if (!text) return UNSIGNED; + const lines = text.split(/\r?\n/); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i] ?? ''; + if (line.trim() === '') continue; // skip trailing blank lines + return isCatSignatureLine(line) ? { signed: true, signatureLine: line.trim() } : UNSIGNED; + } + return UNSIGNED; // all-blank / empty +} + +/** + * Post-seam projection: the observe-only `extra.signatureLint` fragment for a + * message. Empty for blank/whitespace content — pure-media posts carry no text + * signature, so they get no lint verdict and stay out of the sign-rate + * denominator. Spread into the message `extra` bag at the post-message seam so + * the branch-complexity stays out of the (already large) callback handler. + */ +export function signatureLintExtra(text: string): { signatureLint?: { signed: boolean } } { + if (!text.trim()) return {}; + return { signatureLint: { signed: lintCatSignature(text).signed } }; +} 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 6651f85681..b93781fb76 100644 --- a/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts +++ b/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts @@ -116,6 +116,16 @@ export interface StoredMessage { tracing?: { traceId: string; spanId: string; parentSpanId?: string }; systemKind?: 'a2a_routing' | 'context_briefing'; a2aRouting?: { fromCatId?: string; targetCatId?: string; invocationId?: string }; + /** + * F257 #4 (detection layer): message-signature lint result (O2→O1). Recorded + * observe-only on text-bearing agent messages at post time — `signed` = did + * the message end with a recognized `[昵称/模型🐾]` signature. Presence marks + * a linted message (denominator); absence = legacy/pre-lint. Never blocks. + * NOTE: this is the message-level detection observable, NOT the harness-ledger + * closure (auto-deviation → obj-identity-integrity), which is deferred to + * post-#3 — see cat-signature-lint.ts SCOPE note. + */ + signatureLint?: { signed: boolean }; }; /** CatIds mentioned in this message */ mentions: readonly CatId[]; diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts index b5f79cb653..254c046800 100644 --- a/packages/api/src/routes/callbacks.ts +++ b/packages/api/src/routes/callbacks.ts @@ -24,6 +24,7 @@ import { getRichBlockBuffer } from '../domains/cats/services/agents/invocation/R import { stampVisibleTurn } from '../domains/cats/services/agents/invocation/visible-turn.js'; import { extractImagePaths, extractImageUrls } from '../domains/cats/services/agents/providers/image-paths.js'; import { analyzeA2AMentions } from '../domains/cats/services/agents/routing/a2a-mentions.js'; +import { signatureLintExtra } from '../domains/cats/services/agents/routing/cat-signature-lint.js'; import { resolveCatTarget } from '../domains/cats/services/agents/routing/cat-target-resolver.js'; import { extractRichFromText } from '../domains/cats/services/agents/routing/rich-block-extract.js'; import { buildVoteNotification } from '../domains/cats/services/agents/routing/vote-intercept.js'; @@ -1024,7 +1025,14 @@ export const callbacksRoutes: FastifyPluginAsync = async const targetCatsExtra = validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}; // #814: Mark as explicit post_message so frontend TD112 dedup does not // merge this into the cat's CLI stream bubble. - const extraParts = { isExplicitPost: true as const, ...richExtra, ...targetCatsExtra }; + // F257 #4: O2→O1 signature lint — observe-only structured signal recorded on + // text-bearing agent messages (non-blocking; never rejects a persisted message). + const extraParts = { + isExplicitPost: true as const, + ...richExtra, + ...targetCatsExtra, + ...signatureLintExtra(storedContent), + }; const extra = Object.keys(extraParts).length > 0 ? extraParts : undefined; const hasA2AMentions = !!(mentions.length > 0 && router && invocationRecordStore && effectiveThreadId); @@ -1687,7 +1695,15 @@ export const callbacksRoutes: FastifyPluginAsync = async const targetCatsExtra = validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}; // #814: Mark as explicit post_message so frontend TD112 dedup does not // merge this into the cat's CLI stream bubble. - const extraParts = { isExplicitPost: true as const, ...richExtra, ...crossPostExtra, ...targetCatsExtra }; + // F257 #4: O2→O1 signature lint — observe-only structured signal recorded on + // text-bearing agent messages (non-blocking; never rejects a persisted message). + const extraParts = { + isExplicitPost: true as const, + ...richExtra, + ...crossPostExtra, + ...targetCatsExtra, + ...signatureLintExtra(storedContent), + }; const extra = Object.keys(extraParts).length > 0 ? extraParts : undefined; // F121: Validate replyTo — must exist in the same thread diff --git a/packages/api/test/callback-a2a-postmsg.test.js b/packages/api/test/callback-a2a-postmsg.test.js index d20cd27c64..54e92a38a8 100644 --- a/packages/api/test/callback-a2a-postmsg.test.js +++ b/packages/api/test/callback-a2a-postmsg.test.js @@ -207,6 +207,42 @@ describe('post_message A2A mention invocation', () => { assert.deepEqual(invocationRecordStore.getRecords()[0].targetCats, ['codex']); }); + // F257 #4 — O2→O1 signature lint wiring: the post seam records extra.signatureLint + // observe-only (non-blocking) on text-bearing agent messages (dev-7a882ba0 class). + test('post-message WITHOUT trailing signature records extra.signatureLint.signed=false', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: 'LGTM, merging now.' }, + }); + + assert.equal(response.statusCode, 200); + const recent = messageStore.getRecent(10); + assert.equal(recent.length, 1); + assert.deepEqual(recent[0].extra?.signatureLint, { signed: false }); + }); + + test('post-message WITH trailing signature records extra.signatureLint.signed=true', async () => { + const app = await createApp(); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', { threadId: 't1' }); + + const response = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { content: '修复完成了,已跑通 gate。\n\n[砚砚/Codex🐾]' }, + }); + + assert.equal(response.statusCode, 200); + const recent = messageStore.getRecent(10); + assert.equal(recent.length, 1); + assert.deepEqual(recent[0].extra?.signatureLint, { signed: true }); + }); + test('post-message duplicate retry recovers a queued A2A callback before returning duplicate', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const queueProcessor = { diff --git a/packages/api/test/f257-signature-lint.test.js b/packages/api/test/f257-signature-lint.test.js new file mode 100644 index 0000000000..0085eb8eee --- /dev/null +++ b/packages/api/test/f257-signature-lint.test.js @@ -0,0 +1,119 @@ +/** + * F257 修复清单 #4 — message-signature structural lint (O2→O1). + * + * 真相源:docs/features/F257-harness-ledger.md L198 —「签名 lint O2→O1:消息末行 + * 签名 [昵称/模型🐾] 是完美可 lint 断言,当前零结构覆盖(dev-7a882ba0:靠 operator + * 人工发现)」。本测试锁定 `lintCatSignature` 的结构化断言契约:agent 消息末行是否 + * 以一个被识别的猫签名结尾。 + * + * 设计:presence-only(是否有 trailing 签名),非 identity-correctness(是否匹配发帖 + * 猫本身——那需要 per-cat nickname/model 解析,属 F257 #1 域,最小 O1 升级不含)。 + * 判定复用 `isCatSignatureLine`(F167 cat-signature-strip,多轮 FP 收敛真相源),本 + * 测试只验 walk 逻辑 + 代表性签名形态的端到端委托,不重测每一条 FP 边界(那是 + * cat-signature-strip 自身测试的职责)。 + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + lintCatSignature, + signatureLintExtra, +} from '../dist/domains/cats/services/agents/routing/cat-signature-lint.js'; + +describe('F257 #4 — lintCatSignature (O2→O1 message-signature structural lint)', () => { + // --- 正例:末行是有效 trailing 签名(委托 isCatSignatureLine)--- + test('pawed slashed 签名在末行 → signed,返回 trimmed 签名行', () => { + const r = lintCatSignature('Some review text.\n\n[宪宪/claude-opus-4-8🐾]'); + assert.equal(r.signed, true); + assert.equal(r.signatureLine, '[宪宪/claude-opus-4-8🐾]'); + }); + + test('pawed slashless 签名 [Spark🐾] → signed', () => { + assert.equal(lintCatSignature('done\n\n[Spark🐾]').signed, true); + }); + + test('legacy 无爪 slashed 签名 [砚砚/GPT-5.5] → signed', () => { + assert.equal(lintCatSignature('merged\n[砚砚/GPT-5.5]').signed, true); + }); + + test('整条消息就是一个签名 → signed', () => { + assert.equal(lintCatSignature('[宪宪/claude-opus-4-8🐾]').signed, true); + }); + + // --- walk 逻辑:跳过 trailing 空行 / 行内空白 --- + test('签名后有 trailing 空行 → 仍 signed(跳过空行)', () => { + assert.equal(lintCatSignature('text\n[烁烁🐾]\n\n \n').signed, true); + }); + + test('签名行含前后空白 → signed,signatureLine 已 trim', () => { + const r = lintCatSignature('text\n [宪宪/Opus-46🐾] '); + assert.equal(r.signed, true); + assert.equal(r.signatureLine, '[宪宪/Opus-46🐾]'); + }); + + test('\\r\\n 换行 → 正确 walk', () => { + assert.equal(lintCatSignature('line1\r\nline2\r\n[砚砚/Codex🐾]\r\n').signed, true); + }); + + // --- 反例:签名非末尾(其后还有内容行)→ 不算(约定是签名为最后一行)--- + test('签名后还有内容行 → NOT signed(必须 trailing)', () => { + const r = lintCatSignature('[宪宪🐾]\n\nPS: one more thing.'); + assert.equal(r.signed, false); + assert.equal(r.signatureLine, null); + }); + + // --- 反例:完全没有签名(dev-7a882ba0 漏签类)--- + test('普通消息无签名 → not signed', () => { + assert.equal(lintCatSignature('LGTM, merging now.').signed, false); + }); + + test('空串 → not signed', () => { + const r = lintCatSignature(''); + assert.equal(r.signed, false); + assert.equal(r.signatureLine, null); + }); + + test('纯空白 → not signed', () => { + assert.equal(lintCatSignature(' \n\n ').signed, false); + }); + + // --- 反例:FP 形态(复用 isCatSignatureLine 的两侧精确 allowlist 边界)--- + test('正文 token [Phase B] → not signed', () => { + assert.equal(lintCatSignature('Update:\n[Phase B]').signed, false); + }); + + test('括号文件路径 [packages/api/src/foo.ts] → not signed', () => { + assert.equal(lintCatSignature('see\n[packages/api/src/foo.ts]').signed, false); + }); + + test('provider/path LHS FP [openai/GPT-5.5] → not signed', () => { + assert.equal(lintCatSignature('ref\n[openai/GPT-5.5]').signed, false); + }); + + test('CJK 语义标签 FP [模型/GPT-5.5] → not signed', () => { + assert.equal(lintCatSignature('x\n[模型/GPT-5.5]').signed, false); + }); + + test('终端引用 [PR/2442] → not signed', () => { + assert.equal(lintCatSignature('landed\n[PR/2442]').signed, false); + }); +}); + +describe('F257 #4 — signatureLintExtra (post-seam extra projection)', () => { + test('signed message → { signatureLint: { signed: true } }', () => { + assert.deepEqual(signatureLintExtra('done\n\n[宪宪/claude-opus-4-8🐾]'), { + signatureLint: { signed: true }, + }); + }); + + test('unsigned text message → { signatureLint: { signed: false } }', () => { + assert.deepEqual(signatureLintExtra('LGTM, merging now.'), { + signatureLint: { signed: false }, + }); + }); + + test('blank/whitespace content → {} (pure-media exclusion, out of denominator)', () => { + assert.deepEqual(signatureLintExtra(''), {}); + assert.deepEqual(signatureLintExtra(' \n\n '), {}); + }); +}); From bd736e2635c6d50bdc9881e44479a98882430363 Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Tue, 21 Jul 2026 00:35:31 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(F257):=20#4=20=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E5=B1=82=E5=AE=8C=E6=95=B4=E6=80=A7=20=E2=80=94=20sol=20R3=20R?= =?UTF-8?q?EQUEST=20CHANGES=20=E5=85=A8=E9=83=A8=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=88P1=C3=973=20+=20P2=C3=971=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 review 四项全部核实成立(读码验证,非反射接受),本 commit 逐项修复 + red→green: P1-3 严格契约 matcher(correctness):lintCatSignature 之前复用 isCatSignatureLine (routing 的 permissive STRIP matcher,容忍 [Spark🐾] 无模型 / [砚砚/GPT-5.5] 无爪), 把契约违规签名误判为 compliant。改用严格 `[nickname/model🐾]` 正则(slash+model+paw 必需),断言 governance-l0「签名必须含模型型号」。strip=permissive(routing) 与 lint=strict(compliance) 分离。两个原正例 flip 成反例 + 保留严格正例。 P1-1 覆盖(coverage):签名 lint 之前只接 callback post-message 两支,漏掉 serial/ parallel 以 origin:'stream' 持久化的普通 agent 最终文本 → 从不 post_message 的猫不入 分母,签名率系统性偏差。route-serial.ts:2805 + route-parallel.ts:1248 各接 signatureLintExtra(storedContent)。加 serial+parallel 集成测试(signed/unsigned 均入分母)。 P1-2 持久化(durability):serializeExtra 写入但 safeParseExtra 白名单重建时丢弃 signatureLint → Redis 回读后字段消失(in-memory 测试测不到)。parser 加 signatureLint strict 支持(typeof signed === 'boolean')+ serialize→parse round-trip 测试(true/false/ malformed drop/其他字段不受影响)。 P2-1 可达(reachability):GET /api/messages 白名单、两处 callback socket broadcast、 web ChatMessage.extra、server AgentMessage.extra 均漏 signatureLint → Console 读不到。 逐一透传(broadcast 转发 storedMsg.extra.signatureLint,单一真相源)+ cold-hydration (GET)+ live-broadcast 契约测试。无 UI 渲染(#6 消费)。 证据:F257 slice-1 全套 82 tests + blast-radius 回归 60 tests 全绿;tsc/web-tsc clean; biome 新文件 clean,route-serial/parallel/callbacks 复杂度对基线 0 delta;safeParseExtra 48→50、messages GET 70→73(+2/+3,逐字段 established pattern,函数本已远超限,无新增 erroring function)。两阶段账本闭环仍 deferred(sol 已 accept)。 [宪宪/claude-opus-4-8🐾] Co-Authored-By: Claude Opus 4.8 --- .../agents/routing/cat-signature-lint.ts | 80 +++++----- .../services/agents/routing/route-parallel.ts | 4 + .../services/agents/routing/route-serial.ts | 4 + .../stores/redis/redis-message-parsers.ts | 13 ++ .../api/src/domains/cats/services/types.ts | 2 + packages/api/src/routes/callbacks.ts | 4 + packages/api/src/routes/messages.ts | 5 +- .../api/test/callback-a2a-postmsg.test.js | 4 + ...257-signature-lint-redis-roundtrip.test.js | 54 +++++++ .../f257-signature-lint-stream-final.test.js | 139 ++++++++++++++++++ packages/api/test/f257-signature-lint.test.js | 87 ++++++----- packages/api/test/messages-endpoint.test.js | 21 +++ packages/web/src/stores/chat-types.ts | 3 + 13 files changed, 343 insertions(+), 77 deletions(-) create mode 100644 packages/api/test/f257-signature-lint-redis-roundtrip.test.js create mode 100644 packages/api/test/f257-signature-lint-stream-final.test.js diff --git a/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts b/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts index 8ff397d336..8ceb324096 100644 --- a/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts +++ b/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts @@ -1,43 +1,48 @@ /** - * F257 修复清单 #4 — message-signature structural lint (O2→O1). + * F257 修复清单 #4 — message-signature structural lint (O2→O1), detection layer. * - * The trailing `[昵称/模型🐾]` signature is an L0 identity convention enforced - * only by prompt text (O2 观测层). dev-7a882ba0: Fable 漏签靠 operator 人工发现 — - * zero structural coverage. This module upgrades the convention to a - * regex-decidable structural assertion (O1 结构强制): does an agent message end - * with a recognized cat signature? + * The trailing `[昵称/模型🐾]` signature is an L0 identity contract enforced only + * by prompt text (O2 观测层): governance-l0 「用自己的身份签名 `[昵称/模型🐾]`, + * 签名必须含模型型号」. dev-7a882ba0: Fable 漏签靠 operator 人工发现 — zero + * structural coverage. This module upgrades the convention to a regex-decidable + * structural assertion (O1 结构强制): does an agent message end with a + * contract-shaped `[nickname/model🐾]` signature? * - * Reuses the single-source-of-truth matcher `isCatSignatureLine` (F167 - * `cat-signature-strip.ts`, narrowed over many FP-reduction rounds R3–R7) — - * this module never re-implements the regex. It is presence-only (does a - * trailing signature exist), NOT identity-correctness (does the signature match - * the posting cat); the latter needs per-cat nickname/model resolution (F257 #1 - * territory) and is out of scope for the minimal O1 upgrade. - * - * Consumed observe-only at the agent post-message seam (routes/callbacks.ts): - * the boolean result rides on message `extra.signatureLint`, making per-message - * sign status a structured field (queryable, denominator-bearing) instead of a - * prose convention discovered by eye. Non-blocking by design: record a miss, - * never reject a persisted user-visible message. + * STRICTNESS (sol R1 P1-3). This is a COMPLIANCE lint, so it asserts the current + * contract shape — nickname + '/' + model + 🐾 — and does NOT reuse + * `isCatSignatureLine` from `cat-signature-strip.ts`. That matcher is a + * routing-STRIP matcher, permissive BY DESIGN (it also accepts pawed-slashless + * `[Spark🐾]` and legacy un-pawed `[砚砚/GPT-5.5]` so historical routing suffixes + * still get stripped). Reusing it as a compliance predicate produced false + * negatives — model-less / paw-less signatures counted as compliant. The two + * matchers are kept separate: strip = permissive (routing), lint = strict + * (compliance). Scope split: presence-only (contract SHAPE present) — matching + * the signature to the POSTING cat (identity-correctness) stays deferred. * * SCOPE — two-phase (F257 owner vision-guardian review 2026-07-20; AC 完成 ≠ - * feature 完成). This module + the post seam are the **detection layer** (O1 - * structural detection, message-level observable). The harness **ledger closure** - * — auto-emitting a deviation on miss, attributed to `obj-identity-integrity`, - * so signature-miss rate/trend becomes ledger-observable (automating the manual - * `report_harness_signal` that recorded dev-7a882ba0) — is DEFERRED to after #3 - * (objective registry). Why deferred: the harness ledger reads - * DeviationEventLog / GuardRejectionEventLog / eval verdicts, NOT `message.extra`; - * a correct deviation needs a *registered* objective (else it reintroduces the - * free-string-objective archaeology #3 fixes) + the segment/condition attribution - * infra of the #2/#3 data root. `extra.signatureLint` is the interim detection - * observable — NOT the ledger closure; do not read extra-only as "#4 complete". + * feature 完成). This module + the persistence seams are the **detection layer** + * (O1 structural detection, message-level observable). The harness **ledger + * closure** — auto-emitting a deviation on miss, attributed to + * `obj-identity-integrity` (automating the manual `report_harness_signal` that + * recorded dev-7a882ba0) — is DEFERRED to after #3 (objective registry). Why + * deferred: the ledger reads DeviationEventLog / GuardRejectionEventLog / eval + * verdicts, NOT `message.extra`; a correct deviation needs a *registered* + * objective (else it reintroduces the free-string-objective archaeology #3 + * fixes) + the segment/condition attribution infra of the #2/#3 data root. + * `extra.signatureLint` is the interim detection observable — NOT the ledger + * closure; do not read extra-only as "#4 complete". */ -import { isCatSignatureLine } from './cat-signature-strip.js'; +/** + * Strict compliance matcher for the current signature contract `[昵称/模型🐾]`: + * '[' + non-empty nickname (no '/') + '/' + non-empty model (no '/') + 🐾 + ']'. + * Rejects `[Spark🐾]` (no model), `[砚砚/GPT-5.5]` (no paw), and bare tokens — + * exactly the forms the permissive strip matcher tolerates. + */ +const STRICT_SIGNATURE_LINE_RE = /^\s*\[[^[\]\n/]+\/[^[\]\n/]+🐾\]\s*$/u; export interface SignatureLintResult { - /** true iff the last non-blank line is a recognized cat signature. */ + /** true iff the last non-blank line is a contract-shaped `[nickname/model🐾]` signature. */ signed: boolean; /** the matched signature line (trimmed) when signed; null otherwise. */ signatureLine: string | null; @@ -46,13 +51,12 @@ export interface SignatureLintResult { const UNSIGNED: SignatureLintResult = { signed: false, signatureLine: null }; /** - * Structurally lint whether `text` ends with a trailing cat signature. + * Structurally lint whether `text` ends with a trailing contract-shaped + * signature. * * Walks from the last line backwards skipping blank lines; the first non-blank * line decides. A signature that is NOT trailing (content follows it) does not - * count — the L0 convention is that the signature is the final line, and the - * routing strip walker (`stripTrailingCatSignatures`) uses the same tail-anchored - * rule. + * count — the L0 convention is that the signature is the final line. */ export function lintCatSignature(text: string): SignatureLintResult { if (!text) return UNSIGNED; @@ -60,7 +64,7 @@ export function lintCatSignature(text: string): SignatureLintResult { for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i] ?? ''; if (line.trim() === '') continue; // skip trailing blank lines - return isCatSignatureLine(line) ? { signed: true, signatureLine: line.trim() } : UNSIGNED; + return STRICT_SIGNATURE_LINE_RE.test(line) ? { signed: true, signatureLine: line.trim() } : UNSIGNED; } return UNSIGNED; // all-blank / empty } @@ -69,8 +73,8 @@ export function lintCatSignature(text: string): SignatureLintResult { * Post-seam projection: the observe-only `extra.signatureLint` fragment for a * message. Empty for blank/whitespace content — pure-media posts carry no text * signature, so they get no lint verdict and stay out of the sign-rate - * denominator. Spread into the message `extra` bag at the post-message seam so - * the branch-complexity stays out of the (already large) callback handler. + * denominator. Spread into the message `extra` bag at every text-bearing + * cat-final persistence seam (callback post + serial/parallel stream final). */ export function signatureLintExtra(text: string): { signatureLint?: { signed: boolean } } { if (!text.trim()) return {}; diff --git a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts index 7e176a15c4..9dc5e88b01 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts @@ -60,6 +60,7 @@ import { resolveDefaultClaudeMcpServerPath } from '../providers/ClaudeAgentServi import { parseA2AMentions } from '../routing/a2a-mentions.js'; import { accumulateTextAggregate } from '../text-aggregation.js'; import { analyzeA2AMentions } from './a2a-mentions.js'; +import { signatureLintExtra } from './cat-signature-lint.js'; import { type ContextEvalInput, extractContextEvalSignals } from './context-eval.js'; import { buildBriefingMessage } from './format-briefing.js'; import { extractRichFromText, isValidRichBlock } from './rich-block-extract.js'; @@ -1272,6 +1273,9 @@ export async function* routeParallel( } : {}), ...(msg.tracing ? { tracing: msg.tracing } : {}), + // F257 #4 (sol R1 P1-1): stamp signature lint on the ordinary agent + // stream-final so it enters the sign-rate denominator, not just callback posts. + ...signatureLintExtra(storedContent), }, }); const triagePlanStore = deps.invocationDeps.conciergeTriagePlanStore; 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 68fe50ac2e..0986568314 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 @@ -129,6 +129,7 @@ import { import { accumulateTextAggregate } from '../text-aggregation.js'; import { classifyDurableTriggerResult, evaluateAckLiveness } from './a2a-ack-liveness.js'; import { formatA2AHandoffContent } from './a2a-handoff-label.js'; +import { signatureLintExtra } from './cat-signature-lint.js'; import { extractContextEvalSignals } from './context-eval.js'; import { validateRoutingSyntax } from './final-routing-slot.js'; import { buildBriefingMessage } from './format-briefing.js'; @@ -2840,6 +2841,9 @@ export async function* routeSerial( } : {}), ...(doneMsg?.tracing ? { tracing: doneMsg.tracing } : {}), + // F257 #4 (sol R1 P1-1): stamp signature lint on the ordinary agent + // stream-final so it enters the sign-rate denominator, not just callback posts. + ...signatureLintExtra(storedContent), }, }); storedMsgId = storedMsg.id; diff --git a/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts b/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts index 0d41db6919..d859a9d018 100644 --- a/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts +++ b/packages/api/src/domains/cats/services/stores/redis/redis-message-parsers.ts @@ -363,6 +363,8 @@ export function safeParseExtra(raw: string | undefined): isExplicitPost?: boolean; tracing?: { traceId: string; spanId: string; parentSpanId?: string }; systemKind?: 'a2a_routing' | 'context_briefing'; + // F257 #4 (sol R1 P1-2): preserve signature lint through Redis round-trip. + signatureLint?: { signed: boolean }; } | undefined { if (!raw) return undefined; @@ -393,6 +395,7 @@ export function safeParseExtra(raw: string | undefined): tracing?: { traceId: string; spanId: string; parentSpanId?: string }; systemKind?: 'a2a_routing' | 'context_briefing'; a2aRouting?: { fromCatId?: string; targetCatId?: string; invocationId?: string }; + signatureLint?: { signed: boolean }; } = {}; let hasField = false; @@ -464,6 +467,16 @@ export function safeParseExtra(raw: string | undefined): hasField = true; } + // F257 #4 (sol R1 P1-2): preserve signature lint verdict through Redis round-trip. + if ( + parsed.signatureLint && + typeof parsed.signatureLint === 'object' && + typeof parsed.signatureLint.signed === 'boolean' + ) { + result.signatureLint = { signed: parsed.signatureLint.signed }; + hasField = true; + } + if (parsed.a2aRouting && typeof parsed.a2aRouting === 'object') { const routing: NonNullable = {}; if (typeof parsed.a2aRouting.fromCatId === 'string') routing.fromCatId = parsed.a2aRouting.fromCatId; diff --git a/packages/api/src/domains/cats/services/types.ts b/packages/api/src/domains/cats/services/types.ts index 0c437ea39a..aac72420bc 100644 --- a/packages/api/src/domains/cats/services/types.ts +++ b/packages/api/src/domains/cats/services/types.ts @@ -201,6 +201,8 @@ export interface AgentMessage { targetCats?: string[]; /** #814: True when message originated from an explicit post_message callback (not stream duplicate) */ isExplicitPost?: boolean; + /** F257 #4 (sol R1 P2-1): message-signature lint verdict, forwarded to live delivery. */ + signatureLint?: { signed: boolean }; }; /** F121: ID of the message this message is replying to */ replyTo?: string; diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts index 254c046800..ddf37c0899 100644 --- a/packages/api/src/routes/callbacks.ts +++ b/packages/api/src/routes/callbacks.ts @@ -1201,6 +1201,8 @@ export const callbacksRoutes: FastifyPluginAsync = async extra: { isExplicitPost: true, ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), + // F257 #4 (sol R1 P2-1): forward persisted signature lint to live delivery. + ...(storedMsg.extra?.signatureLint ? { signatureLint: storedMsg.extra.signatureLint } : {}), }, ...(mentionsUser ? { mentionsUser } : {}), ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), @@ -1941,6 +1943,8 @@ export const callbacksRoutes: FastifyPluginAsync = async ? { crossPost: { sourceThreadId: actor.threadId, sourceInvocationId: effectiveInvId } } : {}), ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), + // F257 #4 (sol R1 P2-1): forward persisted signature lint to live delivery. + ...(storedMsg.extra?.signatureLint ? { signatureLint: storedMsg.extra.signatureLint } : {}), }, ...(mentionsUser ? { mentionsUser } : {}), ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), diff --git a/packages/api/src/routes/messages.ts b/packages/api/src/routes/messages.ts index 229b605cf6..efc71e87d3 100644 --- a/packages/api/src/routes/messages.ts +++ b/packages/api/src/routes/messages.ts @@ -1870,7 +1870,8 @@ export const messagesRoutes: FastifyPluginAsync = async ( m.extra?.targetCats || m.extra?.scheduler || m.extra?.systemKind || - m.extra?.a2aRouting + m.extra?.a2aRouting || + m.extra?.signatureLint ? { extra: { ...(m.extra.rich ? { rich: m.extra.rich } : {}), @@ -1881,6 +1882,8 @@ export const messagesRoutes: FastifyPluginAsync = async ( ...(m.extra.scheduler ? { scheduler: m.extra.scheduler } : {}), ...(m.extra.systemKind ? { systemKind: m.extra.systemKind } : {}), ...(m.extra.a2aRouting ? { a2aRouting: m.extra.a2aRouting } : {}), + // F257 #4 (sol R1 P2-1): expose signature lint to the message read model. + ...(m.extra.signatureLint ? { signatureLint: m.extra.signatureLint } : {}), }, } : {}), diff --git a/packages/api/test/callback-a2a-postmsg.test.js b/packages/api/test/callback-a2a-postmsg.test.js index 54e92a38a8..21787affaf 100644 --- a/packages/api/test/callback-a2a-postmsg.test.js +++ b/packages/api/test/callback-a2a-postmsg.test.js @@ -224,6 +224,8 @@ describe('post_message A2A mention invocation', () => { const recent = messageStore.getRecent(10); assert.equal(recent.length, 1); assert.deepEqual(recent[0].extra?.signatureLint, { signed: false }); + // P2-1 live-broadcast reachability: the socket delivery carries the verdict too. + assert.deepEqual(socketManager.getMessages().at(-1)?.extra?.signatureLint, { signed: false }); }); test('post-message WITH trailing signature records extra.signatureLint.signed=true', async () => { @@ -241,6 +243,8 @@ describe('post_message A2A mention invocation', () => { const recent = messageStore.getRecent(10); assert.equal(recent.length, 1); assert.deepEqual(recent[0].extra?.signatureLint, { signed: true }); + // P2-1 live-broadcast reachability: the socket delivery carries the verdict too. + assert.deepEqual(socketManager.getMessages().at(-1)?.extra?.signatureLint, { signed: true }); }); test('post-message duplicate retry recovers a queued A2A callback before returning duplicate', async () => { diff --git a/packages/api/test/f257-signature-lint-redis-roundtrip.test.js b/packages/api/test/f257-signature-lint-redis-roundtrip.test.js new file mode 100644 index 0000000000..e6472a207a --- /dev/null +++ b/packages/api/test/f257-signature-lint-redis-roundtrip.test.js @@ -0,0 +1,54 @@ +/** + * F257 #4 (sol R1 P1-2) — signatureLint MUST survive the Redis serialize→parse + * round-trip. + * + * Bug: `serializeExtra` writes the whole object (so signatureLint is stored), + * but `safeParseExtra` rebuilds `extra` from an explicit allowlist and silently + * dropped `signatureLint` → the field was in-process-only; any Redis-backed + * reload/hydration lost it, violating the detection-layer persistence contract. + * The in-memory MessageStore callback tests never caught this because they + * inspect the immediate append result, not the Redis read path. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const { serializeExtra, safeParseExtra } = await import( + '../dist/domains/cats/services/stores/redis/redis-message-parsers.js' +); + +function roundTrip(extra) { + return safeParseExtra(serializeExtra(extra)); +} + +describe('F257 #4 (sol R1 P1-2) — safeParseExtra preserves signatureLint', () => { + it('signed:false round-trips', () => { + assert.deepEqual(roundTrip({ signatureLint: { signed: false } }), { signatureLint: { signed: false } }); + }); + + it('signed:true round-trips', () => { + assert.deepEqual(roundTrip({ signatureLint: { signed: true } }), { signatureLint: { signed: true } }); + }); + + it('coexists with other extra fields (no cross-contamination)', () => { + assert.deepEqual(roundTrip({ isExplicitPost: true, signatureLint: { signed: false } }), { + isExplicitPost: true, + signatureLint: { signed: false }, + }); + }); + + it('malformed signatureLint.signed (non-boolean) is dropped, other fields survive', () => { + // signed: 'no' is not a boolean → signatureLint dropped; isExplicitPost preserved. + const parsed = safeParseExtra(JSON.stringify({ isExplicitPost: true, signatureLint: { signed: 'no' } })); + assert.deepEqual(parsed, { isExplicitPost: true }); + }); + + it('non-object signatureLint is dropped', () => { + const parsed = safeParseExtra(JSON.stringify({ isExplicitPost: true, signatureLint: 'x' })); + assert.deepEqual(parsed, { isExplicitPost: true }); + }); + + it('signatureLint-only with malformed shape → whole extra undefined (no phantom field)', () => { + assert.equal(safeParseExtra(JSON.stringify({ signatureLint: { signed: 1 } })), undefined); + }); +}); diff --git a/packages/api/test/f257-signature-lint-stream-final.test.js b/packages/api/test/f257-signature-lint-stream-final.test.js new file mode 100644 index 0000000000..1f9de58941 --- /dev/null +++ b/packages/api/test/f257-signature-lint-stream-final.test.js @@ -0,0 +1,139 @@ +/** + * F257 #4 (sol R1 P1-1) — stream-final signature-lint coverage. + * + * The detection layer must stamp `extra.signatureLint` on ORDINARY agent final + * messages (persisted by route-serial / route-parallel with `origin:'stream'`), + * not just explicit callback `post_message` posts. Otherwise a cat that never + * calls post_message is absent from the sign-rate denominator → systematic bias. + * These integration tests drive the real routeSerial/routeParallel generators + * and assert the stream-final append carries the signed/unsigned verdict. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const SIGNED_FINAL = 'Review done, all green.\n\n[宪宪/claude-opus-4-8🐾]'; +const UNSIGNED_FINAL = 'Review done, all green.'; + +function createMockService(catId, text, innerInvocationId = `cli-${catId}`) { + return { + async *invoke() { + yield { + type: 'system_info', + catId, + content: JSON.stringify({ type: 'invocation_created', invocationId: innerInvocationId }), + timestamp: Date.now(), + }; + yield { type: 'text', catId, content: text, timestamp: Date.now() }; + yield { type: 'done', catId, timestamp: Date.now() }; + }, + }; +} + +function createMockDeps(services, appendCalls) { + let invocationSeq = 0; + let messageSeq = 0; + const storedById = new Map(); + return { + services, + invocationDeps: { + registry: { + create: () => ({ invocationId: `inv-${++invocationSeq}`, callbackToken: `tok-${invocationSeq}` }), + verify: () => null, + }, + sessionManager: { + getOrCreate: async () => ({}), + get: async () => null, + resolveWorkingDirectory: () => '/tmp/test', + }, + threadStore: null, + apiUrl: 'http://127.0.0.1:3004', + }, + messageStore: { + append: async (msg) => { + const stored = { id: `msg-${++messageSeq}`, ...msg, threadId: msg.threadId ?? 'default' }; + storedById.set(stored.id, stored); + appendCalls.push(msg); + return stored; + }, + getById: async (id) => storedById.get(id) ?? null, + getRecent: () => [], + getMentionsFor: () => [], + getRecentMentionsFor: () => [], + getBefore: () => [], + getByThread: () => [], + getByThreadAfter: () => [], + getByThreadBefore: () => [], + augmentStreamMetadata: async () => ({}), + }, + socketManager: { broadcastToRoom: () => {} }, + draftStore: { + upsert: () => {}, + touch: () => {}, + delete: () => Promise.resolve(), + deleteByThread: () => {}, + getByThread: () => [], + }, + voiceMode: false, + }; +} + +function streamFinal(appendCalls, catId) { + return appendCalls.find((m) => m.origin === 'stream' && m.catId === catId); +} + +describe('F257 #4 (sol R1 P1-1) — routeSerial stream-final signature lint', () => { + it('signed final → stream append carries extra.signatureLint.signed=true', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const appendCalls = []; + const deps = createMockDeps({ opus: createMockService('opus', SIGNED_FINAL) }, appendCalls); + for await (const _msg of routeSerial(deps, ['opus'], 'hi', 'user1', 'thread1')) { + /* drain */ + } + const finalMsg = streamFinal(appendCalls, 'opus'); + assert.ok(finalMsg, 'serial stream-final persisted'); + assert.deepEqual(finalMsg.extra?.signatureLint, { signed: true }); + }); + + it('unsigned final → stream append carries extra.signatureLint.signed=false (enters denominator)', async () => { + const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); + const appendCalls = []; + const deps = createMockDeps({ opus: createMockService('opus', UNSIGNED_FINAL) }, appendCalls); + for await (const _msg of routeSerial(deps, ['opus'], 'hi', 'user1', 'thread1')) { + /* drain */ + } + const finalMsg = streamFinal(appendCalls, 'opus'); + assert.ok(finalMsg, 'serial stream-final persisted'); + assert.deepEqual(finalMsg.extra?.signatureLint, { signed: false }); + }); +}); + +describe('F257 #4 (sol R1 P1-1) — routeParallel stream-final signature lint', () => { + it('signed final → stream append carries extra.signatureLint.signed=true', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const appendCalls = []; + const deps = createMockDeps({ opus: createMockService('opus', SIGNED_FINAL) }, appendCalls); + for await (const _msg of routeParallel(deps, ['opus'], 'hi', 'user1', 'thread1', { + parentInvocationId: 'parent-p1-signed', + })) { + /* drain */ + } + const finalMsg = streamFinal(appendCalls, 'opus'); + assert.ok(finalMsg, 'parallel stream-final persisted'); + assert.deepEqual(finalMsg.extra?.signatureLint, { signed: true }); + }); + + it('unsigned final → stream append carries extra.signatureLint.signed=false (enters denominator)', async () => { + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const appendCalls = []; + const deps = createMockDeps({ opus: createMockService('opus', UNSIGNED_FINAL) }, appendCalls); + for await (const _msg of routeParallel(deps, ['opus'], 'hi', 'user1', 'thread1', { + parentInvocationId: 'parent-p1-unsigned', + })) { + /* drain */ + } + const finalMsg = streamFinal(appendCalls, 'opus'); + assert.ok(finalMsg, 'parallel stream-final persisted'); + assert.deepEqual(finalMsg.extra?.signatureLint, { signed: false }); + }); +}); diff --git a/packages/api/test/f257-signature-lint.test.js b/packages/api/test/f257-signature-lint.test.js index 0085eb8eee..e6f74260d1 100644 --- a/packages/api/test/f257-signature-lint.test.js +++ b/packages/api/test/f257-signature-lint.test.js @@ -1,16 +1,16 @@ /** - * F257 修复清单 #4 — message-signature structural lint (O2→O1). + * F257 修复清单 #4 — message-signature structural lint (O2→O1), detection layer. * - * 真相源:docs/features/F257-harness-ledger.md L198 —「签名 lint O2→O1:消息末行 - * 签名 [昵称/模型🐾] 是完美可 lint 断言,当前零结构覆盖(dev-7a882ba0:靠 operator - * 人工发现)」。本测试锁定 `lintCatSignature` 的结构化断言契约:agent 消息末行是否 - * 以一个被识别的猫签名结尾。 + * 真相源:docs/features/F257-harness-ledger.md L198 + governance-l0「用自己的身份 + * 签名 [昵称/模型🐾],签名必须含模型型号」。`lintCatSignature` 是 COMPLIANCE lint: + * 断言消息末行是否为**当前契约形态** `[nickname/model🐾]`(nickname + '/' + model + * + 🐾)。 * - * 设计:presence-only(是否有 trailing 签名),非 identity-correctness(是否匹配发帖 - * 猫本身——那需要 per-cat nickname/model 解析,属 F257 #1 域,最小 O1 升级不含)。 - * 判定复用 `isCatSignatureLine`(F167 cat-signature-strip,多轮 FP 收敛真相源),本 - * 测试只验 walk 逻辑 + 代表性签名形态的端到端委托,不重测每一条 FP 边界(那是 - * cat-signature-strip 自身测试的职责)。 + * STRICTNESS(sol R1 P1-3):不复用 `isCatSignatureLine`(routing 的 permissive + * STRIP matcher,容忍 `[Spark🐾]` 无模型、`[砚砚/GPT-5.5]` 无爪)——那会把无模型/ + * 无爪签名误判为 compliant(false negative)。strip=permissive(routing) 与 + * lint=strict(compliance) 分离。presence-only(契约 SHAPE 在场),identity- + * correctness(签名匹配发帖猫)仍 deferred。 */ import assert from 'node:assert/strict'; @@ -20,32 +20,45 @@ import { signatureLintExtra, } from '../dist/domains/cats/services/agents/routing/cat-signature-lint.js'; -describe('F257 #4 — lintCatSignature (O2→O1 message-signature structural lint)', () => { - // --- 正例:末行是有效 trailing 签名(委托 isCatSignatureLine)--- - test('pawed slashed 签名在末行 → signed,返回 trimmed 签名行', () => { +describe('F257 #4 — lintCatSignature (strict [nickname/model🐾] compliance lint)', () => { + // --- 正例:契约形态 nickname/model🐾 --- + test('契约形态 [宪宪/claude-opus-4-8🐾] → signed,返回 trimmed 签名行', () => { const r = lintCatSignature('Some review text.\n\n[宪宪/claude-opus-4-8🐾]'); assert.equal(r.signed, true); assert.equal(r.signatureLine, '[宪宪/claude-opus-4-8🐾]'); }); - test('pawed slashless 签名 [Spark🐾] → signed', () => { - assert.equal(lintCatSignature('done\n\n[Spark🐾]').signed, true); + test('契约形态 [烁烁/Gemini-25🐾](模型含 dash)→ signed', () => { + assert.equal(lintCatSignature('done\n\n[烁烁/Gemini-25🐾]').signed, true); }); - test('legacy 无爪 slashed 签名 [砚砚/GPT-5.5] → signed', () => { - assert.equal(lintCatSignature('merged\n[砚砚/GPT-5.5]').signed, true); + test('契约形态 [砚砚/gpt-5.6-sol🐾](模型含 dash+dot)→ signed', () => { + assert.equal(lintCatSignature('merged\n[砚砚/gpt-5.6-sol🐾]').signed, true); }); - test('整条消息就是一个签名 → signed', () => { + test('整条消息就是一个契约签名 → signed', () => { assert.equal(lintCatSignature('[宪宪/claude-opus-4-8🐾]').signed, true); }); - // --- walk 逻辑:跳过 trailing 空行 / 行内空白 --- - test('签名后有 trailing 空行 → 仍 signed(跳过空行)', () => { - assert.equal(lintCatSignature('text\n[烁烁🐾]\n\n \n').signed, true); + // --- P1-3:strip matcher 容忍但契约不合规的形态 → NOT signed --- + test('P1-3: pawed slashless [Spark🐾](无模型型号)→ NOT signed', () => { + assert.equal(lintCatSignature('done\n\n[Spark🐾]').signed, false); }); - test('签名行含前后空白 → signed,signatureLine 已 trim', () => { + test('P1-3: pawed slashless [烁烁🐾](无模型型号)→ NOT signed', () => { + assert.equal(lintCatSignature('x\n[烁烁🐾]').signed, false); + }); + + test('P1-3: legacy 无爪 slashed [砚砚/GPT-5.5](缺 🐾)→ NOT signed', () => { + assert.equal(lintCatSignature('merged\n[砚砚/GPT-5.5]').signed, false); + }); + + // --- walk 逻辑:跳过 trailing 空行 / 行内空白 / \r\n --- + test('契约签名后有 trailing 空行 → 仍 signed(跳过空行)', () => { + assert.equal(lintCatSignature('text\n[烁烁/Gemini-25🐾]\n\n \n').signed, true); + }); + + test('契约签名行含前后空白 → signed,signatureLine 已 trim', () => { const r = lintCatSignature('text\n [宪宪/Opus-46🐾] '); assert.equal(r.signed, true); assert.equal(r.signatureLine, '[宪宪/Opus-46🐾]'); @@ -55,9 +68,9 @@ describe('F257 #4 — lintCatSignature (O2→O1 message-signature structural lin assert.equal(lintCatSignature('line1\r\nline2\r\n[砚砚/Codex🐾]\r\n').signed, true); }); - // --- 反例:签名非末尾(其后还有内容行)→ 不算(约定是签名为最后一行)--- - test('签名后还有内容行 → NOT signed(必须 trailing)', () => { - const r = lintCatSignature('[宪宪🐾]\n\nPS: one more thing.'); + // --- 反例:契约签名非末尾(其后还有内容行)--- + test('契约签名后还有内容行 → NOT signed(必须 trailing)', () => { + const r = lintCatSignature('[宪宪/claude-opus-4-8🐾]\n\nPS: one more thing.'); assert.equal(r.signed, false); assert.equal(r.signatureLine, null); }); @@ -77,7 +90,7 @@ describe('F257 #4 — lintCatSignature (O2→O1 message-signature structural lin assert.equal(lintCatSignature(' \n\n ').signed, false); }); - // --- 反例:FP 形态(复用 isCatSignatureLine 的两侧精确 allowlist 边界)--- + // --- 反例:非签名形态 --- test('正文 token [Phase B] → not signed', () => { assert.equal(lintCatSignature('Update:\n[Phase B]').signed, false); }); @@ -86,32 +99,30 @@ describe('F257 #4 — lintCatSignature (O2→O1 message-signature structural lin assert.equal(lintCatSignature('see\n[packages/api/src/foo.ts]').signed, false); }); - test('provider/path LHS FP [openai/GPT-5.5] → not signed', () => { - assert.equal(lintCatSignature('ref\n[openai/GPT-5.5]').signed, false); - }); - - test('CJK 语义标签 FP [模型/GPT-5.5] → not signed', () => { - assert.equal(lintCatSignature('x\n[模型/GPT-5.5]').signed, false); - }); - - test('终端引用 [PR/2442] → not signed', () => { - assert.equal(lintCatSignature('landed\n[PR/2442]').signed, false); + test('多斜杠 [a/b/c🐾] → not signed(契约是单 nickname/model)', () => { + assert.equal(lintCatSignature('x\n[a/b/c🐾]').signed, false); }); }); describe('F257 #4 — signatureLintExtra (post-seam extra projection)', () => { - test('signed message → { signatureLint: { signed: true } }', () => { + test('契约签名消息 → { signatureLint: { signed: true } }', () => { assert.deepEqual(signatureLintExtra('done\n\n[宪宪/claude-opus-4-8🐾]'), { signatureLint: { signed: true }, }); }); - test('unsigned text message → { signatureLint: { signed: false } }', () => { + test('无签名 text 消息 → { signatureLint: { signed: false } }', () => { assert.deepEqual(signatureLintExtra('LGTM, merging now.'), { signatureLint: { signed: false }, }); }); + test('非契约签名 [Spark🐾] → { signatureLint: { signed: false } }', () => { + assert.deepEqual(signatureLintExtra('done\n[Spark🐾]'), { + signatureLint: { signed: false }, + }); + }); + test('blank/whitespace content → {} (pure-media exclusion, out of denominator)', () => { assert.deepEqual(signatureLintExtra(''), {}); assert.deepEqual(signatureLintExtra(' \n\n '), {}); diff --git a/packages/api/test/messages-endpoint.test.js b/packages/api/test/messages-endpoint.test.js index 5438e80fdd..6477327aff 100644 --- a/packages/api/test/messages-endpoint.test.js +++ b/packages/api/test/messages-endpoint.test.js @@ -97,6 +97,27 @@ describe('GET /api/messages', () => { }); }); + // F257 #4 (sol R1 P2-1): the detection observable must be reachable through the + // message read model on cold load so Console (#6) can consume it. + it('exposes extra.signatureLint through GET /api/messages (cold hydration)', async () => { + messageStore.append({ + provenance: { author: 'cat', routed: false, observation: 'original' }, + userId: 'default-user', + catId: 'opus', + content: 'unsigned final answer', + mentions: [], + timestamp: 2100, + threadId: 'thread-siglint', + extra: { signatureLint: { signed: false } }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/messages?threadId=thread-siglint' }); + const body = JSON.parse(res.body); + + assert.equal(body.messages.length, 1); + assert.deepEqual(body.messages[0].extra?.signatureLint, { signed: false }); + }); + it('maps canonical system messages to type=system', async () => { messageStore.append({ provenance: { author: 'cat', routed: false, observation: 'original' }, diff --git a/packages/web/src/stores/chat-types.ts b/packages/web/src/stores/chat-types.ts index c3e0f41594..4effb965ae 100644 --- a/packages/web/src/stores/chat-types.ts +++ b/packages/web/src/stores/chat-types.ts @@ -284,6 +284,9 @@ export interface ChatMessage { targetCats?: string[]; /** #814: True when message originated from an explicit post_message callback (not stream duplicate) */ isExplicitPost?: boolean; + /** F257 #4 (sol R1 P2-1): message-signature lint verdict (detection layer). Read-model + * reachability for Console (#6) — signed=false marks a contract-non-compliant/missing signature. */ + signatureLint?: { signed: boolean }; /** Scheduler presentation metadata (hidden trigger / ephemeral lifecycle toast) */ scheduler?: SchedulerMessageExtra['scheduler']; /** F118 AC-C3: Timeout diagnostics for enhanced error display */ From f8d3c33dac4d4254b669a574fd50c69cd2d15a71 Mon Sep 17 00:00:00 2001 From: "Cat8zfu14fb-Opus-4.8" Date: Tue, 21 Jul 2026 01:19:09 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(F257):=20#4=20R4=20=E2=80=94=20P1=20pro?= =?UTF-8?q?vider-qualified=20model=20+=20P2=20=C2=A716e=20=E5=85=A8?= =?UTF-8?q?=E9=93=BE=20reachability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sol R4 REQUEST CHANGES 两项核实成立(读码 + runtime 真相源),逐项修 + §16e 全量 sweep: P1 严格 matcher 拒真实模型标识(correctness):STRICT_SIGNATURE_LINE_RE 假设单斜杠,但 opencode 运行时 defaultModel="codex-for-me/gpt-5.4"(provider-qualified,含斜杠)→ [金渐层/codex-for-me/gpt-5.4🐾] 被误判 unsigned、按 cohort 污染分母。改:首斜杠分隔 nickname/model,model 允许 provider-qualified 斜杠,两段 trim 后非空必需(拒 [ /m🐾] / [n/ 🐾])。flip multi-slash 为正例 + roster provider-qualified 正例 + 空段负例。 P2 §16e 全链 reachability(补锅匠 → failure-mode sweep):R3 只补 server 侧 + web ChatMessage 类型,漏 web 投影链全体 + 2 处 dup-recovery broadcast。做全量 sweep(api 20 点 + web 14 点)逐点核 fixed/N-A,修复 14 点: - API: A13/A14 dup-recovery broadcast forward(+ A11-A14 抽 pickSignatureLint helper, callbacks 复杂度对基线 0 delta) - Web 类型: W3 AgentMsg / W4 BackgroundAgentMessage / W5 history-response - Web runtime: W6 cold-hydration(guard+emit) / W7 mergeMessageExtra(sweep 发现的 uncited 点) / W8-W14 useAgentMessages callback side-patch×7(pickSignatureLint 统一 forward) - sweep 发现 sol 未列的 3 个 drop 点(W7/W13/W14)——证明逐点补 sol 列表会再 bounce Divergence: 6+ 异构 allowlist(不同 key 子集),不可统一 projector;pickSignatureLint 1-line forwarder per-package DRY(sweep 建议)。 证据:api F257+回归 105 全绿;web reachability vitest 8 + web tsc clean;biome clean、 callbacks 复杂度 0-delta;无 any。A18/A19(F246 dispatch origination,fresh extra 无 drop)+ A16/A17(full passthrough)文档化 N-A。 [宪宪/claude-opus-4-8🐾] Co-Authored-By: Claude Opus 4.8 --- .../agents/routing/cat-signature-lint.ts | 35 +++++++++-- packages/api/src/routes/callbacks.ts | 10 ++- packages/api/test/f257-signature-lint.test.js | 19 +++++- .../f257-signature-lint-reachability.test.ts | 61 +++++++++++++++++++ packages/web/src/hooks/useAgentMessages.ts | 25 +++++++- packages/web/src/hooks/useChatHistory.ts | 14 ++++- packages/web/src/stores/chat-types.ts | 15 +++++ 7 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 packages/web/src/__tests__/f257-signature-lint-reachability.test.ts diff --git a/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts b/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts index 8ceb324096..aa53327475 100644 --- a/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts +++ b/packages/api/src/domains/cats/services/agents/routing/cat-signature-lint.ts @@ -35,11 +35,23 @@ /** * Strict compliance matcher for the current signature contract `[昵称/模型🐾]`: - * '[' + non-empty nickname (no '/') + '/' + non-empty model (no '/') + 🐾 + ']'. - * Rejects `[Spark🐾]` (no model), `[砚砚/GPT-5.5]` (no paw), and bare tokens — - * exactly the forms the permissive strip matcher tolerates. + * '[' + nickname + '/' + model + 🐾 + ']'. The FIRST slash delimits + * nickname/model; the model MAY be provider-qualified — i.e. contain further + * slashes (e.g. opencode's live `defaultModel: "codex-for-me/gpt-5.4"`), so the + * model class does NOT exclude '/'. Both captured components must be NON-BLANK + * after trim. Rejects `[Spark🐾]` (no model), `[砚砚/GPT-5.5]` (no paw), and + * `[ /model🐾]` / `[nick/ 🐾]` (blank component) — forms the permissive strip + * matcher tolerates or a naive single-slash regex would mis-handle (sol R4 P1). */ -const STRICT_SIGNATURE_LINE_RE = /^\s*\[[^[\]\n/]+\/[^[\]\n/]+🐾\]\s*$/u; +const STRICT_SIGNATURE_LINE_RE = /^\s*\[([^[\]\n/]+)\/([^[\]\n]+)🐾\]\s*$/u; + +function isContractSignatureLine(line: string): boolean { + const m = STRICT_SIGNATURE_LINE_RE.exec(line); + if (!m) return false; + // nickname (m[1], before first slash) and model (m[2], may be provider-qualified) + // must both be non-blank after trim — the regex classes admit whitespace-only. + return (m[1]?.trim().length ?? 0) > 0 && (m[2]?.trim().length ?? 0) > 0; +} export interface SignatureLintResult { /** true iff the last non-blank line is a contract-shaped `[nickname/model🐾]` signature. */ @@ -64,7 +76,7 @@ export function lintCatSignature(text: string): SignatureLintResult { for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i] ?? ''; if (line.trim() === '') continue; // skip trailing blank lines - return STRICT_SIGNATURE_LINE_RE.test(line) ? { signed: true, signatureLine: line.trim() } : UNSIGNED; + return isContractSignatureLine(line) ? { signed: true, signatureLine: line.trim() } : UNSIGNED; } return UNSIGNED; // all-blank / empty } @@ -80,3 +92,16 @@ export function signatureLintExtra(text: string): { signatureLint?: { signed: bo if (!text.trim()) return {}; return { signatureLint: { signed: lintCatSignature(text).signed } }; } + +/** + * Forward an already-computed lint verdict when a stored message's `extra` is + * re-projected onto a broadcast / read-model payload (mirrors the web-side + * `pickSignatureLint`). Kept as a 1-line forwarder so the (already large) + * broadcast closures add no branch-complexity and the 4 broadcast sites stay + * consistent (sol R4 P2). + */ +export function pickSignatureLint(extra: { signatureLint?: { signed: boolean } } | null | undefined): { + signatureLint?: { signed: boolean }; +} { + return extra?.signatureLint ? { signatureLint: extra.signatureLint } : {}; +} diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts index ddf37c0899..82437283de 100644 --- a/packages/api/src/routes/callbacks.ts +++ b/packages/api/src/routes/callbacks.ts @@ -24,7 +24,7 @@ import { getRichBlockBuffer } from '../domains/cats/services/agents/invocation/R import { stampVisibleTurn } from '../domains/cats/services/agents/invocation/visible-turn.js'; import { extractImagePaths, extractImageUrls } from '../domains/cats/services/agents/providers/image-paths.js'; import { analyzeA2AMentions } from '../domains/cats/services/agents/routing/a2a-mentions.js'; -import { signatureLintExtra } from '../domains/cats/services/agents/routing/cat-signature-lint.js'; +import { pickSignatureLint, signatureLintExtra } from '../domains/cats/services/agents/routing/cat-signature-lint.js'; import { resolveCatTarget } from '../domains/cats/services/agents/routing/cat-target-resolver.js'; import { extractRichFromText } from '../domains/cats/services/agents/routing/rich-block-extract.js'; import { buildVoteNotification } from '../domains/cats/services/agents/routing/vote-intercept.js'; @@ -1100,6 +1100,8 @@ export const callbacksRoutes: FastifyPluginAsync = async extra: { isExplicitPost: true, ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), + // F257 #4 (sol R4 P2): duplicate-recovery broadcast must also forward the verdict. + ...pickSignatureLint(duplicateMsg.extra), }, ...(duplicateMsg.mentionsUser ? { mentionsUser: true } : {}), ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), @@ -1202,7 +1204,7 @@ export const callbacksRoutes: FastifyPluginAsync = async isExplicitPost: true, ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), // F257 #4 (sol R1 P2-1): forward persisted signature lint to live delivery. - ...(storedMsg.extra?.signatureLint ? { signatureLint: storedMsg.extra.signatureLint } : {}), + ...pickSignatureLint(storedMsg.extra), }, ...(mentionsUser ? { mentionsUser } : {}), ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), @@ -1835,6 +1837,8 @@ export const callbacksRoutes: FastifyPluginAsync = async ? { crossPost: { sourceThreadId: actor.threadId, sourceInvocationId: effectiveInvId } } : {}), ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), + // F257 #4 (sol R4 P2): duplicate-recovery broadcast must also forward the verdict. + ...pickSignatureLint(duplicateMsg.extra), }, ...(duplicateMsg.mentionsUser ? { mentionsUser: true } : {}), ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), @@ -1944,7 +1948,7 @@ export const callbacksRoutes: FastifyPluginAsync = async : {}), ...(validExplicitTargets.length ? { targetCats: validExplicitTargets } : {}), // F257 #4 (sol R1 P2-1): forward persisted signature lint to live delivery. - ...(storedMsg.extra?.signatureLint ? { signatureLint: storedMsg.extra.signatureLint } : {}), + ...pickSignatureLint(storedMsg.extra), }, ...(mentionsUser ? { mentionsUser } : {}), ...(validatedReplyTo ? { replyTo: validatedReplyTo } : {}), diff --git a/packages/api/test/f257-signature-lint.test.js b/packages/api/test/f257-signature-lint.test.js index e6f74260d1..0520f4604d 100644 --- a/packages/api/test/f257-signature-lint.test.js +++ b/packages/api/test/f257-signature-lint.test.js @@ -99,8 +99,23 @@ describe('F257 #4 — lintCatSignature (strict [nickname/model🐾] compliance l assert.equal(lintCatSignature('see\n[packages/api/src/foo.ts]').signed, false); }); - test('多斜杠 [a/b/c🐾] → not signed(契约是单 nickname/model)', () => { - assert.equal(lintCatSignature('x\n[a/b/c🐾]').signed, false); + // sol R4 P1: model may be PROVIDER-QUALIFIED (contains '/'); first slash delimits. + test('provider-qualified [金渐层/codex-for-me/gpt-5.4🐾](opencode roster 实锤)→ signed', () => { + const r = lintCatSignature('done\n[金渐层/codex-for-me/gpt-5.4🐾]'); + assert.equal(r.signed, true); + assert.equal(r.signatureLine, '[金渐层/codex-for-me/gpt-5.4🐾]'); + }); + + test('multi-segment model [a/b/c🐾] → signed(first slash 分隔,model=b/c)', () => { + assert.equal(lintCatSignature('x\n[a/b/c🐾]').signed, true); + }); + + test('sol R4 P1: 空白 nickname [ /gpt-5.6-sol🐾] → not signed(trim 后非空必需)', () => { + assert.equal(lintCatSignature('x\n[ /gpt-5.6-sol🐾]').signed, false); + }); + + test('sol R4 P1: 空白 model [砚砚/ 🐾] → not signed(trim 后非空必需)', () => { + assert.equal(lintCatSignature('x\n[砚砚/ 🐾]').signed, false); }); }); diff --git a/packages/web/src/__tests__/f257-signature-lint-reachability.test.ts b/packages/web/src/__tests__/f257-signature-lint-reachability.test.ts new file mode 100644 index 0000000000..da633abf40 --- /dev/null +++ b/packages/web/src/__tests__/f257-signature-lint-reachability.test.ts @@ -0,0 +1,61 @@ +/** + * F257 #4 (sol R4 P2) — web read-model reachability for the signature-lint verdict. + * + * The server now persists/broadcasts `extra.signatureLint`, but the web ingestion + * chain rebuilds `extra` via several divergent allowlists. This proves the two + * testable pure seams preserve the field: + * - `pickSignatureLint` — the shared forwarder spread into the live-callback + * side-patches (useAgentMessages W8-W14) and cold-hydration emit (W6). + * - `mergeMessageExtra` — the cold-hydration history-merge reconcile (W7, an + * UNCITED drop point the §16e sweep surfaced), including the guard invariant + * sol flagged: a signatureLint-ONLY extra must NOT collapse to undefined. + */ + +import { describe, expect, it } from 'vitest'; +import { mergeMessageExtra } from '@/hooks/useChatHistory'; +import { pickSignatureLint } from '@/stores/chat-types'; + +describe('pickSignatureLint — shared live-callback / cold-hydration forwarder', () => { + it('forwards signed verdict', () => { + expect(pickSignatureLint({ signatureLint: { signed: true } })).toEqual({ signatureLint: { signed: true } }); + }); + + it('forwards unsigned verdict', () => { + expect(pickSignatureLint({ signatureLint: { signed: false } })).toEqual({ signatureLint: { signed: false } }); + }); + + it('returns empty when field absent (no phantom key)', () => { + expect(pickSignatureLint({})).toEqual({}); + expect(pickSignatureLint({ signatureLint: undefined })).toEqual({}); + expect(pickSignatureLint(undefined)).toEqual({}); + expect(pickSignatureLint(null)).toEqual({}); + }); +}); + +describe('mergeMessageExtra — cold-hydration reconcile preserves signatureLint (W7)', () => { + it('signatureLint-ONLY extra does NOT collapse to undefined (guard invariant)', () => { + const merged = mergeMessageExtra({ signatureLint: { signed: false } }, undefined); + expect(merged).toEqual({ signatureLint: { signed: false } }); + }); + + it('preserves signatureLint from the preferred side', () => { + const merged = mergeMessageExtra({ signatureLint: { signed: true } }, { isExplicitPost: true }); + expect(merged?.signatureLint).toEqual({ signed: true }); + expect(merged?.isExplicitPost).toBe(true); + }); + + it('falls back to signatureLint from the fallback side', () => { + const merged = mergeMessageExtra(undefined, { signatureLint: { signed: false } }); + expect(merged?.signatureLint).toEqual({ signed: false }); + }); + + it('preferred verdict wins over fallback', () => { + const merged = mergeMessageExtra({ signatureLint: { signed: true } }, { signatureLint: { signed: false } }); + expect(merged?.signatureLint).toEqual({ signed: true }); + }); + + it('coexists with other extra fields without clobbering', () => { + const merged = mergeMessageExtra({ isExplicitPost: true, signatureLint: { signed: false } }, undefined); + expect(merged).toEqual({ isExplicitPost: true, signatureLint: { signed: false } }); + }); +}); diff --git a/packages/web/src/hooks/useAgentMessages.ts b/packages/web/src/hooks/useAgentMessages.ts index a565d1f400..c4644298eb 100644 --- a/packages/web/src/hooks/useAgentMessages.ts +++ b/packages/web/src/hooks/useAgentMessages.ts @@ -21,6 +21,7 @@ import type { TokenUsage, ToolEvent, } from '@/stores/chat-types'; +import { pickSignatureLint } from '@/stores/chat-types'; import { useChatStore } from '@/stores/chatStore'; import { useToastStore } from '@/stores/toastStore'; import { compactToolResultDetail } from '@/utils/toolPreview'; @@ -224,6 +225,8 @@ interface AgentMsg { isExplicitPost?: boolean; /** F098-C1: Explicit target cats from post_message (direction pills) */ targetCats?: string[]; + /** F257 #4 (sol R4 P2): message-signature lint verdict (detection layer). */ + signatureLint?: { signed: boolean }; }; /** F121: Reply-to message ID */ replyTo?: string; @@ -443,6 +446,8 @@ export interface BackgroundAgentMessage { isExplicitPost?: boolean; /** F098-C1: Explicit target cats from post_message (direction pills) */ targetCats?: string[]; + /** F257 #4 (sol R4 P2): message-signature lint verdict (detection layer). */ + signatureLint?: { signed: boolean }; }; /** F057-C2: Whether this message mentions the user (@user / @co-creator) */ mentionsUser?: boolean; @@ -2094,11 +2099,13 @@ export function handleBackgroundAgentMessage( const sidePatch: Partial = { ...(msg.metadata ? { metadata: msg.metadata } : {}), - ...(msg.extra?.crossPost || msg.extra?.isExplicitPost + ...(msg.extra?.crossPost || msg.extra?.isExplicitPost || msg.extra?.signatureLint ? { extra: { ...(msg.extra.crossPost ? { crossPost: msg.extra.crossPost } : {}), ...(msg.extra.isExplicitPost ? { isExplicitPost: true as const } : {}), + // F257 #4 (sol R4 P2): forward lint verdict through background callback side-patch. + ...pickSignatureLint(msg.extra), }, } : {}), @@ -2179,12 +2186,14 @@ export function handleBackgroundAgentMessage( catId: msg.catId, content: msg.content, ...(msg.metadata ? { metadata: msg.metadata } : {}), - ...(msg.extra?.crossPost || msg.extra?.isExplicitPost || msg.extra?.targetCats + ...(msg.extra?.crossPost || msg.extra?.isExplicitPost || msg.extra?.targetCats || msg.extra?.signatureLint ? { extra: { ...(msg.extra.crossPost ? { crossPost: msg.extra.crossPost } : {}), ...(msg.extra.isExplicitPost ? { isExplicitPost: true as const } : {}), ...(msg.extra.targetCats ? { targetCats: msg.extra.targetCats } : {}), + // F257 #4 (sol R4 P2): forward lint verdict through background callback path. + ...pickSignatureLint(msg.extra), }, } : {}), @@ -2198,12 +2207,14 @@ export function handleBackgroundAgentMessage( // Side-fields after reducer success (reducer 不 model 这些) const sidePatch: Partial = { ...(msg.metadata ? { metadata: msg.metadata } : {}), - ...(msg.extra?.crossPost || msg.extra?.isExplicitPost || msg.extra?.targetCats + ...(msg.extra?.crossPost || msg.extra?.isExplicitPost || msg.extra?.targetCats || msg.extra?.signatureLint ? { extra: { ...(msg.extra.crossPost ? { crossPost: msg.extra.crossPost } : {}), ...(msg.extra.isExplicitPost ? { isExplicitPost: true as const } : {}), ...(msg.extra.targetCats ? { targetCats: msg.extra.targetCats } : {}), + // F257 #4 (sol R4 P2): forward lint verdict through background callback path. + ...pickSignatureLint(msg.extra), }, } : {}), @@ -3549,6 +3560,8 @@ export function useAgentMessages() { // skips merge for explicit post_message callbacks in fallback path. ...(msg.extra?.isExplicitPost ? { isExplicitPost: true } : {}), ...(msg.extra?.targetCats ? { targetCats: msg.extra.targetCats } : {}), + // F257 #4 (sol R4 P2): forward lint verdict through active callback fallback add. + ...pickSignatureLint(msg.extra), stream: { invocationId, ...(turnInvocationIdForFallback && turnInvocationIdForFallback !== invocationId @@ -3576,6 +3589,8 @@ export function useAgentMessages() { ...(msg.extra?.crossPost ? { crossPost: msg.extra.crossPost } : {}), ...(msg.extra?.isExplicitPost ? { isExplicitPost: true as const } : {}), ...(msg.extra?.targetCats ? { targetCats: msg.extra.targetCats } : {}), + // F257 #4 (sol R4 P2): forward lint verdict through active callback patch. + ...pickSignatureLint(msg.extra), }; if ( msg.metadata || @@ -4269,6 +4284,8 @@ export function useAgentMessages() { // 已写好的 turnInvocationId(applyMessagePatch 是 shallow merge, stream 整块替换)。 const extraForPatch = { ...(msg.extra?.crossPost ? { crossPost: msg.extra.crossPost } : {}), + // F257 #4 (sol R4 P2): forward lint verdict through active reducer-handled patch. + ...pickSignatureLint(msg.extra), ...(hasExplicitInvocationId && msg.invocationId ? { stream: { @@ -4334,6 +4351,8 @@ export function useAgentMessages() { const extraForAdd = { ...(msg.extra?.crossPost ? { crossPost: msg.extra.crossPost } : {}), ...(msg.extra?.targetCats ? { targetCats: msg.extra.targetCats } : {}), + // F257 #4 (sol R4 P2): forward lint verdict through invocationless callback add. + ...pickSignatureLint(msg.extra), ...(hasExplicitInvocationId && msg.invocationId ? { stream: { diff --git a/packages/web/src/hooks/useChatHistory.ts b/packages/web/src/hooks/useChatHistory.ts index 7ac1e8ba68..f599bfccfa 100644 --- a/packages/web/src/hooks/useChatHistory.ts +++ b/packages/web/src/hooks/useChatHistory.ts @@ -6,6 +6,7 @@ import { getBubbleInvocationId, shouldForceReplaceHydrationForCachedMessages } f import { recordDebugEvent } from '@/debug/invocationEventDebug'; import { projectCanonicalBubbles } from '@/stores/bubble-projection'; import type { QueueEntry, TaskProgressItem } from '@/stores/chat-types'; +import { pickSignatureLint } from '@/stores/chat-types'; import { type CatInvocationInfo, type ChatMessage as ChatMessageData, useChatStore } from '@/stores/chatStore'; import type { TaskItem } from '@/stores/taskStore'; import { useTaskStore } from '@/stores/taskStore'; @@ -162,7 +163,7 @@ function mergeRichPayload( return { v: 1 as const, blocks }; } -function mergeMessageExtra( +export function mergeMessageExtra( preferred: ChatMessageData['extra'], fallback: ChatMessageData['extra'], ): ChatMessageData['extra'] | undefined { @@ -180,6 +181,8 @@ function mergeMessageExtra( // #814 P2: preserve isExplicitPost so F5/thread-switch doesn't lose the // "don't merge by invocation" semantic for explicit post_message callbacks. const isExplicitPost = preferred?.isExplicitPost ?? fallback?.isExplicitPost; + // F257 #4 (sol R4 P2): preserve signature lint verdict through history merge. + const signatureLint = preferred?.signatureLint ?? fallback?.signatureLint; if ( !rich && !crossPost && @@ -190,7 +193,8 @@ function mergeMessageExtra( !cliDiagnostics && !governanceBlocked && !systemKind && - !isExplicitPost + !isExplicitPost && + !signatureLint ) { return undefined; } @@ -205,6 +209,7 @@ function mergeMessageExtra( ...(governanceBlocked ? { governanceBlocked } : {}), ...(systemKind ? { systemKind } : {}), ...(isExplicitPost ? { isExplicitPost: true as const } : {}), + ...(signatureLint ? { signatureLint } : {}), }; } @@ -883,6 +888,8 @@ export function useChatHistory(threadId: string) { /** F212 Phase B: history-loader path may already carry cliDiagnostics under * extra (when client wrote it via active-path) — prefer it over metadata copy. */ cliDiagnostics?: CliDiagnostics; + /** F257 #4 (sol R4 P2): message-signature lint verdict (detection layer). */ + signatureLint?: { signed: boolean }; }; timestamp: number; summary?: { id: string; topic: string; conclusions: string[]; openQuestions: string[]; createdBy: string }; @@ -931,6 +938,7 @@ export function useChatHistory(threadId: string) { m.extra?.systemKind || m.extra?.isExplicitPost || m.extra?.targetCats || + m.extra?.signatureLint || cliDiag; if (!hasExtraField) return {}; return { @@ -942,6 +950,8 @@ export function useChatHistory(threadId: string) { ...(m.extra?.systemKind ? { systemKind: m.extra.systemKind } : {}), ...(m.extra?.isExplicitPost ? { isExplicitPost: true as const } : {}), ...(m.extra?.targetCats ? { targetCats: m.extra.targetCats } : {}), + // F257 #4 (sol R4 P2): cold-hydration must carry the lint verdict. + ...pickSignatureLint(m.extra), ...(cliDiag ? { cliDiagnostics: cliDiag } : {}), }, }; diff --git a/packages/web/src/stores/chat-types.ts b/packages/web/src/stores/chat-types.ts index 4effb965ae..208bc80a97 100644 --- a/packages/web/src/stores/chat-types.ts +++ b/packages/web/src/stores/chat-types.ts @@ -727,3 +727,18 @@ export const DEFAULT_THREAD_STATE: ThreadState = { workspaceOpenFilePath: null, workspaceOpenFileLine: null, }; + +/** + * F257 #4 (sol R4 P2): forward the message-signature lint verdict when a message + * `extra` bag is projected/rebuilt. The web ingestion chain reconstructs `extra` + * via several divergent allowlists (cold hydration, live callback side-patches); + * each must spread this so the detection observable survives end-to-end to + * `ChatMessage.extra` — a partial migration silently drops the field on some + * paths (sol R3/R4 P2). Kept as a 1-line forwarder because the full allowlists + * are genuinely divergent (different key subsets) and cannot be centralized. + */ +export function pickSignatureLint(extra: { signatureLint?: { signed: boolean } } | null | undefined): { + signatureLint?: { signed: boolean }; +} { + return extra?.signatureLint ? { signatureLint: extra.signatureLint } : {}; +}