Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,30 @@ export type ValidationResult =
const MARKDOWN_LINE_PREFIX_RE = /^(?:(?:>\s*)|(?:[-*+]\s+)|(?:\d+[.)]\s+))+/;
const URL_RE = /https?:\/\/[^\s)\]]+/g;
const FENCED_CODE_RE = /```[\s\S]*?```/g;
const EVENT_DRIVEN_EXTERNAL_WAIT_RE =
/^(?:(?:[-*+]\s+)|(?:\d+[.)]\s+))?External Wait\s*:\s*event-driven\s*\((?!\s*\))[^)\r\n]+\)\s*$/i;
const CAT_SIGNATURE_LINE_RE = /^\s*\[(?:[^[\]\n]+\/[^[\]\n]+|[^[\]\n]+🐾)\]\s*$/u;

/**
* Strip trailing cat-signature paragraphs so final-slot checks land on the last
* content paragraph. Body bracket tokens like `[Phase B]` are preserved because
* they do not match the slashed-or-paw signature shape.
*/
export function stripTrailingCatSignatures(text: string): string {
if (!text) return text;
const lines = text.split(/\r?\n/);
let lastContentIdx = lines.length - 1;
while (lastContentIdx >= 0) {
const line = lines[lastContentIdx] ?? '';
if (line.trim() === '' || CAT_SIGNATURE_LINE_RE.test(line)) {
lastContentIdx--;
continue;
}
break;
}
if (lastContentIdx < 0) return '';
return lines.slice(0, lastContentIdx + 1).join('\n');
}

/**
* Extract final routing slot = structurally-stripped last non-empty paragraph.
Expand Down Expand Up @@ -72,6 +96,21 @@ export function finalRoutingSlot(text: string): string {
return paragraphs.length > 0 ? paragraphs[paragraphs.length - 1]! : '';
}

function slotHasEventDrivenExternalWaitExit(slot: string): boolean {
if (!slot) return false;
return slot.split(/\r?\n/).some((line) => EVENT_DRIVEN_EXTERNAL_WAIT_RE.test(line.trim()));
}

/**
* True iff the final routing slot contains the documented structural 2b external-wait exit.
*
* This is deliberately a slot-template check, not a natural-language intent classifier.
*/
export function hasEventDrivenExternalWaitExit(text: string | undefined): boolean {
if (!text) return false;
return slotHasEventDrivenExternalWaitExit(finalRoutingSlot(stripTrailingCatSignatures(text)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve URL ids before matching event-driven waits

When the final slot uses a URL as the callback identifier, e.g. External Wait: event-driven (https://github.com/org/repo/pull/32), this calls finalRoutingSlot(), which strips all http(s) URLs before the event-driven regex runs. That leaves an empty () and the new legal 2b exit is treated as missing, so guarded cats still get unnecessary remedial retries and the verdict/void-hold suppressions do not apply for URL-based PR/check ids. Match the External Wait template before URL removal or preserve URLs inside the parenthesized id.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 218e847338e039aa2b84490b54d96be266f22b59. hasEventDrivenExternalWaitExit() now selects the final routing slot without URL stripping, so External Wait: event-driven (https://github.com/...) is accepted while finalRoutingSlot() still strips URLs for inline-mention validation. Red/green: URL-id regression failed before the fix and passes now; expanded routing guard suite 149/149; pnpm check passed.

}

/**
* Find inline @handle mentions in slot (= not at line-start position).
*
Expand Down Expand Up @@ -132,6 +171,7 @@ export function findInlineMentionsInSlot(slot: string, rosterHandles: readonly s
* - legitimate line-start @mention present
* - hold_ball tool call present
* - structured MCP routing (targetCats / multi_mention targets) present
* - structural 2b external wait slot present
* - no inline @handle inside final routing slot
*
* Returns `invalid_route_syntax` when NONE of the above AND slot has inline @handle.
Expand All @@ -142,6 +182,8 @@ export function validateRoutingSyntax(input: ValidationInput): ValidationResult
if (input.structuredTargetCats.length > 0) return { kind: 'ok' };

const slot = finalRoutingSlot(input.text);
if (hasEventDrivenExternalWaitExit(input.text)) return { kind: 'ok' };

const inlineMentions = findInlineMentionsInSlot(slot, input.rosterHandles);
if (inlineMentions.length === 0) return { kind: 'ok' };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* KD-8 safe:只看"有无机械出口信号",零意图分类器。
*/

import { hasEventDrivenExternalWaitExit } from '../final-routing-slot.js';

/** Routing-tool substrings that count as a legitimate exit (持球/群发传球). */
const ROUTING_TOOL_SUBSTRINGS = ['hold_ball', 'multi_mention'] as const;

Expand All @@ -23,6 +25,8 @@ function hasRoutingToolCall(toolNames: readonly string[]): boolean {
}

export interface RoutingExitInput {
/** Stored output text. Only the final routing slot is inspected for structural external-wait exits. */
readonly text?: string;
/** Line-start @cat mentions parsed this turn (parseA2AMentions). */
readonly lineStartMentions: readonly string[];
/** Tool names invoked this turn (scan for hold_ball / multi_mention). */
Expand All @@ -36,13 +40,15 @@ export interface RoutingExitInput {
/**
* True iff the turn has a legitimate routing exit (传球 / 持球 / 升级).
* Mirrors the suppression set of evaluateVoidHold + F177-G hook
* (line-start @, hold_ball, multi_mention, targetCats, co-creator).
* (line-start @, hold_ball, multi_mention, targetCats, co-creator, structural
* 2b external wait slot).
*/
export function hasValidRoutingExit(input: RoutingExitInput): boolean {
if (input.lineStartMentions.length > 0) return true;
if (input.structuredTargetCats.length > 0) return true;
if (input.hasCoCreatorLineStartMention) return true;
if (hasRoutingToolCall(input.toolNames)) return true;
if (hasEventDrivenExternalWaitExit(input.text)) return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require verified callback before accepting waits

When a guarded reply contains External Wait: event-driven (...) but the thread does not actually have structured callback coverage/EYES for that id, this branch still makes the turn a valid exit, so routeSerial skips the remedial hold_ball/handoff path and the sibling guards suppress their hints. Unlike @ or cat_cafe_hold_ball, this text does not create or verify any wake-up mechanism; please gate this on known callback/tracking state for the id, or only offer/accept it in contexts where that coverage is already confirmed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dbda45b (fix: require covered event-driven waits).

What changed:

  • External Wait: event-driven (...) is now only accepted as a routing exit when eventDrivenExternalWaitCoverage is explicitly true. Text alone fails closed.
  • Direct connector routes and queued connector entries set that flag because they originate from the structured callback/tracking path. A2A/user text does not.
  • Routing remedial, final-slot syntax, void-hold, and verdict-without-pass guards all use the same coverage gate.

Red -> Green coverage:

  • Text-only event-driven waits now trigger remedial / syntax / void-hold / verdict warnings.
  • Verified coverage keeps existing 2b event-driven behavior intact.

Validation:

  • pnpm check
  • pnpm --dir packages/api run build
  • Targeted routing guard suites: 132/132 passed ✅
  • git diff --check

[砚砚/GPT-5.5🐾]

return false;
}

Expand Down Expand Up @@ -74,6 +80,7 @@ export const REMEDIAL_PROMPT =
'请只补一个出口,不要重做刚才的工作:\n' +
'- 传球:另起一行,行首独立写 @句柄(如 @opus48)\n' +
'- 持球等外部条件:调用 cat_cafe_hold_ball\n' +
'- 事件驱动外部等待(已有结构化回调 + EYES>0):另起一行写 External Wait: event-driven (<id>)\n' +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve original content for event-driven remedials

When a first pass has useful text but no exit, this new remedial option asks the model to return only External Wait: event-driven (<id>); however route-serial still treats only @... remedials as route-only, so an event-driven remedial is handled as replacement content and the persisted/visible message becomes just the wait line, discarding the original work the guard was supposed to patch. Please extend the route-only normalization/preservation path for this structural template before teaching the remedial prompt to emit it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b41b72a3 as part of the current-head preservation fix. Event-driven external-wait remedials are included in route-only normalization, so the first-pass content is preserved instead of replaced by the bare wait line. Focused suite is 85/85; expanded guard suite is 122/122; pnpm check passed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve original text for event-driven remedials

When a guarded cat first produces useful content but no route exit, this new prompt can now make the remedial call answer with only External Wait: event-driven (...). route-serial still recognizes only one-line @... remedials as route-only via normalizeRouteOnlyRemedialText, so this new exit is treated as replacement text rather than an exit patch, causing the original first-pass content to be discarded and the persisted/visible message to become just the bare external-wait line. Please either include this template in the route-only preservation path or avoid offering it as a “只补一个出口” remedial option.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b41b72a3. External Wait: event-driven (...) remedials are now route-only patches: route-serial preserves the first-pass visible text, validates downstream guards with the routing patch, and no longer emits guard/syntax/void-hold hints for the preserved turn. Red/green focused suite is 85/85; expanded guard suite is 122/122; pnpm check passed.

'- 升级co-creator:另起一行行首写 @co-creator';

export function buildRemedialPrompt(): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ import {
import { accumulateTextAggregate } from '../text-aggregation.js';
import { formatA2AHandoffContent } from './a2a-handoff-label.js';
import { extractContextEvalSignals } from './context-eval.js';
import { validateRoutingSyntax } from './final-routing-slot.js';
import {
hasEventDrivenExternalWaitExit,
stripTrailingCatSignatures,
validateRoutingSyntax,
} from './final-routing-slot.js';
import { buildBriefingMessage } from './format-briefing.js';
import { buildRemedialPrompt, hasValidRoutingExit, shouldRemediateRouting } from './guards/routing-guard-remedial.js';
import { extractRichFromText, isValidRichBlock } from './rich-block-extract.js';
Expand Down Expand Up @@ -175,13 +179,22 @@ function stripMarkdownRoutePrefix(line: string): string {
}

function normalizeRouteOnlyRemedialText(text: string): string | null {
const lines = text
const lines = stripTrailingCatSignatures(text)
.trim()
.split(/\r?\n/)
.map((line) => stripMarkdownRoutePrefix(line))
.filter((line) => line.length > 0);
if (lines.length !== 1) return null;
return ROUTE_ONLY_REMEDIAL_TEXT_RE.test(lines[0]) ? lines[0] : null;
const line = lines[0]!;
if (ROUTE_ONLY_REMEDIAL_TEXT_RE.test(line)) return line;
return hasEventDrivenExternalWaitExit(line) ? line : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle signed event-driven remedials as route-only

When the remedial model signs the route-only External Wait patch, the signature is a second non-empty line, so lines.length !== 1 returns before the new event-driven check can classify it as route-only. In that common signed-output case, route-serial treats the bare wait patch as replacement content, hiding and persisting over the first-pass answer instead of preserving it as the unsigned remedial test expects.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cd0ba30c29d29e52752f473dce6e6f437bee0c24. normalizeRouteOnlyRemedialText() now strips trailing cat signatures before classifying route-only remedial text, so signed External Wait: event-driven (...) patches preserve the first-pass response instead of replacing it. Added regression coverage for the signed remedial case. Verification: red test failed with signed patch replacing first-pass text; green route-serial suite 21/21; expanded guard suite 124/124; git diff --check clean; pnpm check passed.

}

function buildRoutingAnalysisContent(storedContent: string, routingContent: string): string {
if (!routingContent.trim()) return storedContent;
if (!storedContent.trim()) return routingContent;
if (routingContent.trim() === storedContent.trim()) return storedContent;
return `${storedContent}\n\n${routingContent}`;
}

function collectStructuredTargetCatsFromInput(input: unknown): string[] {
Expand Down Expand Up @@ -1483,6 +1496,7 @@ export async function* routeSerial(
allRichBlocks: RichBlock[];
a2aMentions: CatId[];
hasCoCreatorLineStartMention: boolean;
routingContent: string;
streamEvents: AgentMessage[];
}> => {
routingGuardAttempted = true;
Expand Down Expand Up @@ -1717,6 +1731,7 @@ export async function* routeSerial(
allRichBlocks: remedialAllRichBlocks,
a2aMentions: remedialA2aMentions,
hasCoCreatorLineStartMention: remedialHasCoCreatorLineStartMention,
routingContent: remedialRoutingContent,
// Exit-only remedials validate the original text instead of replacing it; surface it after validation.
streamEvents: preservesOriginalVisibleContent
? [...visibleRemedialStreamEvents, ...originalVisibleStreamEventsForRemedialTurn]
Expand All @@ -1732,6 +1747,7 @@ export async function* routeSerial(
shouldRemediateRouting({
needsGuard: needsServerRoutingGuard,
attempted: routingGuardAttempted,
text: '',
lineStartMentions: getRoutingExitLineStartMentions(),
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand All @@ -1748,6 +1764,7 @@ export async function* routeSerial(
noTextBlocksOverride = result.allRichBlocks;
if (
!hasValidRoutingExit({
text: result.routingContent,
lineStartMentions: getRoutingExitLineStartMentions(result.a2aMentions),
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand All @@ -1765,6 +1782,7 @@ export async function* routeSerial(
// F22: Extract cc_rich blocks from text (Route B fallback for non-MCP cats)
const { cleanText, blocks: textBlocks } = extractRichFromText(sanitized);
let storedContent = cleanText;
let routingAnalysisContent = storedContent;
let allRichBlocks = [...bufferedBlocks, ...textBlocks, ...streamRichBlocks];

// F34-b: Resolve voice blocks (audio with text, no url) — Route B path.
Expand Down Expand Up @@ -1799,6 +1817,7 @@ export async function* routeSerial(
shouldRemediateRouting({
needsGuard: needsServerRoutingGuard,
attempted: routingGuardAttempted,
text: storedContent,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress void-hold for event-driven wait exits

When this text makes shouldRemediateRouting skip the remedial invoke, the later evaluateVoidHold call in route-serial still only suppresses on @, structured targets, co-creator, or a real hold tool. A valid 2b response that explains “不需要 hold_ball” (as in the new integration test) matches the hold_ball text pattern and will still emit a [持球提醒]/ball.void_pass despite the final External Wait: event-driven (...) exit being accepted here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b41b72a3. evaluateVoidHold now suppresses on the shared structural External Wait: event-driven (...) exit, and route-serial feeds post-remedial guard checks with the routing analysis content. Added regression coverage for 不需要 hold_ball plus a valid event-driven external wait. Focused suite is 85/85; expanded guard suite is 122/122; pnpm check passed.

lineStartMentions: routingExitLineStartMentions,
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand All @@ -1809,13 +1828,15 @@ export async function* routeSerial(
for (const event of result.streamEvents) yield event;
await flushDeferredVoice();
storedContent = result.storedContent;
routingAnalysisContent = buildRoutingAnalysisContent(storedContent, result.routingContent);
allRichBlocks = result.allRichBlocks;
a2aMentions = result.a2aMentions;
routingExitLineStartMentions = getRoutingExitLineStartMentions(a2aMentions);
routingExitHasCoCreatorLineStartMention = result.hasCoCreatorLineStartMention;

if (
!hasValidRoutingExit({
text: routingAnalysisContent,
lineStartMentions: routingExitLineStartMentions,
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand Down Expand Up @@ -1851,7 +1872,7 @@ export async function* routeSerial(
}
}
const phaseHResult = validateRoutingSyntax({
text: storedContent,
text: routingAnalysisContent,
lineStartMentions: routingExitLineStartMentions,
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand Down Expand Up @@ -2005,7 +2026,7 @@ export async function* routeSerial(
// frustrationAutoIssueEligible=false but still need verdict-pass handoff guards.
options.verdictPassWarningEnabled !== false &&
shouldWarnVerdictWithoutPass({
text: storedContent,
text: routingAnalysisContent,
lineStartMentions: routingExitLineStartMentions,
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand All @@ -2030,7 +2051,7 @@ export async function* routeSerial(
});
const verdictFireAttr: Record<string, string> = {
...c2BaseAttr,
[TRIGGER]: detectMatchedVerdictKeyword(storedContent) ?? 'unknown',
[TRIGGER]: detectMatchedVerdictKeyword(routingAnalysisContent) ?? 'unknown',
};
c2VerdictHintEmitted.add(1, verdictFireAttr);
c2VerdictWithoutPassCount.add(1, verdictFireAttr);
Expand Down Expand Up @@ -2067,7 +2088,7 @@ export async function* routeSerial(
// hold-claim message, so drilldown lands on the original content, not on the hint.
let pendingC2VoidHoldSampleTrigger: string | null = null;
const voidHoldEval = evaluateVoidHold({
text: storedContent,
text: routingAnalysisContent,
toolNames: collectedToolNames,
lineStartMentions: routingExitLineStartMentions,
structuredTargetCats: [...structuredTargetCats],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,60 +13,7 @@
* shared-rules §10 已落地,本模块是不依赖猫配合的兜底信号。
*/

import { finalRoutingSlot } from './final-routing-slot.js';

/**
* Cat signature line pattern: must have a slash OR a paw 🐾.
*
* Per L0 identity rule (`assets/system-prompts/system-prompt-l0.md`) and
* `cat-cafe-skills/refs/commit-signatures.md`, valid cat signatures are:
* - Slashed: `[昵称/变体]` or `[昵称/变体🐾]` — e.g. `[宪宪/Opus-46🐾]`,
* `[砚砚/GPT-5.5]`
* - Slashless: `[昵称🐾]` — e.g. `[Spark🐾]`,
* `[烁烁🐾]`
* (paw REQUIRED — used by cats whose nickname is the full identifier)
*
* NOT a signature (per source-of-truth `commit-signatures.md` lines 12-13):
* - Slashless without paw — `[Spark]`, `[note]`, `[Phase B]` — these are
* just bracketed body tokens. R6 P1 (砚砚): the previous broader regex
* stripped these too, reintroducing the narrative-body false-positive
* class this PR is fixing (e.g. `"approved earlier.\n\n[Phase B]"`
* would strip `[Phase B]`, slot falls back to the prior paragraph, and
* `approved` fires.)
*
* Match: `[non-bracket-non-newline]` containing EITHER an internal `/`
* (matches both `[name/model]` and `[name/model🐾]`) OR a `🐾` (matches
* `[Spark🐾]` and `[烁烁🐾]`). A bare `[Phase B]` matches neither alternative.
*
* Stripping these before slot detection prevents `finalRoutingSlot('LGTM\n\n[宪宪/Opus-46🐾]')`
* from returning just the signature (which would make `shouldWarnVerdictWithoutPass`
* return false for a real signed verdict-without-pass).
*/
const CAT_SIGNATURE_LINE_RE = /^\s*\[(?:[^[\]\n]+\/[^[\]\n]+|[^[\]\n]+🐾)\]\s*$/u;

/**
* Strip trailing cat-signature paragraphs (and blank lines) so the slot picker
* lands on the last *content* paragraph. Body brackets that happen to match the
* signature shape are preserved — only TRAILING signature lines are stripped.
*
* Iterates from the last line backwards: blank lines and signature lines are
* dropped; the first non-empty, non-signature line stops the walk.
*/
function stripTrailingCatSignatures(text: string): string {
if (!text) return text;
const lines = text.split(/\r?\n/);
let lastContentIdx = lines.length - 1;
while (lastContentIdx >= 0) {
const line = lines[lastContentIdx] ?? '';
if (line.trim() === '' || CAT_SIGNATURE_LINE_RE.test(line)) {
lastContentIdx--;
continue;
}
break;
}
if (lastContentIdx < 0) return '';
return lines.slice(0, lastContentIdx + 1).join('\n');
}
import { finalRoutingSlot, hasEventDrivenExternalWaitExit, stripTrailingCatSignatures } from './final-routing-slot.js';

/**
* Review verdict 关键词。保守集,避免常见日常用语误报:
Expand Down Expand Up @@ -211,5 +158,6 @@ export function shouldWarnVerdictWithoutPass(input: VerdictWarningInput): boolea
if (hasHoldBallCall(input.toolNames)) return false;
if (input.structuredTargetCats.length > 0) return false;
if (input.hasCoCreatorLineStartMention) return false;
if (hasEventDrivenExternalWaitExit(input.text)) return false;
return true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* keyword). `shouldWarnVoidHold` is preserved as a backward-compatible shim.
*/

import { hasEventDrivenExternalWaitExit } from './final-routing-slot.js';

const FENCED_CODE_RE = /```[\s\S]*?```/g;
const URL_RE = /https?:\/\/[^\s)\]]+/g;

Expand Down Expand Up @@ -107,6 +109,7 @@ export function evaluateVoidHold(input: VoidHoldInput): VoidHoldEvaluation {
if (input.lineStartMentions.length > 0) return { shouldEmit: false, matchedPattern: matched };
if (input.structuredTargetCats.length > 0) return { shouldEmit: false, matchedPattern: matched };
if (input.hasCoCreatorLineStartMention) return { shouldEmit: false, matchedPattern: matched };
if (hasEventDrivenExternalWaitExit(input.text)) return { shouldEmit: false, matchedPattern: matched };
return { shouldEmit: true, matchedPattern: matched };
}

Expand Down
27 changes: 27 additions & 0 deletions packages/api/test/final-routing-slot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { describe, test } from 'node:test';
import {
finalRoutingSlot,
findInlineMentionsInSlot,
hasEventDrivenExternalWaitExit,
validateRoutingSyntax,
} from '../dist/domains/cats/services/agents/routing/final-routing-slot.js';

Expand Down Expand Up @@ -151,6 +152,32 @@ describe('F167 Phase H AC-H3: validateRoutingSyntax trigger conditions', () => {
});
assert.equal(result.kind, 'ok');
});

test('2b event-driven external wait exit suppresses inline mention syntax warning', () => {
const result = validateRoutingSyntax({
text: '不再 @codex。\nExternal Wait: event-driven (pr:35)',
lineStartMentions: [],
toolNames: [],
structuredTargetCats: [],
rosterHandles: roster,
});
assert.equal(result.kind, 'ok');
});

test('signed 2b event-driven external wait exit suppresses inline mention syntax warning', () => {
const text = '不再 @codex。\nExternal Wait: event-driven (pr:35)\n\n[砚砚/GPT-5.5]';

assert.equal(hasEventDrivenExternalWaitExit(text), true);

const result = validateRoutingSyntax({
text,
lineStartMentions: [],
toolNames: [],
structuredTargetCats: [],
rosterHandles: roster,
});
assert.equal(result.kind, 'ok');
});
});

describe('F167 Phase H AC-H6: structural exemptions', () => {
Expand Down
Loading
Loading