From f59e23703583cf98493325ef8bd4e664b8a9311c Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 05:13:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(loop-engine):=20auto=20=E6=94=B6=E6=AD=A5?= =?UTF-8?q?=E8=A1=A5=E4=B8=A4=E5=A4=84=20=E2=80=94=E2=80=94=20=E7=9C=9F?= =?UTF-8?q?=E6=B5=8B=20carded=20=E9=97=A8=20+=20=E5=8D=8A=20auto=20?= =?UTF-8?q?=E4=B8=8D=E6=8A=A2=E6=A8=A1=E5=9E=8B=E7=9A=84=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审 Important:「卡还没发过就不收」那条用例其实没走到 carded 门(命中的是 Task 7 既有的发卡分支),换一条真正让 card 被 when 挡掉、只有别的笔照写的用例才让执行落进 carded 门;原用例改名留作 Task 7 那条路径的回归,逻辑不动。 R9(基于评审 Minor #1 的裁定):收步条件加一项 guide 为空 —— 半 auto(声明了非空 guide)的步,人答过卡之后那份判断仍是模型的活,不该被这段抢在模型前面收掉。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/turn-runner-acceptance.test.ts | 83 ++++++++++++++++++- .../internal/usecase/turn-runner.ts | 7 +- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts index fe39e2f..073e83e 100644 --- a/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts @@ -2313,7 +2313,13 @@ describe('auto 步:配方在拍首跑', () => { expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed'); }); - it('卡还没发过就不收 —— 那才是替人签字', async () => { + /* + * 这条原名「卡还没发过就不收 —— 那才是替人签字」——评审 trace 出它验的其实是 Task 7 + * 既有的「还没发过就发卡」那条路(:1998 `auto.issuedCardId !== undefined` 提前返回), + * 从没走到 Task 8 新加的 `carded` 门(:2019 起那一段)。名字改准,逻辑不动 —— + * 它仍是一条有效回归(证明配方在没发过卡时确实把卡发了出去),只是不再冒充「验过 carded 门」。 + */ + it('还没发过卡 → 这一拍把卡发出去(Task 7 既有路径,不是 carded 门 —— 见下一条)', async () => { const h = harness({ parsed: AUTO_MODEL, runtime: AUTO_RUNTIME, @@ -2333,4 +2339,79 @@ describe('auto 步:配方在拍首跑', () => { expect(h.issued).toHaveLength(1); expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed'); }); + + /* + * **真正把执行推到 :2019 且 carded === false 的用例**(评审 Important)。 + * + * 配方两笔:`project` 无条件照写(worldTouched 不受它影响)、`card` 带 `when` 这一拍算假、 + * 被跳过 —— 卡**没有**发出去,`autoRunner.run` 回的是 `issuedCardId: undefined`。 + * `auto.wrote > 0` 触发一次 doneWhen 重扫,可这一步没声明 doneWhen,重扫不改变局面, + * 于是**第一次**真的落到收步判断那一段:`recipeIssuesCard(current.auto)` 因为配方里 + * 结构上确实有一笔 `card`(不看这一拍它有没有被 `when` 挡住)算出 true,`carded` 却是假 + * ——这道门就是靠 `carded` 才没把它错收。`carded &&` 这个条件若被误删,这条会变红。 + */ + it('配方这一拍卡被 when 挡住没发、只有别的笔照写 → carded 是假,这一步不该被收', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: AUTO_RUNTIME, + sfeelCondition: () => false, + steps: [ + { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + auto: { + do: [ + { kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } }, + { + kind: 'card', + when: '恒假', + as: 'decide', + variant: 'review', + title: '结论审核', + options: [{ value: 'approve', label: '确认通过' }], + basis: [{ k: 'a', v: 'b' }], + }, + ], + }, + }, + ], + }); + const out = await h.runner.runTurn(TASK); + // project 照写,card 被 when 挡掉没发 + expect(h.written).toHaveLength(1); + expect(h.issued).toHaveLength(0); + expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed'); + }); + + /* + * **R9**(基于评审 Minor #1 的裁定):半 auto(声明了非空 `guide`)的步,卡答过了也不该 + * 被这段收掉 —— 半 auto 存在的意义就是「确定的先做完,剩下的判断仍问模型」,人答完卡之后 + * 那份判断(读人写的自由文本、落到具体的行上)恰恰是模型该干的活。 + * 用默认的 `submit` 动作(harness 不传 `action` 时的缺省值):它只发验收卡、不碰项状态, + * 这样「S1 没被收成 Completed」这条断言测的确实是「没被这段收掉」,不是被模型自己收掉的假阳性。 + */ + it('R9:半 auto(有 guide)的步卡也答过了 —— 不该被收在模型之前,那份判断仍是模型的活', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never, + unanswered: [], + sfeelCondition: () => false, + steps: [ + { + item: 'S1', + guide: '把结论落进台账,再判断该不该收步', + writes: [], + reads: [], + tools: [], + auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] }, + }, + ], + }); + const out = await h.runner.runTurn(TASK); + expect(h.decided).toHaveLength(1); + expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed'); + }); }); diff --git a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts index 4374f2f..bc52d9a 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts @@ -2021,10 +2021,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { * 这一步声明了 auto、卡发过(worldTouched)、这一拍没有未答的卡(前面 unanswered 把住)、 * 配方这一拍也没再发新卡。**人已经签了字,收步是照实记录,不是替人签字** —— * 「人工激活项不给 doneWhen」那条纪律防的是引擎抢在人前面,这里人在前面。 + * + * **半 auto(声明了非空 `guide`)的步不走这条近道**(R9,评审 Minor #1 逼出来的裁定): + * 半 auto 存在的意义就是「确定的先做完,剩下的判断仍问模型」—— 人答完卡之后那份判断 + * (读人写的自由文本、落到具体的行上)恰恰是模型该干的活。收在它前面,等于把半 auto + * 的后半截整个删掉。当前没有活动踩到这个组合,但机制不该埋这个坑。 */ const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === ''; const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true; - if (noDoneWhen && carded && recipeIssuesCard(current.auto)) { + if (noDoneWhen && carded && recipeIssuesCard(current.auto) && current.guide.trim() === '') { const active = readyToWork(parsed, stateRuntime, current.item); const settled = transitionItem(active, current.item, 'Completed'); await deps.memory.append({