脚本 e2e 逼出的八条:闸看错了表 · 拒回喂指针 · 零行骗过闸与哨兵 · 早收不该算撞墙

凭证到位后 `w05` 五案例连跑六轮,逼出八条**浏览器走查逮不到**的缺陷 ——
脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候永远不会去试
「空着交上去会怎样」。末轮:跑到的四例全部 released,三例被拒 0
(第五例停在大模型账户余额不足,不是代码问题)。

平台侧(六条):
- L 数据工单的提交闸没牙 —— 卡没绑表就回落到必交付物,而数据工单要人补的是
  `parts.*` 主数据,不在交付物名单里:闸盯着一堆无关的表、一格不缺,空手交卷一路放行。
  契约新增 `ExecutionWorkOrder.gateLedgers`(这张卡该拦哪几张表,发卡的人说了算),
  `gateAddressesOf` 变三级取一:绑的表 > 卡自报 > 交付物。
- M `finish` 撞 doneWhen 为假的拒回只谈「行与列」,而缺的是一次**工具调用**;
  现在摆出判据原文 + 那几个名字此刻的读数,`tool_*_calls` 读到 0 的单独点名(`unmetWhy`)。
- N 人点了决策卡,模型每拍再发一张,连拒五拍熔断。把「他选的是 approve」写进拒回**没用** ——
  下一拍它又从头翻一次会滚走的窗口。改结构:`answeredCardFact` 把
  「这张卡已答过 → 他选的是什么」做成每拍都在的事实行。
- O 模型传 `requirement: null`,工具 `null.trim()` 当场崩(边界上一律经 `text()` 归一)。
- P 拒回里的 `count=(没写过)` 是 S-FEEL 函数名被当成台账名 —— 假线索模型会去追。
- Q/S 零行 = 零缺,闸与哨兵一起放过空手交卷的数据工单(`emptyGate`:卡自报的表一行都没有
  = 没完成);「早收」头一次只落观察、第二次起才算被拒(与「重发已答过的卡」同一把尺)。

活动侧(1.2.7 → 1.2.9):三张执行工单各自声明 `gateLedgers`;说明书③补「发卡前骨架必须先有行」、
⑤补这张卡的前提(一颗 absent 都不剩);哨兵 `parts_profile_rows >= created` 逐颗对账;
S0_bind 写明发录入卡那一拍别抢写需求行。

e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(服务账号 client_credentials 仍是正路)。
门禁 typecheck / lint / depcruise / test(2091)全绿;api:check 的差异仍只在 org/* 与 api/*
(工作树里先前那批鉴权改动),card.d.ts 是本轮的纯新增字段。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
This commit is contained in:
xcs
2026-08-27 09:31:35 +08:00
co-authored by Claude Opus 5
parent e235207e4a
commit df18882322
16 changed files with 490 additions and 43 deletions
@@ -31,6 +31,37 @@ const pair = (parameterName: string, originalValue: string, candidateValue: stri
...extra,
});
describe('边界上的 null:模型给 null 不该把工具打崩', () => {
it('**requirement / evidenceRef 给 null 照常算**(2026-08-27 W05 e2e 实测:模型写 `requirement: null` 而不是省掉,`null.trim()` 当场崩,整次调用记成「行动被拒」)', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [
{
candidateInternalPn: 'LM2596S-ADJ',
pairs: [
{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '1.23~37', constraintType: 'hard', unit: 'V', requirement: null, evidenceRef: null },
{ parameterName: '输入电压范围', originalValue: '4.5~40', candidateValue: '4.5~40', constraintType: 'hard', unit: 'V', requirement: '>= 40', evidenceRef: 'P-10 Rev H' },
],
},
],
});
const items = (out as { results: Array<{ items: Array<{ parameterName: string; differenceStatus: string }> }> }).results[0]!.items;
expect(items.find((i) => i.parameterName === '输出电压')?.differenceStatus).toBe('conditional');
expect(items.find((i) => i.parameterName === '输入电压范围')?.differenceStatus).toBe('match');
});
it('硬约束的 evidenceRef 给 null = 没给证据 → 不判 MATCH,退成 CONDITIONAL', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
candidates: [
{
candidateInternalPn: 'X',
pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', constraintType: 'hard', evidenceRef: null }],
},
],
});
expect((out as { results: Array<{ items: Array<{ differenceStatus: string }> }> }).results[0]!.items[0]!.differenceStatus).toBe('conditional');
});
});
describe('compare-parameters:逐参数四态', () => {
it('候选温域更宽 → covers → MATCH 低风险;固定 vs 可调 → configurable → CONDITIONAL;未披露 → UNKNOWN', async () => {
const out = await call('part-substitution-assessment.compare-parameters', {
@@ -19,7 +19,7 @@
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
*/
import { describe, expect, it } from 'vitest';
import { findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
import { emptyGate, findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
import { missingReadings } from '../wiring/engines.ts';
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
@@ -284,6 +284,36 @@ describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声
).toEqual(['quote_intake.record', 'quote_intake.verdict']);
});
it('**卡自己声明了 gateLedgers → 看它,不回落到交付物**(2026-08-27 W05 实测:数据工单要补的是 parts.* 主数据,不在必交付物名单里,闸盯着一堆无关的表、一格不缺,空手交卷一路放行)', () => {
expect(
gateAddressesOf(undefined, [{ ledgers: ['sub_eval.input_gap', 'substitution.relation'] }], ['parts.profile', 'parts.parameter']),
).toEqual(['parts.profile', 'parts.parameter']);
});
it('**卡自报的表一行都没有 = 没完成**(2026-08-27 W05 e2e:模型压拍没写骨架就发了数据工单,件上空空如也,而「缺必填」只数已有行 → 零行零缺、闸照开)', () => {
const watched = [
{ domain: 'parts', object: 'profile', label: '芯片主数据' },
{ domain: 'parts', object: 'parameter', label: '标准化参数集' },
];
const declared = ['parts.profile', 'parts.parameter'];
expect(emptyGate({}, watched, declared)?.ready).toBe(false);
expect(emptyGate({}, watched, declared)?.why).toContain('芯片主数据、标准化参数集');
// 两张都有行 → 不归这一段管,交给「缺不缺必填」那一段
expect(emptyGate({ 'mine:parts.profile': [{}], 'mine:parts.parameter': [{}] }, watched, declared)).toBeUndefined();
// 一张有一张没有 → 只点没有的那一张
expect(emptyGate({ 'mine:parts.profile': [{}] }, watched, declared)?.why).toContain('标准化参数集');
});
it('卡没自报(回落到必交付物那条老路)→ 零行不拦人:那条路上零行是常态', () => {
expect(emptyGate({}, [{ domain: 'a', object: 'b' }], [])).toBeUndefined();
});
it('优先级:绑的表 > 卡自报 > 交付物;空串与空白不算数', () => {
expect(gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b'] }], ['parts.profile'])).toEqual(['quote_intake.record']);
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], ['', ' '])).toEqual(['a.b']);
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], [' parts.profile '])).toEqual(['parts.profile']);
});
it('老清单只给 ledger 单张 → 退它那一张;两边都空才真的没有依据', () => {
expect(gateAddressesOf([], [{ ledger: 'quote_sourcing.quote_list' }])).toEqual(['quote_sourcing.quote_list']);
expect(gateAddressesOf([], [{}])).toEqual([]);
+47 -6
View File
@@ -794,33 +794,74 @@ async function submitGate(
if (task === undefined) return undefined;
const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
if (manifest === undefined) return undefined;
const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables);
const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables, order.gateLedgers);
if (addresses.length === 0) return undefined;
const watched = manifest.declarations.ledger.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
if (watched.length === 0) return undefined;
/*
* **卡自己点名要拦的表,「一行都没有」也算没完成**(2026-08-27 W05 e2e 实测)。
*
* 缺必填的算法只数**已有行**缺不缺格子 —— 零行 = 零缺,闸照开。实测那一单:
* 模型压拍,骨架一行没写就把 P-08 数据工单发了出去;人打开件是空的,提交钮却绿着,
* 交上去「主数据已创建」而库里一颗料都没有,一路走到 S2 才被 refersTo 那道墙拦下。
* 只对**卡自报**(`gateLedgers`)那几张表这么判 —— 那是发卡的人明说「这张卡就是来填它的」;
* 回落到必交付物的老路一个字不变(那条路上零行是常态,比如还没到写的时候)。
*/
const declared = (order.gateLedgers ?? []).map((a) => a.trim()).filter((a) => a !== '');
// 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`)
const mine: Record<string, unknown[]> = {};
for (const draft of await container.ledger.taskDrafts(taskId)) {
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
}
return gateOf(missingReadings(mine, watched), watched);
return emptyGate(mine, watched, declared) ?? gateOf(missingReadings(mine, watched), watched);
}
/**
* 闸看哪几张表:卡上绑了表就看绑的那几张;没绑(执行卡零输入之后是常态)
* 回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
* 两边都空才真的没有依据。抽成纯函数是为了钉测 —— 回落断了闸就整个失效,
* 而失效的闸在页面上看不出来(按钮照常绿着)。
* **卡自报要拦的表里,有没有「一行都没有」的** —— 有就是没完成,别的都不用算了。
*
* 「缺必填」的算法只数**已有行**缺不缺格子:零行 = 零缺,闸照开(2026-08-27 W05 e2e 实测)。
* 那一单模型压拍、骨架一行没写就把数据工单发了出去 —— 人打开件是空的、什么也补不了,
* 提交钮却绿着;交上去「主数据已创建」而库里一颗料都没有,一路走到 S2 才被引用检查拦下。
*
* 只对**卡自报**(`gateLedgers`)那几张表这么判:那是发卡的人明说「这张卡就是来填它的」。
* 回落到必交付物的老路一个字不变 —— 那条路上零行是常态(还没轮到写的时候)。
*/
export function emptyGate(
mine: Readonly<Record<string, ReadonlyArray<unknown>>>,
watched: ReadonlyArray<{ domain: string; object: string; label?: string | undefined }>,
declared: ReadonlyArray<string>,
): { ready: boolean; why: string; blanks: string[] } | undefined {
if (declared.length === 0) return undefined;
const empty = watched.filter(
(l) => declared.includes(`${l.domain}.${l.object}`) && (mine[`mine:${l.domain}.${l.object}`] ?? []).length === 0,
);
if (empty.length === 0) return undefined;
const names = empty.map((l) => l.label ?? `${l.domain}.${l.object}`);
return { ready: false, why: `${names.join('、')} 这一单还一行都没有 —— 这张卡就是来建它的,先在件上加一行填好再提交`, blanks: [] };
}
/**
* 闸看哪几张表,三级取一:
* ①卡上绑了表(`blocks`)就看绑的那几张;
* ②**卡自己声明了 `gateLedgers`** 就看它 —— 零输入卡里,只有发卡的人知道这一张卡要人补哪几张表
* (2026-08-27 W05 实测:数据工单要补的是 `parts.*` 主数据,不在必交付物名单里,
* 闸于是盯着一堆与这张卡无关的表、一格不缺、空手交卷照放);
* ③都没有才回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
* 三级全空才真的没有依据 —— **没有依据的时候不拦人**。
* 抽成纯函数是为了钉测:回落断了闸就整个失效,而失效的闸在页面上看不出来(按钮照常绿着)。
*/
export function gateAddressesOf(
blocks: ReadonlyArray<ViewBlock> | undefined,
deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray<string> | undefined }>,
declared?: ReadonlyArray<string> | undefined,
): string[] {
const bound = (blocks ?? [])
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
.map((b) => b.ledger as string);
if (bound.length > 0) return bound;
const own = (declared ?? []).map((a) => a.trim()).filter((a) => a !== '');
if (own.length > 0) return own;
return deliverables.flatMap((d) => [...(d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger]))]);
}
+20 -9
View File
@@ -357,9 +357,20 @@ type ParamPair = {
candidateValue: string;
unit?: string;
constraintType: 'hard' | 'soft';
requirement?: string;
evidenceRef?: string;
/**
* 这两格**模型爱给 null**(「这一项没有客户约束」它写 `requirement: null` 而不是省掉)——
* 工具入参是宽松描述式 schema,null 一路落到这里,`null.trim()` 当场崩,
* 整个工具调用记成「行动被拒」(2026-08-27 W05 e2e 实测,一单撞两次)。
* 类型如实写成可空,取值一律经 `text()`:**边界上的东西按它真会来的样子接**。
*/
requirement?: string | null;
evidenceRef?: string | null;
};
/** 边界上的字符串:null / undefined / 非字符串一律归成空串(模型给 null 是常态,见 ParamPair 上的注) */
function text(v: unknown): string {
return typeof v === 'string' ? v.trim() : '';
}
type CompareCounts = { match: number; mismatch: number; unknown: number; conditional: number; hardMismatch: number; softMismatch: number; configurable: number };
const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
@@ -397,9 +408,9 @@ function compareParsed(o: Parsed, c: Parsed): CompareResult {
}
/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */
function satisfiesRequirement(requirement: string | undefined, c: Parsed): boolean | undefined {
if (requirement === undefined || requirement.trim() === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
const s = requirement.trim();
function satisfiesRequirement(requirement: string | null | undefined, c: Parsed): boolean | undefined {
const s = text(requirement);
if (s === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s);
const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s);
const hi = c.kind === 'range' ? c.hi : c.value;
@@ -415,8 +426,8 @@ function satisfiesRequirement(requirement: string | undefined, c: Parsed): boole
/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */
const LIFECYCLE_ORDER: Record<string, number> = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 };
function compareLifecycle(o: string, c: string): CompareResult | undefined {
const ro = LIFECYCLE_ORDER[o.trim().toLowerCase()];
const rc = LIFECYCLE_ORDER[c.trim().toLowerCase()];
const ro = LIFECYCLE_ORDER[text(o).toLowerCase()];
const rc = LIFECYCLE_ORDER[text(c).toLowerCase()];
if (ro === undefined || rc === undefined) return undefined;
return rc === ro ? 'equal' : rc > ro ? 'covers' : 'differs';
}
@@ -430,11 +441,11 @@ function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStat
const req = satisfiesRequirement(p.requirement, c);
if (req === false) {
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${p.requirement ?? ''}` };
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` };
}
if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' };
if (compareResult === 'equal' || compareResult === 'covers') {
if (hard && (p.evidenceRef === undefined || p.evidenceRef.trim() === '')) {
if (hard && text(p.evidenceRef) === '') {
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' };
}
return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' };
+11 -1
View File
@@ -1002,7 +1002,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
throw new Error(
`${field.label ?? field.name}「${value}」在${refLabel}里不存在 —— ` +
`${target.label} 的这一列只许引用台账里真实存在的值,不许自造编号或占位引用。` +
'要指的那个人/那一行还不存在的话,先把它真实建出来(要经营的人不存在就发数据录入卡收人),' +
'要指的那一行还不存在的话,先把它真实建出来(该派数据工单的派数据工单、该发数据录入卡的发录入卡),' +
'或者把这一笔挂到已存在的那一行名下',
);
}
@@ -1685,6 +1685,15 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
? input.card['steps'].map((x) => String(x)).filter((x) => x.trim() !== '')
: [];
const standard = String(input.card['standard'] ?? '').trim();
/*
* **这张卡该拦哪几张表** —— 零输入卡没有 `blocks`,闸只能回落到必交付物,
* 而数据工单要人补的是 `parts.*` 这类主数据(不在交付物名单里):闸盯着一堆
* 与这张卡无关的表,一格不缺,空手交卷一路放行(2026-08-27 W05 实测)。
* 认不出形状的一律丢掉,不把脏东西端给闸。
*/
const gateLedgers = Array.isArray(input.card['gateLedgers'])
? input.card['gateLedgers'].map((x) => String(x).trim()).filter((x) => /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(x))
: [];
const issued = await deps.humanTool.issueExecution(context, {
title,
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
@@ -1693,6 +1702,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
...(steps.length === 0 ? {} : { steps }),
...(standard === '' ? {} : { standard }),
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
...(gateLedgers.length === 0 ? {} : { gateLedgers }),
});
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash } from '../internal/usecase/turn-runner.ts';
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, earlyFinishTitleOf } from '../internal/usecase/turn-runner.ts';
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
import type { PendingAcceptance } from '../port.ts';
import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
@@ -992,7 +992,7 @@ describe('宣告完成', () => {
* 被拦下)。声明 doneWhen 就是说「这件事完没完可以算出来」,算出来是假,finish 就该被拒——
* 不是让模型自己判断,是让基座去核。
*/
it('声明了 doneWhen 的项:算出来是假,finish 被拒——不许凭一句话绕过基座的判断', async () => {
it('声明了 doneWhen 的项:算出来是假,这一下 finish **不生效**(头一次只落观察,不记被拒)', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
@@ -1005,6 +1005,25 @@ describe('宣告完成', () => {
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Available');
});
/*
* **早收一次不算撞墙,第二次才算**(2026-08-27 W05 e2e:五案例里三例各白吃一次被拒 ——
* 模型把「写行 · 调工具 · finish」排在同一串,工具还没轮到调 finish 先到了,下一拍它自己补上)。
* 与「重发已答过的卡头两次无事发生」同一把尺;真在原地打转的单仍看得见(第二次起照旧拒,喂熔断计数)。
*/
it('同一项第二次早收:照旧被拒 —— 熔断要看得见真卡死的单', async () => {
const h = harness({
parsed: STAGE_MODEL,
runtime: READY_RUNTIME2,
action: finish('FREE'),
steps: [{ item: 'FREE', intent: '干活', guide: '做法', writes: [], reads: [], tools: [], view: 'x', doneWhen: 'some_rows > 0' }],
sfeelCondition: () => false,
trail: [{ kind: 'observation', title: earlyFinishTitleOf('FREE') }],
});
const outcome = await h.runner.runTurn(TASK);
expect(outcome.status).toBe('running');
expect(h.appended.some((m) => m.kind === 'result' && m.title.includes('行动被拒'))).toBe(true);
});
it('声明了 doneWhen 的项:算出来是真,finish 正常收', async () => {
const h = harness({
parsed: STAGE_MODEL,
@@ -1704,6 +1723,58 @@ describe('办完的项:话要说对,按一下不报错', () => {
* 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。
* 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。
*/
describe('「这张卡人已经答过了」摆成每拍都在的事实行', () => {
const sent = (title: string) => ({ kind: 'card-sent', title });
it('**决策卡:把人选的那个值摆出来**(2026-08-27 W05 e2e:五个案例四个死在这里 —— 剧本让模型自己去轨迹里翻回传,而那条痕会被挤走)', () => {
const line = answeredCardFact([sent('请审核结论'), { kind: 'card-reply', title: '请审核结论', detail: { value: 'approve' } }]);
expect(line).toContain('人已经答过了');
expect(line).toContain('他选的是「approve」');
expect(line).toContain('finish');
});
it('执行工单没有值 → 说清「空答复 = 照原样确认」,不编一个选择出来', () => {
const line = answeredCardFact([sent('评估输入确认'), { kind: 'card-reply', title: '评估输入确认' }]);
expect(line).toContain('空答复 = 照原样确认');
expect(line).not.toContain('他选的是');
});
it('回传之后又发了**别的**卡 → 有新问题在等,这条不摆', () => {
const line = answeredCardFact([
sent('第一问'),
{ kind: 'card-reply', title: '第一问', detail: { value: 'approve' } },
sent('另一件完全不同的事'),
]);
expect(line).toBe('');
});
it('一张卡都没答过 → 空', () => {
expect(answeredCardFact([sent('还没答的卡')])).toBe('');
expect(answeredCardFact([])).toBe('');
});
});
describe('doneWhen 为假的拒回:差在哪要说得出口', () => {
it('**缺的是一次工具调用就直说** —— 2026-08-27 W05 e2e:五个案例里三个撞上,拒回只教它去查「行与列」,而缺的是 recommend 没调过', () => {
const why = unmetWhy('rows > 0 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 0 });
expect(why).toContain('缺的不是行也不是列,是一次工具调用');
expect(why).toContain('tool_w05_recommend_calls');
expect(why).toContain('tool_w05_recommend_calls=0');
});
it('工具调过了(读数 > 0)→ 不点它的名,只把判据与读数摆出来', () => {
const why = unmetWhy('rows = 10 and tool_w05_recommend_calls > 0', { rows: 9, tool_w05_recommend_calls: 2 });
expect(why).not.toContain('缺的不是行也不是列');
expect(why).toContain('rows=9');
});
it('判据里压根没有工具那一档 → 照旧只摆判据与读数', () => {
const why = unmetWhy('a_rows = b_rows', { a_rows: 3, b_rows: 4 });
expect(why).toContain('a_rows=3');
expect(why).toContain('b_rows=4');
expect(why).not.toContain('工具调用');
});
});
describe('同一张卡再发一遍的三档', () => {
const sent = (title: string) => ({ kind: 'card-sent', title });
const replied = (title: string) => ({ kind: 'card-reply', title });
@@ -1717,6 +1788,21 @@ describe('同一张卡再发一遍的三档', () => {
expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send');
});
it('**人选的那个值当场摆出来**(2026-08-27 W05 e2e:人点了通过,拒回只给「值在轨迹里」这个指针,模型对不准那一条,连发五次同一张卡熔断)', () => {
const win = [sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: 'approve', note: '试跑代答' } }];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问', chose: 'approve' });
const rejected = resendVerdict([...win, echoed('这一问'), echoed('这一问')], '这一问');
expect(rejected).toEqual({ kind: 'reject', answered: '这一问', chose: 'approve' });
});
it('回传痕里没有值(空串 / 缺 detail)→ 不编,退回原来那句指针话术', () => {
expect(resendVerdict([sent('这一问'), { kind: 'card-reply', title: '这一问', detail: { value: ' ' } }], '这一问')).toEqual({
kind: 'echo',
answered: '这一问',
});
expect(resendVerdict([sent('这一问'), replied('这一问')], '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
});
it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => {
const win = [sent('这一问'), replied('这一问')];
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
@@ -396,7 +396,10 @@ export function criterionKey(raw: string): string {
}
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
/** S-FEEL 保留字与**内建函数名** —— 摆读数时不该把它们当成台账名去查:
* `count(candidateInternalPns)` 里的 `count` 从前被摆成「count=(没写过)」,
* 而那是个函数,不是谁忘了写的一笔账 —— 拒回里多一个假线索,模型就会去追它(2026-08-27 实测)。 */
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between', 'count', 'sum', 'min', 'max', 'all', 'any']);
/**
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
@@ -210,6 +210,30 @@ function rejectReason(title: string): string {
* 削掉它们再比。**不做包含匹配**:两个不同的缺口问句常有很长的公共前缀,
* 一模糊就会把「问 A 料」当成「问 B 料」拦掉,那比多问一遍更坏。
*/
/**
* **doneWhen 为假时,「差在哪」要说得出口** —— 拒回的话是模型下一拍的行动指南。
*
* 从前这条拒回只有一句通用话术:「常见的是该写 N 行只写了 M 行,或某一行缺了必填列」——
* 把注意力全导向**行与列**。而收步判据里那半句常常根本不是行也不是列:
* `tool_<工具>_calls > 0`(2026-08-03 立的「机检只读基座数」扩到工具那一条)。
* 实测(2026-08-27 W05 e2e,五个案例里三个撞上):模型跳过工具、自己把推荐等级填了,
* finish 恒被拒,而拒回教它去查行与列 —— 它只能把同样的明细再写一遍。
* **指向两扇打不开的门,比不指还糟。**
*
* 现在把判据原文与它那几个名字**此刻的读数**一起摆出来(`readingsOf`,与断点提示同一段),
* 并且:凡是 `tool_*_calls` 这一族读数为 0 的,单独点名说「缺的不是行也不是列,是一次工具调用」。
*/
export function unmetWhy(doneWhen: string, scope: Readonly<Record<string, unknown>>): string {
const head = ` 干完的判据:${doneWhen}${readingsOf(doneWhen, scope)}`;
const idle = [...new Set(doneWhen.match(/tool_[A-Za-z0-9_]+_calls/g) ?? [])].filter((name) => Number(scope[name] ?? 0) === 0);
if (idle.length === 0) return head;
return (
`${head} —— **缺的不是行也不是列,是一次工具调用**:${idle.join(' / ')} 现在是 0,` +
'说明这一步该用的工具你还没真调过(调失败不算)。先按剧本调它,拿工具算出来的结果写盘,' +
'别自己填那几个值 —— 这一步的收步判据数的就是这枚工具的调用次数。'
);
}
export function sameQuestion(a: string, b: string): boolean {
// 标点与空白一律削掉:`\p{P}` 连全角标点一起认,逐个列举漏一个就漏一次拦截
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
@@ -229,6 +253,11 @@ export function sameQuestion(a: string, b: string): boolean {
return short.length >= 6 && long.startsWith(short);
}
/** 「还没到收步的时候」那条观察痕的标题 —— 与「已答过没再发」同族:记与数是同一句话 */
export function earlyFinishTitleOf(itemId: string): string {
return `还没到收步的时候,这一下 finish 没生效(${itemId})`;
}
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
export function echoTitleOf(answered: string): string {
return `这张卡人已答过,没有再发(「${answered}」)`;
@@ -246,16 +275,62 @@ export function echoTitleOf(answered: string): string {
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
*/
export function resendVerdict(
window: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
asked: string,
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } {
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string; readonly chose?: string } {
const trimmed = asked.trim();
if (trimmed === '') return { kind: 'send' };
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
const replies = window.filter((t) => t.kind === 'card-reply');
const replied = new Set(replies.map((t) => t.title));
const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed));
if (same === undefined) return { kind: 'send' };
const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length;
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title };
/*
* **他选的是什么,当场摆出来**(2026-08-27 W05 e2e 实测的一单熔断):
* 人点了「通过」,拒回却只说「值在你的轨迹里(card-reply 那一条)」—— 一个**指针**。
* 而模型恰恰对不准那一条(轨迹里躺着好几张卡的 card-reply),于是连发五次同一张卡熔断。
* 与 E04 那一课同源:**指南让模型去找的事实,引擎手里有现成的,就直接给它**。
* 值就在回传痕的 `detail.value` 里(决策卡的载荷,与 payloadOf 同一约定)。
*/
const value = replies.find((t) => t.title === same.title)?.detail?.['value'];
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title, ...(chose === undefined ? {} : { chose }) };
}
/**
* **「这张卡人已经答过了、他选的是什么」——摆成每拍都在的一句事实**(2026-08-27 W05 e2e 逼出来的)。
*
* 剧本里那句「先在轨迹里找这一项已答过、还没处理的 card-reply」是**让模型自己去翻窗口**,
* 而那条回传会被后面的痕挤走:实测五个案例里四个死在同一处 —— 人点了「通过」,
* 模型每拍照剧本第一档「没有回音 → 发第一张决策卡」,同一张卡连发五次熔断;
* 拒回把「他选的是 approve、接下来该写哪面旗」说得再明白也没用,因为**下一拍它又从头翻一次窗口**。
*
* 与 worldTouched 那条(2026-08-21)、E04 的「唯引擎事实行是从」(2026-08-24)同一条治法:
* **指南让模型判的事,引擎手里有现成事实的,一律改成读事实**。
*
* 只在「最后一次卡的往来是回传」时摆(后面又发了别的卡就说明有新问题在等,不摆);
* 决策卡摆出选中的值,执行工单如实说「空答复 = 照原样确认」。
*/
export function answeredCardFact(
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
): string {
let at = -1;
for (let i = window.length - 1; i >= 0; i -= 1) {
if (window[i]?.kind === 'card-reply') {
at = i;
break;
}
}
const reply = at < 0 ? undefined : window[at];
if (reply === undefined) return '';
const newer = window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title));
if (newer) return '';
const value = reply.detail?.['value'];
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`;
return chose === undefined
? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
: `${head} → **他选的是「${chose}」**。别再发一张一样的卡 —— 照说明书处理这个选择(通常是把结论落进台账的那一面记账旗,再 finish 这一项)。`;
}
/**
@@ -537,7 +612,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 那就不该被模型一句「我 finish 了」绕过去,机检只读基座数得出来的数这条铁律,
* 第五次被同一类洞违反(前四次是自报的行数/去向哨兵/入场哨兵/收步判据)。
*/
doneWhenOf?: (itemId: string) => boolean | undefined,
doneWhenOf?: (itemId: string) => { ok: boolean; why: string } | undefined,
/**
* **这个行动身后同一串里还有没排到的行动**(2026-08-22,E04 活单熔断实测)。
* 空转写(值一行都没变)从前一律当场拒,而「被拒停在谁那儿」把整串弃权 ——
@@ -1135,8 +1210,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (verdict.kind === 'reject') {
throw new Error(
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
'他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' +
'照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。',
(verdict.chose === undefined
? '他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):'
: `**他选的是「${verdict.chose}」** —— `) +
'照这个选择往下办:照说明书把结论落进台账(通常是那一面记账旗),' +
'再 finish 这一项。真有**另一个**岔路口要人定,换一件事、换一个问法。',
);
}
await deps.memory.append({
@@ -1145,7 +1223,10 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
title: echoTitle,
detail: {
internal: true,
note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。',
note:
verdict.chose === undefined
? '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。'
: `人已经在这个岔路口选过了,他选的是「${verdict.chose}」。别再发 —— 照这个选择把结论落进台账,再 finish 这一项。`,
},
});
// 什么也没做,局面原样交回
@@ -1283,13 +1364,33 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
* 是让基座去核。算不出来(表达式写坏了)或压根没声明,不拦,原样交给模型判断。
*/
const doneWhenNow = doneWhenOf?.(action.itemId);
if (doneWhenNow === false) {
throw new Error(
if (doneWhenNow?.ok === false) {
const say =
`${action.itemId} 声明了 doneWhen(完没完可以算出来),但现在算出来是假 —— ` +
'不能凭这一下 finish 说它已经办完了。回头看这一步该写的东西是不是漏了一部分' +
'(常见的是:该写 N 行只写了 M 行,或某一行缺了必填列),补齐了 doneWhen 自然为真,' +
'基座会自己收,不必你再 finish。',
);
'不能凭这一下 finish 说它已经办完了。补齐了 doneWhen 自然为真,基座会自己收,不必你再 finish。' +
doneWhenNow.why;
/*
* **早收一次不算撞墙**(2026-08-27 W05 e2e:五案例里三例各白吃一次)——
* 与「重发已答过的卡头两次无事发生」同一把尺(设计律:按钮按两下不该变成墙)。
* 实测那一形是**计划次序**的小错:模型把 [写行 · 调工具 · finish] 排在同一串,
* 工具还没轮到调,finish 先到了;它下一拍自己就补上了 ——
* 这种拍不该记成「行动被拒」去喂熔断计数(那是给真卡死留的)。
* 头一次只落观察(把判据与差在哪原样摆回轨迹,和拒回一个字不差),
* 第二次起照旧拒 —— 真在原地打转的单仍然看得见。
*/
const seen = (await deps.memory.window(taskId, STALL_WINDOW)).filter(
(t) => t.kind === 'observation' && t.title === earlyFinishTitleOf(action.itemId),
).length;
if (seen === 0) {
await deps.memory.append({
taskId,
kind: 'observation',
title: earlyFinishTitleOf(action.itemId),
detail: { internal: true, note: say },
});
return { runtime, status: 'running' };
}
throw new Error(say);
}
const ready = readyToWork(finishModel, runtime, action.itemId);
await deps.memory.append({
@@ -1745,6 +1846,7 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
: [
`【当前这一步 · ${current.item}】${current.intent}`,
currentTouch,
answeredCardFact(trail),
current.guide,
current.writes.length === 0 ? '' : `这一步要写的台账:${current.writes.join('、')}`,
current.tools.length === 0 ? '' : `这一步要调的工具:${current.tools.join('、')}`,
@@ -1976,7 +2078,8 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
const step = stepOf(itemId);
if (step?.doneWhen === undefined || step.doneWhen.trim() === '') return undefined;
try {
return deps.sfeel.condition(step.doneWhen, liveScope);
const ok = deps.sfeel.condition(step.doneWhen, liveScope);
return ok ? { ok, why: '' } : { ok, why: unmetWhy(step.doneWhen, liveScope) };
} catch {
// 谓词写坏了不该拖垮整拍(同 1436 行的口径):算不出来就不拦
return undefined;