Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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,8 @@ 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;

/**
* Extract final routing slot = structurally-stripped last non-empty paragraph.
Expand Down Expand Up @@ -72,6 +74,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(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 Strip trailing cat signatures before checking external waits

When a cat follows the L0 identity rule and signs after the routing line, e.g. External Wait: event-driven (pr:35) followed by [砚砚/GPT-5.5], finalRoutingSlot(text) returns the signature paragraph, so this helper returns false. That makes the new 2b external-wait exit still trip the remedial/verdict/void guards for signed outputs; verdict-detect.ts already had to strip trailing cat signatures for the same final-slot failure mode, so this shared exit predicate should do the same before selecting the slot.

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 602e3c11d0be8df9fef1559f2c68db99ffdc449d. hasEventDrivenExternalWaitExit() now strips trailing cat signatures before selecting the final slot, and verdict-detect reuses the same shared stripper. Added regression coverage for External Wait: event-driven (...) followed by [砚砚/GPT-5.5]. Verification: build passed, expanded guard suite 123/123, git diff --check clean, pnpm check passed.

}

/**
* Find inline @handle mentions in slot (= not at line-start position).
*
Expand Down Expand Up @@ -132,6 +149,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 +160,8 @@ export function validateRoutingSyntax(input: ValidationInput): ValidationResult
if (input.structuredTargetCats.length > 0) return { kind: 'ok' };

const slot = finalRoutingSlot(input.text);
if (slotHasEventDrivenExternalWaitExit(slot)) 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,7 @@ 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, 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 @@ -181,7 +181,16 @@ function normalizeRouteOnlyRemedialText(text: string): string | null {
.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 +1492,7 @@ export async function* routeSerial(
allRichBlocks: RichBlock[];
a2aMentions: CatId[];
hasCoCreatorLineStartMention: boolean;
routingContent: string;
streamEvents: AgentMessage[];
}> => {
routingGuardAttempted = true;
Expand Down Expand Up @@ -1717,6 +1727,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 +1743,7 @@ export async function* routeSerial(
shouldRemediateRouting({
needsGuard: needsServerRoutingGuard,
attempted: routingGuardAttempted,
text: '',
lineStartMentions: getRoutingExitLineStartMentions(),
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand All @@ -1748,6 +1760,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 +1778,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 +1813,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 +1824,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 +1868,7 @@ export async function* routeSerial(
}
}
const phaseHResult = validateRoutingSyntax({
text: storedContent,
text: routingAnalysisContent,
lineStartMentions: routingExitLineStartMentions,
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand Down Expand Up @@ -2005,7 +2022,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 +2047,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 +2084,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,7 +13,7 @@
* shared-rules §10 已落地,本模块是不依赖猫配合的兜底信号。
*/

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

/**
* Cat signature line pattern: must have a slash OR a paw 🐾.
Expand Down Expand Up @@ -211,5 +211,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
11 changes: 11 additions & 0 deletions packages/api/test/final-routing-slot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,17 @@ 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');
});
});

describe('F167 Phase H AC-H6: structural exemptions', () => {
Expand Down
54 changes: 54 additions & 0 deletions packages/api/test/route-serial-routing-guard-remedial.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,41 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => {
);
});

test('event-driven external-wait remedial counts as route-only and keeps first-pass text visible', async () => {
const firstPass = '我查完 current truth;不再 @codex,只剩外部 CI gate。';
const service = createSequenceService('codex', [firstPass, 'External Wait: event-driven (pr:35)']);

const { appended, calls, yielded } = await runRoute(service, 'thread-routing-guard-event-driven-remedial');

assert.equal(calls.length, 2, 'first-pass no-exit text should trigger one remedial invoke');
assert.equal(
appended.find((m) => m.source?.connector === 'routing-guard-failure'),
undefined,
'event-driven route-only remedial should count as a valid routing exit',
);
assert.equal(
appended.find((m) => m.source?.connector === 'routing-syntax-hint'),
undefined,
'event-driven remedial exit should suppress inline-mention syntax hints for the preserved first-pass text',
);
assert.equal(
appended.find((m) => m.source?.connector === 'void-hold-hint'),
undefined,
'event-driven remedial exit should not be treated as a void hold',
);

const codexMessages = appended.filter((m) => m.catId === 'codex' && m.origin === 'stream');
assert.equal(codexMessages.length, 1);
assert.equal(codexMessages[0].content, firstPass);
assert.deepEqual(codexMessages[0].mentions, []);
assert.notEqual(codexMessages[0].mentionsUser, true);
assert.deepEqual(
yielded.filter((m) => m.type === 'text').map((m) => m.content),
[firstPass],
'live stream must surface the first-pass text, not the bare event-driven exit patch',
);
});

test('tool-only no-text initial output still gets the remedial guard instead of silent completion', async () => {
const service = createSequenceService('codex', [
[
Expand Down Expand Up @@ -664,6 +699,25 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => {
assert.deepEqual(spokenChunks, ['我先持球继续。'], 'voice TTS should match the preserved live text');
});

test('2b event-driven external wait final slot counts as a routing exit without remedial invoke', async () => {
const service = createSequenceService('codex', [
'cloud / CI 已有结构化回调覆盖,不需要 hold_ball。\n\nExternal Wait: event-driven (pr:clowder-labs/clowder-ai#32)',
'@co-creator',
]);

const { appended, calls } = await runRoute(service, 'thread-routing-guard-event-driven-wait');

assert.equal(calls.length, 1, 'explicit 2b event-driven external wait should not trigger remedial invoke');
assert.equal(
appended.find((m) => m.source?.connector === 'routing-guard-failure'),
undefined,
'event-driven external wait should not emit routing guard failure',
);
const codexMessages = appended.filter((m) => m.catId === 'codex' && m.origin === 'stream');
assert.equal(codexMessages.length, 1);
assert.match(codexMessages[0].content, /External Wait: event-driven/);
});

test('guard-disabled cat still runs once and keeps legacy non-blocking hint behavior', async () => {
const service = createSequenceService('codex', ['I will keep going from here.'], { needsGuard: false });

Expand Down
25 changes: 24 additions & 1 deletion packages/api/test/routing-guard-remedial.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ describe('F177 Phase H — shouldRemediateRouting', () => {
true,
);
});

test('2b External Wait event-driven 槽位 → 不触发 remedial', () => {
assert.equal(
shouldRemediateRouting({
...base,
text: 'cloud / CI 已有结构化回调覆盖。\n\nExternal Wait: event-driven (pr:clowder-ai#32)',
needsGuard: true,
attempted: false,
}),
false,
);
});
});

describe('F177 Phase H — hasValidRoutingExit', () => {
Expand All @@ -88,13 +100,24 @@ describe('F177 Phase H — hasValidRoutingExit', () => {
assert.equal(hasValidRoutingExit({ ...base, structuredTargetCats: ['x'] }), true);
assert.equal(hasValidRoutingExit({ ...base, hasCoCreatorLineStartMention: true }), true);
});

test('External Wait: event-driven(<id>) counts as a valid 2b external-wait exit', () => {
assert.equal(
hasValidRoutingExit({
...base,
text: '结论:已有结构化回调 + EYES>0,不续 hold_ball。\n\nExternal Wait: event-driven (github-pr-32)',
}),
true,
);
});
});

describe('F177 Phase H — buildRemedialPrompt', () => {
test('含路由指引(行首 @ / hold_ball / @co-creator)且明确不重做工作', () => {
test('含路由指引(行首 @ / hold_ball / event-driven / @co-creator)且明确不重做工作', () => {
const p = buildRemedialPrompt();
assert.match(p, /行首/);
assert.match(p, /hold_ball/);
assert.match(p, /event-driven/);
assert.match(p, /@co-creator/);
assert.match(p, /不要重做/);
});
Expand Down
Loading
Loading