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 f53af43..b15edc6 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 @@ -63,6 +63,8 @@ type Harness = { readonly pushed: string[]; /** 每次推帧时「已写下几行」—— 帧推在行动之前的话,它是 0 */ readonly writesAtPush: number[]; + /** 每次推帧时携带的 scope(`showView` 现读的 `deps.data.view()`)—— 断言「画面里真的有新行」走这里 */ + readonly pushedScopes: Array>; /** 这一拍写下的全部留痕 —— 断言「被拒/无事发生」走这里 */ readonly appended: Array<{ kind: string; title: string; detail: Record | undefined }>; /** 真被调到的工具名 —— 断言「拦没拦住调用」走这里 */ @@ -110,6 +112,7 @@ function harness( const dropped: Harness['dropped'] = []; const pushed: string[] = []; const writesAtPush: number[] = []; + const pushedScopes: Harness['pushedScopes'] = []; const appended: Harness['appended'] = []; const toolCalls: string[] = []; const listened: Array<{ eventType: string }> = []; @@ -121,6 +124,7 @@ function harness( dropped, pushed, writesAtPush, + pushedScopes, appended, toolCalls, listened, @@ -210,10 +214,11 @@ function harness( window: async () => over.trail ?? [], } as never, views: { - push: async (input: { template: string }) => { + push: async (input: { template: string; scope?: Record }) => { pushed.push(input.template); // 推这一帧时已经写下了几行 —— 帧推早了就是 0(见「推在行动之后」那条) writesAtPush.push(written.length); + pushedScopes.push(input.scope ?? {}); return { pushed: (over.templates ?? ['case-overview']).includes(input.template), templates: over.templates ?? ['case-overview'] }; }, } as never, @@ -2266,6 +2271,45 @@ describe('auto 步:配方在拍首跑', () => { expect(h.issued).toHaveLength(0); }); + /* + * **这一拍真写下过东西,收拍前要把画面重推一遍**(2026-08-30,W05 数据工单浏览器走查实测: + * 人给候选料选好芯片类型、按保存,配方铺出 7 行新参数、红带也点名要填这 7 格 —— 而这条路 + * 从不推帧,任务停在 `awaiting-event`,人刷新页面看到的仍是保存前那份快照(件是服务端渲好 + * 推来的快照,重放不重渲),被点名的格子屏上根本不存在,走查到此卡死。 + * 上一条用例(不发新卡)只断言了 `written`/`issued`,从没问过画面有没有跟上;这一条补上。 + */ + it('有卡在等人交回:配方写下新行 → 收拍前重推画面,画面里带着刚写的那一行', async () => { + const h = harness({ + parsed: AUTO_MODEL, + runtime: AUTO_RUNTIME, + unanswered: [{ id: 'card-9', title: '补数据' }], + sfeelCondition: () => false, + templates: ['input-supplement'], + // 画面的 scope 现读「已写下的那几行」—— 与 `showView` 实际调用 `deps.data.view()` 同一条路 + viewFn: (written) => ({ pns: written.map((w) => w.key['pn']) }), + steps: [ + { + item: 'S1', + guide: '', + writes: [], + reads: [], + tools: [], + view: 'input-supplement', + auto: { + do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'MP2596-5.0-WALK', eau: 1 } }], + }, + }, + ], + }); + const out = await h.runner.runTurn(TASK); + expect(h.written).toHaveLength(1); + expect(out.status).toBe('awaiting'); + // 画面真被重推了一次 —— 不是「一帧不推,留着保存前那份快照」 + expect(h.pushed).toEqual(['input-supplement']); + // 推的时候真带着刚写的那一行,不是拍首、写之前的那份旧 scope + expect(h.pushedScopes.at(-1)?.['pns']).toEqual(['MP2596-5.0-WALK']); + }); + it('配方抛错(地址不在台账声明里)→ 交人,不问模型、不重试', async () => { const h = harness({ parsed: AUTO_MODEL, 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 8157cc5..bb83588 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 @@ -1727,8 +1727,9 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { new Set(runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id)), [...enabled.inProgress, ...enabled.entries], ); + let quietWrote = false; if (quietStep?.auto !== undefined) { - await autoRunner.run(quietStep.auto, { + const outcome = await autoRunner.run(quietStep.auto, { taskId, stepId: quietStep.item, ownerId: (await deps.tasks.of(taskId)).ownerId, @@ -1736,6 +1737,27 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService { quiet: true, carded: true, }); + quietWrote = outcome.wrote > 0 || outcome.dropped > 0; + } + /* + * **这一拍真写下过东西,收拍前把画面重推一遍**(2026-08-30,W05 数据工单浏览器走查 + * 实测:人给候选料选好芯片类型、按保存,上面的配方铺出 7 行新参数、红带也点名要填 + * 这 7 格,而这条路从不推帧 —— 任务停在 `awaiting-event`,人刷新页面看到的仍是保存前 + * 那份快照(件是服务端渲好推来的快照,重放不重渲),被点名的格子屏上根本不存在, + * 走查到此卡死。**这极可能与 e2e 反复撞见的「答完卡不推进」异常同一个短路**: + * 数据工单的下一轮填报要看这一步刚铺出来的新字段,画面不重推,人 / 脚本都无从下手, + * 之后再没有任何事件会把它唤醒。 + * + * 走到这儿状态机什么都没变(quiet 只跑 project/say,不迁移计划项),`runtime` 仍是 + * 拍首那份,画面按 `quietStep` 声明的那张推就够 —— 与「拍首跑配方」发卡那条同一份 + * 判断(下面 `issuedCardId` 那处:「这一步声明了画面才推,没声明就沿用上一帧」)。 + */ + if (quietWrote && quietStep?.view !== undefined && quietStep.view !== '') { + try { + await showView(taskId, quietStep.view, runtime, quietStep.view); + } catch { + // 呈现失败不影响回路:画面丢一帧,事实一件不少(与末尾推帧同一条纪律) + } } await deps.memory.append({ taskId,