From aa4cb1352e5051ac3ce79ec3c3a74559b2c7b3fe Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 12:12:43 +0800 Subject: [PATCH 01/10] feat(a2a): detect inline action @mentions and write routing feedback (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the F064 AC-B3 write-side debt: when a cat writes action-like inline @mentions (e.g. "Ready for @codex review") without a valid line-start handoff, the system now writes mentionRoutingFeedback so the cat sees a [路由提醒] on its next invocation. - Add detectInlineActionMentions() in a2a-mentions.ts - Wire write-side feedback in route-serial.ts after parseA2AMentions - Fix post_message tool description to say "line-start @猫名" explicitly - Add 'inline_action' to MentionRoutingSuppressionReason - 9 new tests (206 total, 0 fail) - Refresh F064 (AC-B3 done) and F055 truth sources [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../F055-a2a-mcp-structured-routing.md | 6 ++ docs/features/F064-a2a-exit-check.md | 34 ++++----- .../services/agents/routing/a2a-mentions.ts | 71 +++++++++++++++++++ .../services/agents/routing/route-serial.ts | 21 +++++- .../cats/services/stores/ports/ThreadStore.ts | 2 +- packages/api/test/a2a-mentions.test.js | 65 +++++++++++++++++ .../mcp-server/src/tools/callback-tools.ts | 4 +- 7 files changed, 177 insertions(+), 26 deletions(-) diff --git a/docs/features/F055-a2a-mcp-structured-routing.md b/docs/features/F055-a2a-mcp-structured-routing.md index 987a27312..37e40c995 100644 --- a/docs/features/F055-a2a-mcp-structured-routing.md +++ b/docs/features/F055-a2a-mcp-structured-routing.md @@ -61,6 +61,12 @@ created: 2026-03-04 - 不改 thread participation 逻辑 - 不做 7B 意图识别(成本高、CJK 准确率不够、双通道已够兜底) +### 相关改进(#417) + +- 协议明确区分:inline `@xx` = semantic only,line-start `@xx` = actionable handoff +- `callback-tools.ts` post_message 描述已统一为"行首 @猫名",避免误导 +- `detectInlineActionMentions()` 补上 write-side feedback,句中 action-like @ 不再静默丢弃 + --- ## Acceptance Criteria diff --git a/docs/features/F064-a2a-exit-check.md b/docs/features/F064-a2a-exit-check.md index 3685b6b72..44de94560 100644 --- a/docs/features/F064-a2a-exit-check.md +++ b/docs/features/F064-a2a-exit-check.md @@ -33,7 +33,7 @@ F064 的核心动机是修复 A2A 协作中的“链条终止盲区”:该 @ ### Phase B(运行时注入) - [x] AC-B1: 非 parallel 且 a2aEnabled 时注入出口检查提示。 - [x] AC-B2: `mentionRoutingFeedback` read-side 注入与测试覆盖完成。 -- [ ] AC-B3: write-side 自动回写尚未接入(列为已知债务并保留后续方案)。 +- [x] AC-B3: write-side 自动回写已接入(#417: detectInlineActionMentions + route-serial 集成)。 ## Dependencies @@ -127,30 +127,20 @@ Maine Coon(GPT-5.2) 在协作场景中反复出现两种极端: - 历史事件:Maine Coon mention spam 事件(Anti-Mention-Spam 规则起源) - 相关 Feature:F046 Anti-Drift Protocol、F055 A2A MCP Structured Routing -## Known Debt: `mentionRoutingFeedback` write-side 未接入 +## Resolved Debt: `mentionRoutingFeedback` write-side (#417) -**状态**:read-side 已完成(F064 PR #227),write-side 未实现 +**状态**:已完成(#417 PR) -**现状**: -- `buildInvocationContext()` 已能渲染 `mentionRoutingFeedback`(如果有值的话)→ 提醒猫"上次 @ 没生效" -- `ThreadStore.setMentionRoutingFeedback()` 接口已存在(in-memory + Redis 两个实现都有) -- **但没有任何代码在检测到"句中 @ 未路由"时调用 `setMentionRoutingFeedback()`** +**实现**: +- `a2a-mentions.ts` 新增 `detectInlineActionMentions()`:检测句中 `@pattern` + 动作词(请/帮/review/确认/check/fix/merge/ready for 等),排除代码块、引用块、已路由目标 +- `route-serial.ts` 在 `parseA2AMentions()` 之后调用检测,命中时写入 `setMentionRoutingFeedback()` +- 下次该猫被唤起时,read-side 渲染为 `[路由提醒]`,消费后自动清除(one-shot) +- `callback-tools.ts` post_message 描述已统一为"行首 @猫名",不再出现可能误导成句中 handoff 的说法 -**为什么没一起修**: -1. write-side 需要在 `routeSerial` 完成后分析猫的回复——检测"句中有 `@xxx` 但不在行首"→ 写入 feedback。当前 `a2a-mentions.ts` 只解析行首 @,不检测句中 @,需要扩展解析逻辑 -2. **误报风险高**:叙述性提及(如"Ragdoll已经完成了 @opus 的建议")不应触发反馈,但简单的正则很难区分"想 @ 但格式错"和"单纯的叙述性提及"。如果误报频繁,反而会引发 mention spam(猫收到"你上次 @ 没生效"→ 补一个行首 @ → 实际上不需要对方行动) -3. F064 的核心目标是**主动预防**(出口检查),write-side feedback 是**被动纠正**,优先级低 - -**未来接入建议**: -- 在 `routeSerial` 的猫回复完成后,用 `a2a-mentions.ts` 的扩展版本检测"句中有 @pattern 但不在行首"的情况 -- 只有同时满足以下条件才写入 feedback:(a) 句中有 @pattern (b) 同段有动作词(请/帮/review/确认等)(c) 不在代码块或引用块中 -- 写入后设 TTL(如 1 次调用后过期),避免反复提醒 - -**相关代码位置**: -- Read-side: `SystemPromptBuilder.ts:384-388` -- Store interface: `ThreadStore.ts:129-137` -- 路由入口: `route-serial.ts:120-149` -- 解析器: `a2a-mentions.ts`(需扩展) +**误报控制**: +- 必须同时满足:(a) 句中 @pattern (b) 同行有动作词 (c) 不在代码块/引用块 (d) 该猫未被行首 @ 路由 +- 纯叙述性提及(如"之前 @codex 提出的方案不错")不会触发 +- feedback 是 one-shot(consumeMentionRoutingFeedback 读后即删),不会反复提醒 ## 愿景守护签收表 diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts index 4b81e3f35..4cbe3cdd2 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts @@ -47,6 +47,12 @@ export interface A2AMentionAnalysis { readonly suppressed: SuppressedA2AMention[]; } +/** #417: Inline @mention paired with action words — missed handoff candidate. */ +export interface InlineActionMention { + readonly catId: CatId; + readonly lineText: string; +} + /** @deprecated Mode is ignored — line-start mentions always route regardless of mode. */ export type MentionActionabilityMode = 'strict' | 'relaxed'; @@ -118,3 +124,68 @@ export function analyzeA2AMentions( return { mentions: found, suppressed: [] }; } + +/** + * #417: Detect inline @mentions paired with action words — missed handoff candidates. + * Used for write-side feedback only, NOT for routing. + * + * Conditions (all must hold): + * 1. @pattern appears mid-line (not at line start) + * 2. Same line contains an action keyword (请/帮/review/确认/处理/check/fix/merge/ready for/etc.) + * 3. Not inside a fenced code block or blockquote + * 4. Target cat was not already routed via line-start mention + * 5. Not a self-mention + */ +const INLINE_ACTION_RE = + /(?:请|帮|review|确认|处理|看一?下|check|fix|merge|ready\s+for|交接|接力|你来|你去)/i; + +export function detectInlineActionMentions( + text: string, + currentCatId?: CatId, + routedMentions?: CatId[], +): InlineActionMention[] { + if (!text) return []; + + const stripped = text.replace(/```[\s\S]*?```/g, ''); + const allConfigs = + Object.keys(catRegistry.getAllConfigs()).length > 0 ? catRegistry.getAllConfigs() : CAT_CONFIGS; + + const entries: MentionPatternEntry[] = []; + for (const [id, config] of Object.entries(allConfigs)) { + if (currentCatId && id === currentCatId) continue; + if (!isCatAvailable(id)) continue; + for (const pattern of config.mentionPatterns) { + entries.push({ catId: id as CatId, pattern: pattern.toLowerCase() }); + } + } + entries.sort((a, b) => b.pattern.length - a.pattern.length); + + const routedSet = new Set(routedMentions ?? []); + const found: InlineActionMention[] = []; + const seen = new Set(); + + for (const rawLine of stripped.split(/\r?\n/)) { + const trimmed = rawLine.trimStart(); + const normalized = trimmed.toLowerCase(); + // Skip line-start @mentions (handled by parseA2AMentions) and blockquotes + if (normalized.startsWith('@') || normalized.startsWith('>')) continue; + + for (const entry of entries) { + const idx = normalized.indexOf(entry.pattern); + if (idx < 0) continue; + const charAfter = normalized[idx + entry.pattern.length]; + const isBoundary = + !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); + if (!isBoundary) continue; + if (routedSet.has(entry.catId)) continue; + if (!INLINE_ACTION_RE.test(rawLine)) continue; + if (!seen.has(entry.catId)) { + seen.add(entry.catId); + found.push({ catId: entry.catId, lineText: rawLine.trim() }); + } + break; + } + } + + return found; +} 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 10d111ce5..f01cdfc09 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 @@ -36,7 +36,7 @@ import { invokeSingleCat } from '../invocation/invoke-single-cat.js'; import { buildMcpCallbackInstructions, needsMcpInjection } from '../invocation/McpPromptInjector.js'; import { getRichBlockBuffer } from '../invocation/RichBlockBuffer.js'; import { resolveDefaultClaudeMcpServerPath } from '../providers/ClaudeAgentService.js'; -import { getMaxA2ADepth, parseA2AMentions } from '../routing/a2a-mentions.js'; +import { detectInlineActionMentions, getMaxA2ADepth, parseA2AMentions } from '../routing/a2a-mentions.js'; import { registerWorklist, unregisterWorklist } from '../routing/WorklistRegistry.js'; import { buildBriefingMessage } from './format-briefing.js'; import { extractRichFromText, isValidRichBlock } from './rich-block-extract.js'; @@ -654,6 +654,25 @@ export async function* routeSerial( // Line-start @mention = always actionable (no keyword gate) a2aMentions = parseA2AMentions(storedContent, catId); + // #417 / F064 AC-B3: Write-side feedback for inline action-like @mentions + if (deps.invocationDeps.threadStore) { + const inlineHits = detectInlineActionMentions(storedContent, catId, a2aMentions); + if (inlineHits.length > 0) { + try { + await deps.invocationDeps.threadStore.setMentionRoutingFeedback(threadId, catId, { + sourceTimestamp: Date.now(), + items: inlineHits.map((m) => ({ targetCatId: m.catId, reason: 'inline_action' as const })), + }); + log.info( + { catId: catId as string, threadId, targets: inlineHits.map((h) => h.catId) }, + 'Inline action @mention detected — wrote routing feedback', + ); + } catch { + /* best-effort */ + } + } + } + // F079 Phase 2: Vote interception — extract [VOTE:xxx] from cat response const votedOption = extractVoteFromText(storedContent); if (votedOption && deps.invocationDeps.threadStore) { diff --git a/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts b/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts index ae77d2093..34eabc3b1 100644 --- a/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts +++ b/packages/api/src/domains/cats/services/stores/ports/ThreadStore.ts @@ -68,7 +68,7 @@ export interface ThreadMemoryV1 { artifacts?: string[]; } -export type MentionRoutingSuppressionReason = 'no_action' | 'cross_paragraph'; +export type MentionRoutingSuppressionReason = 'no_action' | 'cross_paragraph' | 'inline_action'; export type MentionActionabilityMode = 'strict' | 'relaxed'; export interface ThreadMentionRoutingFeedbackItem { diff --git a/packages/api/test/a2a-mentions.test.js b/packages/api/test/a2a-mentions.test.js index fbbe07eb9..83fdd88e5 100644 --- a/packages/api/test/a2a-mentions.test.js +++ b/packages/api/test/a2a-mentions.test.js @@ -257,6 +257,71 @@ describe('F052: cross-thread self-reference exemption', () => { }); }); +describe('#417: detectInlineActionMentions', () => { + it('detects inline @mention with action word (Ready for @codex review)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '217 tests pass. Ready for @codex review on lifecycle completeness.'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.equal(result.length, 1); + assert.equal(result[0].catId, 'codex'); + }); + + it('detects Chinese action word with inline @mention', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '这个方案 @codex 请帮忙看一下'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.equal(result.length, 1); + assert.equal(result[0].catId, 'codex'); + }); + + it('ignores inline @mention WITHOUT action word (pure narrative)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '之前 @codex 提出的方案不错'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.deepEqual(result, []); + }); + + it('skips cats already routed via line-start mention', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = 'Ready for @codex review'; + const result = detectInlineActionMentions(text, 'opus', ['codex']); + assert.deepEqual(result, []); + }); + + it('ignores line-start @mention (those are handled by parseA2AMentions)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '@codex 请 review'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.deepEqual(result, []); + }); + + it('ignores @mention inside code blocks', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '看看代码:\n```\nReady for @codex review\n```'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.deepEqual(result, []); + }); + + it('ignores @mention inside blockquotes', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '> Ready for @codex review'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.deepEqual(result, []); + }); + + it('filters self-mention', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = 'Ready for @opus review'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.deepEqual(result, []); + }); + + it('returns empty for empty text', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + assert.deepEqual(detectInlineActionMentions('', 'opus', []), []); + }); +}); + describe('SystemPromptBuilder A2A injection', () => { it('includes A2A section when a2aEnabled and serial mode', async () => { const { buildSystemPrompt } = await import('../dist/domains/cats/services/context/SystemPromptBuilder.js'); diff --git a/packages/mcp-server/src/tools/callback-tools.ts b/packages/mcp-server/src/tools/callback-tools.ts index fa5a553ed..8eb72aaba 100644 --- a/packages/mcp-server/src/tools/callback-tools.ts +++ b/packages/mcp-server/src/tools/callback-tools.ts @@ -720,8 +720,8 @@ export const callbackTools = [ description: 'Post a proactive async message to YOUR CURRENT thread mid-task (e.g. progress updates, sharing results). ' + 'Always posts to the thread your invocation belongs to. To post to a DIFFERENT thread, use cat_cafe_cross_post_message instead. ' + - 'To simply @mention another cat at the end of your response, use @猫名 in your reply text instead — it is free and never expires. ' + - 'GOTCHA: This tool uses callback credentials that expire — if it fails with 401, fall back to inline @mention in your response text. ' + + 'To hand off to another cat, write @猫名 on its own line at the START of the line (sentence-internal @mention does NOT route — it is treated as narrative only). ' + + 'GOTCHA: This tool uses callback credentials that expire — if it fails with 401, fall back to line-start @mention in your response text. ' + 'GOTCHA: Do NOT use this for routine replies — only for mid-task proactive messages when you need to share something before your response completes.', inputSchema: postMessageInputSchema, handler: handlePostMessage, From ad9aea93f91f45b2156bc728846a5ede4ebe42de Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 12:19:56 +0800 Subject: [PATCH 02/10] fix(a2a): tighten inline action detection to proximity-based matching (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from @gpt52: P1 (blocking): whole-line action matching caused false positives. "请按 @codex 之前的建议继续处理" triggered on @codex because "请" and "处理" were anywhere on the line. Multi-mention lines also hit the wrong target. Fix: replace INLINE_ACTION_RE whole-line scan with proximity-based BEFORE_HANDOFF_RE / AFTER_HANDOFF_RE that must be immediately adjacent to the @mention. 4 new regression tests from gpt52 repro cases, all green. P2 (should-fix): F064 doc claimed global write-side completion but only serial response path is covered. Narrowed AC-B3 and debt section to "serial path via route-serial; callback path not yet covered." 210 tests pass, 0 fail. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- docs/features/F064-a2a-exit-check.md | 21 +++++++----- .../services/agents/routing/a2a-mentions.ts | 15 ++++++--- packages/api/test/a2a-mentions.test.js | 32 +++++++++++++++++++ 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/docs/features/F064-a2a-exit-check.md b/docs/features/F064-a2a-exit-check.md index 44de94560..a2f895797 100644 --- a/docs/features/F064-a2a-exit-check.md +++ b/docs/features/F064-a2a-exit-check.md @@ -33,7 +33,7 @@ F064 的核心动机是修复 A2A 协作中的“链条终止盲区”:该 @ ### Phase B(运行时注入) - [x] AC-B1: 非 parallel 且 a2aEnabled 时注入出口检查提示。 - [x] AC-B2: `mentionRoutingFeedback` read-side 注入与测试覆盖完成。 -- [x] AC-B3: write-side 自动回写已接入(#417: detectInlineActionMentions + route-serial 集成)。 +- [x] AC-B3: write-side 自动回写已部分接入(#417: serial response path via route-serial; callback/post_message path 尚未覆盖)。 ## Dependencies @@ -127,18 +127,23 @@ Maine Coon(GPT-5.2) 在协作场景中反复出现两种极端: - 历史事件:Maine Coon mention spam 事件(Anti-Mention-Spam 规则起源) - 相关 Feature:F046 Anti-Drift Protocol、F055 A2A MCP Structured Routing -## Resolved Debt: `mentionRoutingFeedback` write-side (#417) +## Partially Resolved Debt: `mentionRoutingFeedback` write-side (#417) -**状态**:已完成(#417 PR) +**状态**:serial response path 已接入;callback/post_message path 尚未覆盖 -**实现**: -- `a2a-mentions.ts` 新增 `detectInlineActionMentions()`:检测句中 `@pattern` + 动作词(请/帮/review/确认/check/fix/merge/ready for 等),排除代码块、引用块、已路由目标 +**已接入路径(route-serial)**: +- `a2a-mentions.ts` 新增 `detectInlineActionMentions()`:邻近性检测句中 `@pattern` + 紧邻动作词 - `route-serial.ts` 在 `parseA2AMentions()` 之后调用检测,命中时写入 `setMentionRoutingFeedback()` - 下次该猫被唤起时,read-side 渲染为 `[路由提醒]`,消费后自动清除(one-shot) -- `callback-tools.ts` post_message 描述已统一为"行首 @猫名",不再出现可能误导成句中 handoff 的说法 +- `callback-tools.ts` post_message 描述已统一为"行首 @猫名" -**误报控制**: -- 必须同时满足:(a) 句中 @pattern (b) 同行有动作词 (c) 不在代码块/引用块 (d) 该猫未被行首 @ 路由 +**未覆盖路径**: +- `callbacks.ts` 的 post_message callback 路径仍只做行首 mention 解析,未调用 write-side feedback +- 后续如需完整覆盖,需在 callback 写路径也接入 `detectInlineActionMentions` + +**误报控制(邻近性检测)**: +- 动作词必须紧邻 @mention 前后(BEFORE: ready for/请/帮/交接给/转给;AFTER: review/check/确认/处理 等) +- 整行有动作词但不紧邻 @mention 不触发(如"请按 @codex 之前的建议继续处理") - 纯叙述性提及(如"之前 @codex 提出的方案不错")不会触发 - feedback 是 one-shot(consumeMentionRoutingFeedback 读后即删),不会反复提醒 diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts index 4cbe3cdd2..bbe21380b 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts @@ -131,13 +131,16 @@ export function analyzeA2AMentions( * * Conditions (all must hold): * 1. @pattern appears mid-line (not at line start) - * 2. Same line contains an action keyword (请/帮/review/确认/处理/check/fix/merge/ready for/etc.) + * 2. Action keyword immediately adjacent to @mention (proximity-based, not whole-line) * 3. Not inside a fenced code block or blockquote * 4. Target cat was not already routed via line-start mention * 5. Not a self-mention */ -const INLINE_ACTION_RE = - /(?:请|帮|review|确认|处理|看一?下|check|fix|merge|ready\s+for|交接|接力|你来|你去)/i; + +/** Action patterns that appear immediately BEFORE @mention (e.g. "Ready for @xxx"). */ +const BEFORE_HANDOFF_RE = /(?:ready\s+for|交接给?|转给|请|帮)\s*$/i; +/** Action patterns that appear immediately AFTER @mention (e.g. "@xxx review"). */ +const AFTER_HANDOFF_RE = /^\s*(?:review|check|fix|merge|确认|处理|看一?下|来处理|来看|帮忙|请)/i; export function detectInlineActionMentions( text: string, @@ -167,7 +170,6 @@ export function detectInlineActionMentions( for (const rawLine of stripped.split(/\r?\n/)) { const trimmed = rawLine.trimStart(); const normalized = trimmed.toLowerCase(); - // Skip line-start @mentions (handled by parseA2AMentions) and blockquotes if (normalized.startsWith('@') || normalized.startsWith('>')) continue; for (const entry of entries) { @@ -178,7 +180,10 @@ export function detectInlineActionMentions( !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); if (!isBoundary) continue; if (routedSet.has(entry.catId)) continue; - if (!INLINE_ACTION_RE.test(rawLine)) continue; + // Proximity check: action keyword must be immediately adjacent to @mention + const before = normalized.slice(0, idx); + const after = normalized.slice(idx + entry.pattern.length); + if (!BEFORE_HANDOFF_RE.test(before) && !AFTER_HANDOFF_RE.test(after)) continue; if (!seen.has(entry.catId)) { seen.add(entry.catId); found.push({ catId: entry.catId, lineText: rawLine.trim() }); diff --git a/packages/api/test/a2a-mentions.test.js b/packages/api/test/a2a-mentions.test.js index 83fdd88e5..e2fb9d666 100644 --- a/packages/api/test/a2a-mentions.test.js +++ b/packages/api/test/a2a-mentions.test.js @@ -320,6 +320,38 @@ describe('#417: detectInlineActionMentions', () => { const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); assert.deepEqual(detectInlineActionMentions('', 'opus', []), []); }); + + // --- P1 regression: gpt52 review repro cases (proximity-based matching) --- + + it('P1-repro: "请按 @codex 之前的建议" is narrative, not handoff', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '请按 @codex 之前的建议继续处理这个问题。'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.deepEqual(result, [], 'should not trigger: "请按" is not a handoff directive to @codex'); + }); + + it('P1-repro: multi-mention line targets the right cat', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '之前 @codex 讨论过,Ready for @gemini review on this patch.'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.equal(result.length, 1, 'should detect exactly one inline action mention'); + assert.equal(result[0].catId, 'gemini', 'should target gemini (Ready for), not codex (narrative)'); + }); + + it('proximity: "帮 @codex review" triggers (adjacent action)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '这个问题帮 @codex review 一下'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.equal(result.length, 1); + assert.equal(result[0].catId, 'codex'); + }); + + it('proximity: distant action word does not trigger', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '请参考 @codex 提出的方案并继续处理'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.deepEqual(result, [], 'action words "请" and "处理" are not adjacent to @codex'); + }); }); describe('SystemPromptBuilder A2A injection', () => { From 6abb5a70422784b9a75e1c47e05024f67c771353 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 12:25:45 +0800 Subject: [PATCH 03/10] fix(a2a): exclude completion suffixes + scan all occurrences per line (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 from @gpt52: P1: Chinese verbs in AFTER_HANDOFF_RE (确认/处理/来处理/来看) triggered on narrative past-tense forms like "@codex 处理过" and "@codex 确认了". Fix: negative lookahead (?![过了完好掉]) excludes completion suffixes while preserving imperative forms like "@codex 处理一下". P2: indexOf() only checked the first occurrence of a pattern per line. "之前 @codex 提过意见,现在 Ready for @codex review" returned [] because the first @codex failed proximity check and the second was never examined. Fix: inner while loop scans all positions via indexOf(pattern, searchFrom). 5 new regression tests (3 completion-suffix, 1 imperative sanity, 1 same-cat-twice). 215 total, 0 fail. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../services/agents/routing/a2a-mentions.ts | 47 ++++++++++++------- packages/api/test/a2a-mentions.test.js | 34 ++++++++++++++ 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts index bbe21380b..147a7cb5f 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts @@ -139,8 +139,13 @@ export function analyzeA2AMentions( /** Action patterns that appear immediately BEFORE @mention (e.g. "Ready for @xxx"). */ const BEFORE_HANDOFF_RE = /(?:ready\s+for|交接给?|转给|请|帮)\s*$/i; -/** Action patterns that appear immediately AFTER @mention (e.g. "@xxx review"). */ -const AFTER_HANDOFF_RE = /^\s*(?:review|check|fix|merge|确认|处理|看一?下|来处理|来看|帮忙|请)/i; +/** + * Action patterns immediately AFTER @mention (e.g. "@xxx review"). + * Chinese verbs use negative lookahead to exclude completion suffixes (过/了/完/好/掉), + * which turn commands into narrative: "@codex 处理过" ≠ "@codex 处理一下". + */ +const AFTER_HANDOFF_RE = + /^\s*(?:review|check|fix|merge|(?:确认|处理|来处理|来看)(?![过了完好掉])|看一?下|帮忙|请)/i; export function detectInlineActionMentions( text: string, @@ -172,23 +177,31 @@ export function detectInlineActionMentions( const normalized = trimmed.toLowerCase(); if (normalized.startsWith('@') || normalized.startsWith('>')) continue; + let lineMatched = false; for (const entry of entries) { - const idx = normalized.indexOf(entry.pattern); - if (idx < 0) continue; - const charAfter = normalized[idx + entry.pattern.length]; - const isBoundary = - !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); - if (!isBoundary) continue; - if (routedSet.has(entry.catId)) continue; - // Proximity check: action keyword must be immediately adjacent to @mention - const before = normalized.slice(0, idx); - const after = normalized.slice(idx + entry.pattern.length); - if (!BEFORE_HANDOFF_RE.test(before) && !AFTER_HANDOFF_RE.test(after)) continue; - if (!seen.has(entry.catId)) { - seen.add(entry.catId); - found.push({ catId: entry.catId, lineText: rawLine.trim() }); + if (lineMatched) break; + // Scan ALL occurrences of this pattern in the line (not just first indexOf hit). + // Fixes: "之前 @codex 提过意见,现在 Ready for @codex review" must find the second one. + let searchFrom = 0; + while (searchFrom < normalized.length) { + const idx = normalized.indexOf(entry.pattern, searchFrom); + if (idx < 0) break; + searchFrom = idx + 1; + const charAfter = normalized[idx + entry.pattern.length]; + const isBoundary = + !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); + if (!isBoundary) continue; + if (routedSet.has(entry.catId)) { lineMatched = true; break; } + const before = normalized.slice(0, idx); + const after = normalized.slice(idx + entry.pattern.length); + if (!BEFORE_HANDOFF_RE.test(before) && !AFTER_HANDOFF_RE.test(after)) continue; + if (!seen.has(entry.catId)) { + seen.add(entry.catId); + found.push({ catId: entry.catId, lineText: rawLine.trim() }); + } + lineMatched = true; + break; } - break; } } diff --git a/packages/api/test/a2a-mentions.test.js b/packages/api/test/a2a-mentions.test.js index e2fb9d666..0d8ab872c 100644 --- a/packages/api/test/a2a-mentions.test.js +++ b/packages/api/test/a2a-mentions.test.js @@ -352,6 +352,40 @@ describe('#417: detectInlineActionMentions', () => { const result = detectInlineActionMentions(text, 'opus', []); assert.deepEqual(result, [], 'action words "请" and "处理" are not adjacent to @codex'); }); + + // --- R2 P1 regression: completion suffixes turn commands into narrative --- + + it('R2-P1: "@codex 处理过" is narrative (completion suffix 过)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + assert.deepEqual(detectInlineActionMentions('之前 @codex 处理过这个问题', 'opus', []), []); + }); + + it('R2-P1: "@codex 确认了" is narrative (completion suffix 了)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + assert.deepEqual(detectInlineActionMentions('之前 @codex 确认了这个方案', 'opus', []), []); + }); + + it('R2-P1: "@codex 来看过" is narrative (completion suffix 过)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + assert.deepEqual(detectInlineActionMentions('之前 @codex 来看过一次', 'opus', []), []); + }); + + it('R2-P1: "@codex 处理一下" is still handoff (no completion suffix)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const result = detectInlineActionMentions('这个问题 @codex 处理一下', 'opus', []); + assert.equal(result.length, 1); + assert.equal(result[0].catId, 'codex'); + }); + + // --- R2 P2 regression: same cat appears twice, second occurrence is handoff --- + + it('R2-P2: same cat twice — first narrative, second handoff', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = '之前 @codex 提过意见,现在 Ready for @codex review'; + const result = detectInlineActionMentions(text, 'opus', []); + assert.equal(result.length, 1, 'should detect the second occurrence as handoff'); + assert.equal(result[0].catId, 'codex'); + }); }); describe('SystemPromptBuilder A2A injection', () => { From 975404abfad7a16c731ba2309b7beb1557a868e4 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 13:06:40 +0800 Subject: [PATCH 04/10] style(a2a): fix biome formatting in detectInlineActionMentions (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../cats/services/agents/routing/a2a-mentions.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts index 147a7cb5f..bb7713543 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts @@ -144,8 +144,7 @@ const BEFORE_HANDOFF_RE = /(?:ready\s+for|交接给?|转给|请|帮)\s*$/i; * Chinese verbs use negative lookahead to exclude completion suffixes (过/了/完/好/掉), * which turn commands into narrative: "@codex 处理过" ≠ "@codex 处理一下". */ -const AFTER_HANDOFF_RE = - /^\s*(?:review|check|fix|merge|(?:确认|处理|来处理|来看)(?![过了完好掉])|看一?下|帮忙|请)/i; +const AFTER_HANDOFF_RE = /^\s*(?:review|check|fix|merge|(?:确认|处理|来处理|来看)(?![过了完好掉])|看一?下|帮忙|请)/i; export function detectInlineActionMentions( text: string, @@ -155,8 +154,7 @@ export function detectInlineActionMentions( if (!text) return []; const stripped = text.replace(/```[\s\S]*?```/g, ''); - const allConfigs = - Object.keys(catRegistry.getAllConfigs()).length > 0 ? catRegistry.getAllConfigs() : CAT_CONFIGS; + const allConfigs = Object.keys(catRegistry.getAllConfigs()).length > 0 ? catRegistry.getAllConfigs() : CAT_CONFIGS; const entries: MentionPatternEntry[] = []; for (const [id, config] of Object.entries(allConfigs)) { @@ -188,10 +186,12 @@ export function detectInlineActionMentions( if (idx < 0) break; searchFrom = idx + 1; const charAfter = normalized[idx + entry.pattern.length]; - const isBoundary = - !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); + const isBoundary = !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); if (!isBoundary) continue; - if (routedSet.has(entry.catId)) { lineMatched = true; break; } + if (routedSet.has(entry.catId)) { + lineMatched = true; + break; + } const before = normalized.slice(0, idx); const after = normalized.slice(idx + entry.pattern.length); if (!BEFORE_HANDOFF_RE.test(before) && !AFTER_HANDOFF_RE.test(after)) continue; From 6a9db119eef4d801e086e136f9da4470196ad5b8 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 15:15:29 +0800 Subject: [PATCH 05/10] style: fix biome formatting in project-setup-card-ime test (from main merge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../components/__tests__/project-setup-card-ime.test.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/web/src/components/__tests__/project-setup-card-ime.test.tsx b/packages/web/src/components/__tests__/project-setup-card-ime.test.tsx index 6a8bd8ac2..f27f03332 100644 --- a/packages/web/src/components/__tests__/project-setup-card-ime.test.tsx +++ b/packages/web/src/components/__tests__/project-setup-card-ime.test.tsx @@ -40,13 +40,7 @@ describe('ProjectSetupCard IME guard', () => { await act(async () => { root.render( - , + , ); }); From be65df229dad129ea960b37d95c273972e0f8701 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 15:21:32 +0800 Subject: [PATCH 06/10] fix(a2a): routed cat must not block other cats on same line (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review P2: when an inline @mention belonged to an already-routed cat, lineMatched=true broke out of the entire line scan, silently dropping actionable mentions for other cats on the same line. Fix: break the while loop (skip remaining positions of the routed cat) but do NOT set lineMatched, so the outer for loop continues checking other entries. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../cats/services/agents/routing/a2a-mentions.ts | 6 ++---- packages/api/test/a2a-mentions.test.js | 11 +++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts index bb7713543..d1380c11a 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts @@ -188,10 +188,8 @@ export function detectInlineActionMentions( const charAfter = normalized[idx + entry.pattern.length]; const isBoundary = !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); if (!isBoundary) continue; - if (routedSet.has(entry.catId)) { - lineMatched = true; - break; - } + // Already routed via line-start: skip this entry but keep scanning other cats on same line. + if (routedSet.has(entry.catId)) break; const before = normalized.slice(0, idx); const after = normalized.slice(idx + entry.pattern.length); if (!BEFORE_HANDOFF_RE.test(before) && !AFTER_HANDOFF_RE.test(after)) continue; diff --git a/packages/api/test/a2a-mentions.test.js b/packages/api/test/a2a-mentions.test.js index 0d8ab872c..a1012153f 100644 --- a/packages/api/test/a2a-mentions.test.js +++ b/packages/api/test/a2a-mentions.test.js @@ -386,6 +386,17 @@ describe('#417: detectInlineActionMentions', () => { assert.equal(result.length, 1, 'should detect the second occurrence as handoff'); assert.equal(result[0].catId, 'codex'); }); + + // --- Codex review: routed cat on same line must not block other cats --- + + it('routed cat does not block other cats on same line', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const text = 'Ready for @codex and @gemini review'; + // codex already routed via line-start, gemini is not + const result = detectInlineActionMentions(text, 'opus', ['codex']); + assert.equal(result.length, 1, 'should still detect gemini'); + assert.equal(result[0].catId, 'gemini'); + }); }); describe('SystemPromptBuilder A2A injection', () => { From 3f99655f64bc749430d8d68a63e272e73414a825 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 15:37:38 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(a2a):=20exclude=20=E8=AF=B7=E6=95=99/?= =?UTF-8?q?=E8=AF=B7=E7=A4=BA=20compounds=20+=20require=20left=20boundary?= =?UTF-8?q?=20(#417)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review R3: 1. AFTER_HANDOFF_RE "请" matched narrative compounds like "请教过" (to consult). Fix: negative lookahead 请(?![教示假求问]). 2. Inline detection only checked right boundary, so "foo@codex" could match. Fix: require left token boundary (no preceding word char before @). 4 new regression tests, 53 pass / 0 fail. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../services/agents/routing/a2a-mentions.ts | 5 +++- packages/api/test/a2a-mentions.test.js | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts index d1380c11a..945202547 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts @@ -144,7 +144,8 @@ const BEFORE_HANDOFF_RE = /(?:ready\s+for|交接给?|转给|请|帮)\s*$/i; * Chinese verbs use negative lookahead to exclude completion suffixes (过/了/完/好/掉), * which turn commands into narrative: "@codex 处理过" ≠ "@codex 处理一下". */ -const AFTER_HANDOFF_RE = /^\s*(?:review|check|fix|merge|(?:确认|处理|来处理|来看)(?![过了完好掉])|看一?下|帮忙|请)/i; +const AFTER_HANDOFF_RE = + /^\s*(?:review|check|fix|merge|(?:确认|处理|来处理|来看)(?![过了完好掉])|看一?下|帮忙|请(?![教示假求问]))/i; export function detectInlineActionMentions( text: string, @@ -185,6 +186,8 @@ export function detectInlineActionMentions( const idx = normalized.indexOf(entry.pattern, searchFrom); if (idx < 0) break; searchFrom = idx + 1; + // Left boundary: @ must not be preceded by word-like chars (avoids "foo@codex") + if (idx > 0 && HANDLE_CONTINUATION_RE.test(normalized[idx - 1]!)) continue; const charAfter = normalized[idx + entry.pattern.length]; const isBoundary = !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter); if (!isBoundary) continue; diff --git a/packages/api/test/a2a-mentions.test.js b/packages/api/test/a2a-mentions.test.js index a1012153f..3fc8eec97 100644 --- a/packages/api/test/a2a-mentions.test.js +++ b/packages/api/test/a2a-mentions.test.js @@ -397,6 +397,30 @@ describe('#417: detectInlineActionMentions', () => { assert.equal(result.length, 1, 'should still detect gemini'); assert.equal(result[0].catId, 'gemini'); }); + + // --- Codex R3: 请 compound exclusion + left boundary --- + + it('"@codex 请教过" is narrative (请教 = consult, not imperative)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + assert.deepEqual(detectInlineActionMentions('之前 @codex 请教过这个问题', 'opus', []), []); + }); + + it('"@codex 请看" is still handoff (请 + action verb)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const result = detectInlineActionMentions('这个 @codex 请看一下', 'opus', []); + assert.equal(result.length, 1); + }); + + it('ignores embedded @mention without left boundary (foo@codex)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + assert.deepEqual(detectInlineActionMentions('contact foo@codex review', 'opus', []), []); + }); + + it('detects @mention with left boundary (space before @)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const result = detectInlineActionMentions('contact @codex review', 'opus', []); + assert.equal(result.length, 1); + }); }); describe('SystemPromptBuilder A2A injection', () => { From bc5adc2a6ce56fe3af59365782aa59bbfe0979b5 Mon Sep 17 00:00:00 2001 From: mindfn Date: Fri, 10 Apr 2026 15:45:38 +0800 Subject: [PATCH 08/10] fix(a2a): enforce word boundary after English action verbs (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex R4: AFTER_HANDOFF_RE matched prefixes like "reviewed" and "checklist" as actionable. Fix: (?![a-z]) after English verbs ensures only standalone imperatives match. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../cats/services/agents/routing/a2a-mentions.ts | 6 +++--- packages/api/test/a2a-mentions.test.js | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts index 945202547..21f8f11ef 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts @@ -141,11 +141,11 @@ export function analyzeA2AMentions( const BEFORE_HANDOFF_RE = /(?:ready\s+for|交接给?|转给|请|帮)\s*$/i; /** * Action patterns immediately AFTER @mention (e.g. "@xxx review"). - * Chinese verbs use negative lookahead to exclude completion suffixes (过/了/完/好/掉), - * which turn commands into narrative: "@codex 处理过" ≠ "@codex 处理一下". + * English verbs use (?![a-z]) to reject continuations ("reviewed", "checklist"). + * Chinese verbs use negative lookahead to exclude completion suffixes (过/了/完/好/掉). */ const AFTER_HANDOFF_RE = - /^\s*(?:review|check|fix|merge|(?:确认|处理|来处理|来看)(?![过了完好掉])|看一?下|帮忙|请(?![教示假求问]))/i; + /^\s*(?:(?:review|check|fix|merge)(?![a-z])|(?:确认|处理|来处理|来看)(?![过了完好掉])|看一?下|帮忙|请(?![教示假求问]))/i; export function detectInlineActionMentions( text: string, diff --git a/packages/api/test/a2a-mentions.test.js b/packages/api/test/a2a-mentions.test.js index 3fc8eec97..afdb620f9 100644 --- a/packages/api/test/a2a-mentions.test.js +++ b/packages/api/test/a2a-mentions.test.js @@ -421,6 +421,18 @@ describe('#417: detectInlineActionMentions', () => { const result = detectInlineActionMentions('contact @codex review', 'opus', []); assert.equal(result.length, 1); }); + + // --- Codex R4: English verb boundary --- + + it('"@codex reviewed" is narrative (past tense, not imperative)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + assert.deepEqual(detectInlineActionMentions('之前 @codex reviewed this', 'opus', []), []); + }); + + it('"@codex checklist" is narrative (compound word)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + assert.deepEqual(detectInlineActionMentions('@codex checklist 已更新', 'opus', []), []); + }); }); describe('SystemPromptBuilder A2A injection', () => { From 8b7f65f0cf34d7035ecd7613691551602a45b2de Mon Sep 17 00:00:00 2001 From: mindfn Date: Sat, 11 Apr 2026 00:21:19 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(a2a):=20exclude=20=E8=AF=B7-compounds?= =?UTF-8?q?=20(=E9=82=80=E8=AF=B7/=E7=94=B3=E8=AF=B7/=E6=95=AC=E8=AF=B7)?= =?UTF-8?q?=20from=20BEFORE=20handoff=20trigger=20(#417)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BEFORE_HANDOFF_RE matched 请 as compound suffix (邀请 = invite, 申请 = apply), causing false-positive inline action detection. Add negative lookbehind to require standalone 请 only. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../services/agents/routing/a2a-mentions.ts | 7 +++++-- packages/api/test/a2a-mentions.test.js | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts index 21f8f11ef..c40dde5ee 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts @@ -137,8 +137,11 @@ export function analyzeA2AMentions( * 5. Not a self-mention */ -/** Action patterns that appear immediately BEFORE @mention (e.g. "Ready for @xxx"). */ -const BEFORE_HANDOFF_RE = /(?:ready\s+for|交接给?|转给|请|帮)\s*$/i; +/** + * Action patterns that appear immediately BEFORE @mention (e.g. "Ready for @xxx"). + * Chinese 请 uses negative lookbehind to exclude compounds (邀请 = invite, 申请 = apply). + */ +const BEFORE_HANDOFF_RE = /(?:ready\s+for|交接给?|转给|(? { const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); assert.deepEqual(detectInlineActionMentions('@codex checklist 已更新', 'opus', []), []); }); + + // --- Codex R5: 请 as compound suffix (邀请/申请) --- + + it('"邀请 @codex 参加评审" is narrative (邀请 = invite, not imperative 请)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + assert.deepEqual(detectInlineActionMentions('邀请 @codex 参加评审', 'opus', []), []); + }); + + it('"申请 @codex 权限" is narrative (申请 = apply, not imperative 请)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + assert.deepEqual(detectInlineActionMentions('申请 @codex 权限', 'opus', []), []); + }); + + it('"请 @codex review" still triggers (standalone 请 is imperative)', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + const result = detectInlineActionMentions('这个问题请 @codex review 一下', 'opus', []); + assert.equal(result.length, 1); + }); }); describe('SystemPromptBuilder A2A injection', () => { From 6d5b0f21dadeb694210095ba17a091342d35c32b Mon Sep 17 00:00:00 2001 From: mindfn Date: Sat, 11 Apr 2026 00:31:39 +0800 Subject: [PATCH 10/10] fix(a2a): already-seen cat must not block fresh cats on same line (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an actionable inline mention matched a cat already in `seen`, the code still set `lineMatched = true`, preventing other cats on the same line from being scanned. Move `lineMatched = true` inside the `!seen` branch so deduped cats don't claim the line. [宪宪/Opus-46🐾] Co-Authored-By: Claude Opus 4.6 --- .../cats/services/agents/routing/a2a-mentions.ts | 3 ++- packages/api/test/a2a-mentions.test.js | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts index c40dde5ee..7f0701e87 100644 --- a/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts +++ b/packages/api/src/domains/cats/services/agents/routing/a2a-mentions.ts @@ -202,8 +202,9 @@ export function detectInlineActionMentions( if (!seen.has(entry.catId)) { seen.add(entry.catId); found.push({ catId: entry.catId, lineText: rawLine.trim() }); + lineMatched = true; } - lineMatched = true; + // Already-seen cat: don't claim the line — let other cats still be scanned. break; } } diff --git a/packages/api/test/a2a-mentions.test.js b/packages/api/test/a2a-mentions.test.js index 7246ed056..668ea0fef 100644 --- a/packages/api/test/a2a-mentions.test.js +++ b/packages/api/test/a2a-mentions.test.js @@ -451,6 +451,19 @@ describe('#417: detectInlineActionMentions', () => { const result = detectInlineActionMentions('这个问题请 @codex review 一下', 'opus', []); assert.equal(result.length, 1); }); + + // --- Codex R6: already-seen cat must not block fresh cats on later lines --- + + it('already-seen cat (longer pattern, scanned first) does not block fresh cat on same line', async () => { + const { detectInlineActionMentions } = await import('../dist/domains/cats/services/agents/routing/a2a-mentions.js'); + // @gemini (7 chars) is longer than @codex (6 chars), so entries sort puts gemini first. + // Line 1 adds gemini to seen. Line 2: gemini scanned first → already seen → must NOT set lineMatched. + const text = 'Ready for @gemini review\nReady for @gemini and @codex review'; + const result = detectInlineActionMentions(text, 'opus', []); + const catIds = result.map((r) => r.catId); + assert.ok(catIds.includes('gemini'), 'gemini should be detected from line 1'); + assert.ok(catIds.includes('codex'), 'codex should be detected from line 2 even though gemini already seen'); + }); }); describe('SystemPromptBuilder A2A injection', () => {